diff --git a/src/antigravity_provider/hermes_plugin.py b/src/antigravity_provider/hermes_plugin.py index 69c3148..ece2aaf 100644 --- a/src/antigravity_provider/hermes_plugin.py +++ b/src/antigravity_provider/hermes_plugin.py @@ -27,17 +27,45 @@ def _error_message(exc: Exception) -> str: def antigravity_llm_execution(**kwargs: Any) -> Any: request = kwargs.get("request") or {} next_call = kwargs.get("next_call") - provider = kwargs.get("provider") + provider = kwargs.get("provider") or request.get("provider") + model = kwargs.get("model") or request.get("model") + session_id = kwargs.get("session_id") or request.get("session_id") - # 1. Try routing through Multi-Provider Account Router if enabled AND role is determined + # 1. Try routing through Multi-Provider Account Router if enabled try: from .router import get_router_engine engine = get_router_engine() if engine.config.enabled: + # Check if router has any configured and enabled profiles. + # Empty configuration must not break Hermes and falls through cleanly. + has_active_profiles = bool( + engine.config.profiles and any(p.enabled for p in engine.config.profiles.values()) + ) + if not has_active_profiles: + logger.info("Router has no active profiles; passing call downstream to Hermes") + if callable(next_call): + return next_call(request) + return request + role = kwargs.get("role") or request.get("role") if not role and isinstance(request.get("metadata"), dict): role = request["metadata"].get("role") - resolved_role = engine.resolve_role(request, explicit_role=role) + + if provider and not role and not any(p.provider == provider for p in engine.config.profiles.values()): + logger.info("Explicit provider %r not managed by router; passing call downstream to Hermes", provider) + if callable(next_call): + return next_call(request) + return request + + allow_default_fallback = not provider or provider == "antigravity" + resolved_role, resolution_source = engine.resolve_role_with_source( + request, + explicit_role=role, + model=model, + provider=provider, + session_id=session_id, + fallback_to_default=allow_default_fallback, + ) if not resolved_role: if callable(next_call): @@ -45,8 +73,16 @@ def antigravity_llm_execution(**kwargs: Any) -> Any: return request if resolved_role: - session_id = kwargs.get("session_id") or request.get("session_id") + source_labels = { + "explicit": "явная роль", + "model_match": "по модели и провайдеру", + "session_affinity": "по устойчивости сессии", + "default_fallback": "роль по умолчанию", + } + source_label = source_labels.get(resolution_source, resolution_source) + completion = engine.route_request(request, role=resolved_role, session_id=session_id) + # Исчерпанная цепочка — это отказ роутера, а не ответ модели. # Возвращать её текст Гермесу нельзя: он подменит собой настоящий # ответ провайдера, который Гермес выбрал бы сам, и пользователь @@ -58,9 +94,41 @@ def antigravity_llm_execution(**kwargs: Any) -> Any: resolved_role, completion.get("failover_trail"), ) + try: + from antigravity_provider.router.unified_health import EventLogService + EventLogService.get().log( + category="routing", + message=f"Цепочка для роли '{resolved_role}' исчерпана; вызов передан штатному обработчику Hermes", + details=f"Признак выбора роли: {source_label}", + level="warning", + ) + except Exception: + pass if callable(next_call): return next_call(request) return openai_completion_object(completion) + + # Log successful Hermes routing event with factual role origin and profile + try: + from antigravity_provider.router.unified_health import EventLogService + meta = completion.get("router_metadata", {}) if isinstance(completion, dict) else {} + served_profile = meta.get("profile_id", "default") + served_provider = meta.get("provider", provider or "unknown") + served_model = ( + meta.get("selected_model") + or (meta.get("selection_trace") or {}).get("selected_model") + or model + or "default" + ) + EventLogService.get().log( + category="routing", + message=f"Запрос Hermes направлен роли '{resolved_role}' ({source_label})", + details=f"Профиль: '{served_profile}' ({served_provider}), модель: {served_model}", + level="info", + ) + except Exception: + pass + if isinstance(completion, dict) and "error" in completion and not completion.get("choices"): err_text = format_antigravity_error(completion.get("error")) completion = { diff --git a/src/antigravity_provider/router/action_handler.py b/src/antigravity_provider/router/action_handler.py index 4e8a31c..7daa835 100644 --- a/src/antigravity_provider/router/action_handler.py +++ b/src/antigravity_provider/router/action_handler.py @@ -105,7 +105,7 @@ def do_test_profile(provider: str, profile_id: str) -> Dict[str, Any]: EventLogService.get().log('system', f'Сбой проверки {profile_id} ({model}): {e}', level='error') return {'success': False, 'model': model, 'duration_sec': round(time.time() - t0, 2), 'error': str(e)} -def do_delete_credentials(provider: str, profile_id: str) -> Tuple[bool, str]: +def do_delete_credentials(provider: str, profile_id: str, actor: str = "system") -> Tuple[bool, str]: # Сигнатура get_profile_dir — (profile_id, provider), а здесь её звали # наоборот. Внутри есть костыль, молча исправляющий перестановку, но только # для antigravity, openai-codex и opencode-go. Для grok, claude и local путь @@ -118,9 +118,26 @@ def do_delete_credentials(provider: str, profile_id: str) -> Tuple[bool, str]: if auth_p.is_file(): try: auth_p.unlink() - EventLogService.get().log('account', f'Учетные данные для {profile_id} удалены.', level='warning') + EventLogService.get().log( + 'account', + f'Учетные данные для {profile_id} удалены.', + level='warning', + actor=actor, + action='delete_credentials', + target_profile=profile_id, + outcome='success', + ) return True, f"Учетные данные для '{profile_id}' удалены" except Exception as e: + EventLogService.get().log( + 'account', + f'Ошибка удаления учётных данных {profile_id}: {e}', + level='error', + actor=actor, + action='delete_credentials', + target_profile=profile_id, + outcome='failed', + ) return False, f'Ошибка удаления: {e}' # Отсутствие файла — это не успех удаления. Раньше такой ответ выглядел # для пользователя как «сработало», хотя аккаунт оставался подключённым. @@ -418,7 +435,7 @@ class ActionExecutor: """Shared execution layer for Desktop and Web actions.""" @classmethod - def execute(cls, action: str, data: Dict[str, Any], async_runner: Optional[Callable] = None) -> Dict[str, Any]: + def execute(cls, action: str, data: Dict[str, Any], async_runner: Optional[Callable] = None, actor: str = "user:web") -> Dict[str, Any]: """ Execute the specified action. If async_runner is provided, long actions will be dispatched to it. @@ -678,8 +695,61 @@ class ActionExecutor: return {'ok': res.get('success', False), 'message': res.get('response') or res.get('error'), 'data': res} elif action == 'delete_credentials': - ok, msg = do_delete_credentials(prov, pid) + dry_run = bool(data.get('dry_run', False)) + confirmed = bool(data.get('confirmed', True)) + from antigravity_provider.router.profile_manager import get_profile_auth_path + auth_p = get_profile_auth_path(prov, pid) + if dry_run: + exists = auth_p.is_file() + EventLogService.get().log( + 'security', + f'Сухой прогон удаления учётных данных {pid}', + actor=actor, + action='delete_credentials', + target_profile=pid, + outcome='dry_run', + level='info', + ) + return { + 'ok': True, + 'dry_run': True, + 'message': f"Сухой прогон: будет удалён файл {auth_p.name} ({'найден' if exists else 'не найден'})", + 'data': {'path': str(auth_p), 'exists': exists, 'provider': prov, 'profile_id': pid}, + } + if not confirmed: + EventLogService.get().log( + 'security', + f'Запрошено подтверждение удаления учётных данных {pid}', + actor=actor, + action='delete_credentials', + target_profile=pid, + outcome='denied', + level='warning', + ) + return { + 'ok': False, + 'confirmation_required': True, + 'message': f"Требуется подтверждение удаления учётных данных для '{pid}'", + 'data': {'path': str(auth_p), 'provider': prov, 'profile_id': pid}, + } + + ok, msg = do_delete_credentials(prov, pid, actor=actor) return {'ok': ok, 'message': msg} + + elif action == 'dry_run_delete': + from antigravity_provider.router.security_guard import get_workspace_guard + targets = data.get('paths') or ([data.get('path')] if data.get('path') else []) + guard = get_workspace_guard() + res = guard.dry_run_deletion(targets) + EventLogService.get().log( + 'security', + f"Сухой прогон удаления: {res['total_files']} файлов, {res['total_dirs']} каталогов (риск: {res['risk_level']})", + actor=actor, + action='dry_run_delete', + outcome='dry_run', + level='info', + ) + return {'ok': True, 'message': 'Сухой прогон выполнен', 'data': res} elif action == 'auto_assign_all': if async_runner: diff --git a/src/antigravity_provider/router/model_registry.py b/src/antigravity_provider/router/model_registry.py index aa55b86..a59b077 100644 --- a/src/antigravity_provider/router/model_registry.py +++ b/src/antigravity_provider/router/model_registry.py @@ -231,12 +231,57 @@ class ModelRegistry: quota_bucket="antigravity.gemini", quality_tier=5, ), + ModelDescriptor( + model_id="google-antigravity/gemini-3.1-pro-high", + display_name="Gemini 3.1 Pro (High)", + provider="antigravity", + family="gemini", + capabilities=["coding", "reasoning", "tools", "structured_output", "long_context", "planning", "security_analysis", "developer-2", "reviewer"], + context_window=1000000, + supports_tools=True, + supports_reasoning=True, + latency_class="medium", + cost_input_per_m=1.25, + cost_output_per_m=5.0, + quota_bucket="antigravity.gemini", + quality_tier=5, + ), + ModelDescriptor( + model_id="google-antigravity/gemini-3.1-pro-low", + display_name="Gemini 3.1 Pro (Low)", + provider="antigravity", + family="gemini", + capabilities=["coding", "reasoning", "tools", "structured_output", "long_context", "developer-2"], + context_window=1000000, + supports_tools=True, + supports_reasoning=True, + latency_class="low", + cost_input_per_m=1.25, + cost_output_per_m=5.0, + quota_bucket="antigravity.gemini", + quality_tier=4, + ), ModelDescriptor( model_id="google-antigravity/gemini-3.7-flash", display_name="Gemini 3.7 Flash", provider="antigravity", family="gemini", - capabilities=["coding", "reasoning", "tools", "structured_output", "classification", "routing", "long_context"], + capabilities=["coding", "reasoning", "tools", "structured_output", "classification", "routing", "long_context", "developer-1"], + context_window=1000000, + supports_tools=True, + supports_reasoning=True, + latency_class="ultra_low", + cost_input_per_m=0.15, + cost_output_per_m=0.6, + quota_bucket="antigravity.gemini", + quality_tier=4, + ), + ModelDescriptor( + model_id="google-antigravity/gemini-3.7-flash-high", + display_name="Gemini 3.7 Flash (High)", + provider="antigravity", + family="gemini", + capabilities=["coding", "reasoning", "tools", "structured_output", "classification", "routing", "long_context", "developer-1"], context_window=1000000, supports_tools=True, supports_reasoning=True, @@ -262,6 +307,51 @@ class ModelRegistry: quality_tier=5, ), # Google Antigravity (Claude family inside AGY) + ModelDescriptor( + model_id="google-antigravity/claude-opus-4-6-thinking", + display_name="Claude Opus 4.6 (Thinking)", + provider="antigravity", + family="claude", + capabilities=["coding", "reasoning", "tools", "structured_output", "security_analysis", "planning", "code-reviewer", "reviewer"], + context_window=200000, + supports_tools=True, + supports_reasoning=True, + latency_class="high", + cost_input_per_m=15.0, + cost_output_per_m=75.0, + quota_bucket="antigravity.claude", + quality_tier=5, + ), + ModelDescriptor( + model_id="google-antigravity/claude-opus-4-6", + display_name="Claude Opus 4.6 (AGY)", + provider="antigravity", + family="claude", + capabilities=["coding", "reasoning", "tools", "structured_output", "security_analysis", "planning", "code-reviewer", "reviewer"], + context_window=200000, + supports_tools=True, + supports_reasoning=True, + latency_class="high", + cost_input_per_m=15.0, + cost_output_per_m=75.0, + quota_bucket="antigravity.claude", + quality_tier=5, + ), + ModelDescriptor( + model_id="google-antigravity/claude-sonnet-4-6", + display_name="Claude Sonnet 4.6 (Thinking)", + provider="antigravity", + family="claude", + capabilities=["coding", "reasoning", "tools", "structured_output", "security_analysis", "planning"], + context_window=200000, + supports_tools=True, + supports_reasoning=True, + latency_class="medium", + cost_input_per_m=3.0, + cost_output_per_m=15.0, + quota_bucket="antigravity.claude", + quality_tier=5, + ), ModelDescriptor( model_id="google-antigravity/claude-3-7-sonnet", display_name="Claude 3.7 Sonnet (AGY)", diff --git a/src/antigravity_provider/router/role_registry.py b/src/antigravity_provider/router/role_registry.py index e60b496..7d98987 100644 --- a/src/antigravity_provider/router/role_registry.py +++ b/src/antigravity_provider/router/role_registry.py @@ -52,7 +52,8 @@ CANONICAL_ROLES: Dict[str, RoleDefinition] = { is_implemented=True, capabilities=["coding", "developer-1", "reasoning", "tools", "structured_output"], fallback_capabilities=["coding"], - default_preferred_chain=["codex-worker-1", "claude-worker-1", "ag-w1", "opengo-1"], + default_preferred_chain=["ag-w1", "codex-worker-1", "claude-worker-1", "opengo-1"], + default_model="gemini-3.7-flash", max_failover_attempts=3, tier="core", ), @@ -64,7 +65,8 @@ CANONICAL_ROLES: Dict[str, RoleDefinition] = { is_implemented=True, capabilities=["coding", "developer-2", "reviewer", "tools"], fallback_capabilities=["coding", "reviewer"], - default_preferred_chain=["ag-w1", "grok-worker-1", "codex-worker-2", "opengo-3"], + default_preferred_chain=["ag-w2", "codex-worker-2", "grok-worker-1", "opengo-3"], + default_model="gemini-3.1-pro-high", max_failover_attempts=3, tier="core", ), @@ -76,7 +78,8 @@ CANONICAL_ROLES: Dict[str, RoleDefinition] = { is_implemented=True, capabilities=["code-reviewer", "reviewer", "coding", "security_analysis"], fallback_capabilities=["reviewer", "coding"], - default_preferred_chain=["claude-worker-2", "codex-worker-2", "ag-w2", "opengo-2"], + default_preferred_chain=["ag-w3", "ag-w2", "claude-worker-2", "codex-worker-2", "opengo-2"], + default_model="claude-opus-4-6-thinking", max_failover_attempts=3, tier="core", ), diff --git a/src/antigravity_provider/router/router_config.py b/src/antigravity_provider/router/router_config.py index bd48408..50bcbb6 100644 --- a/src/antigravity_provider/router/router_config.py +++ b/src/antigravity_provider/router/router_config.py @@ -36,7 +36,7 @@ class RolePolicy: @dataclass class RouterConfig: enabled: bool = True - default_role: str = "orchestrator" + default_role: str = "manager" quota_cooldown_seconds: int = 1800 # 30 min default rate_limit_cooldown_seconds: int = 60 # 1 min default max_failover_attempts: int = 3 @@ -277,7 +277,7 @@ def get_default_router_config() -> RouterConfig: return RouterConfig( enabled=True, - default_role="orchestrator", + default_role="manager", roles=roles, profiles=profiles, ) @@ -343,7 +343,8 @@ def load_router_config(config_path: Optional[Path] = None) -> RouterConfig: } enabled = bool(r_block.get("enabled", data.get("enabled", True))) - default_role = str(r_block.get("default_role", data.get("default_role", "orchestrator"))) + raw_default_role = str(r_block.get("default_role", data.get("default_role", "manager"))).strip().lower() + default_role = RoleRegistry.resolve_canonical_role(raw_default_role) if raw_default_role else "manager" max_failover = int(r_block.get("max_failover_attempts", data.get("max_failover_attempts", 3))) cooldown_base = int(r_block.get("cooldown_base_seconds", data.get("cooldown_base_seconds", 300))) cooldown_max = int(r_block.get("cooldown_max_seconds", data.get("cooldown_max_seconds", 3600))) diff --git a/src/antigravity_provider/router/router_engine.py b/src/antigravity_provider/router/router_engine.py index 9757238..780ab89 100644 --- a/src/antigravity_provider/router/router_engine.py +++ b/src/antigravity_provider/router/router_engine.py @@ -35,21 +35,117 @@ class RouterEngine: if self.affinity and hasattr(self.affinity, "ttl_seconds"): self.affinity.ttl_seconds = self.config.session_affinity_ttl_seconds - def resolve_role(self, request: Dict[str, Any], explicit_role: Optional[str] = None) -> Optional[str]: - """Determine logical role from explicit parameter, request payload, or metadata. + def resolve_role_with_source( + self, + request: Dict[str, Any], + explicit_role: Optional[str] = None, + model: Optional[str] = None, + provider: Optional[str] = None, + session_id: Optional[str] = None, + fallback_to_default: bool = False, + ) -> Tuple[Optional[str], str]: + """Determine logical role and factual reason from parameters, configuration, or session. - Returns None if role cannot be reliably determined (no guessing from prompts). + Resolution order: + 1. Explicit role in parameters, request, or metadata -> ("role", "explicit") + 2. By model and provider matched dynamically against router configuration -> ("role", "model_match") + 3. By session affinity for session_id -> ("role", "session_affinity") + 4. Configurable default role -> ("default_role", "default_fallback") if fallback_to_default else (None, "none") + + Zero prompt guessing. """ - if explicit_role: - return explicit_role.strip().lower() - if "role" in request and request["role"]: - return str(request["role"]).strip().lower() - if "personality" in request and request["personality"]: - return str(request["personality"]).strip().lower() - metadata = request.get("metadata", {}) - if isinstance(metadata, dict) and metadata.get("role"): - return str(metadata["role"]).strip().lower() - return None + from .role_registry import RoleRegistry + + # 1. Explicit role + raw_role = explicit_role + if not raw_role and "role" in request and request["role"]: + raw_role = str(request["role"]) + if not raw_role and "personality" in request and request["personality"]: + raw_role = str(request["personality"]) + if not raw_role: + metadata = request.get("metadata", {}) + if isinstance(metadata, dict) and metadata.get("role"): + raw_role = str(metadata["role"]) + if raw_role and str(raw_role).strip(): + canon_role = RoleRegistry.resolve_canonical_role(str(raw_role).strip().lower()) + return canon_role, "explicit" + + # 2. By model and provider (derived dynamically from router config, zero hardcoded literals) + req_model = str(model or request.get("model") or "").strip() + req_prov = str(provider or request.get("provider") or "").strip().lower() + + if req_model: + import re + + def _clean_m(m: str) -> str: + return m.split("/")[-1].strip().lower() + + def _base_m(m: str) -> str: + short = _clean_m(m) + return re.sub(r"-(high|medium|low|none|thought|thinking)(?:-(high|medium|low|none))?$", "", short) + + req_m_clean = _clean_m(req_model) + req_m_base = _base_m(req_model) + + # 2a. Direct match with role default_model + for rname, rpolicy in self.config.roles.items(): + if rpolicy.default_model: + def_m_clean = _clean_m(rpolicy.default_model) + def_m_base = _base_m(rpolicy.default_model) + if req_m_clean == def_m_clean or (req_m_base and req_m_base == def_m_base): + if not req_prov: + return RoleRegistry.resolve_canonical_role(rname), "model_match" + primary_pcfg = self.config.get_profile(rpolicy.preferred_chain[0]) if rpolicy.preferred_chain else None + if primary_pcfg and primary_pcfg.provider.lower() == req_prov: + return RoleRegistry.resolve_canonical_role(rname), "model_match" + + # 2b. Match with preferred_models of profiles in role's chain + for rname, rpolicy in self.config.roles.items(): + for pid in rpolicy.preferred_chain: + pcfg = self.config.get_profile(pid) + if pcfg and pcfg.preferred_models: + if req_prov and pcfg.provider.lower() != req_prov: + continue + for pm in pcfg.preferred_models: + pm_clean = _clean_m(pm) + pm_base = _base_m(pm) + if req_m_clean == pm_clean or (req_m_base and req_m_base == pm_base): + return RoleRegistry.resolve_canonical_role(rname), "model_match" + + # 3. By session affinity + target_session = session_id or self.resolve_session_id(request) + if target_session and self.affinity: + aff_rec = self.affinity.get_affinity(target_session) + if aff_rec and getattr(aff_rec, "role", None): + return RoleRegistry.resolve_canonical_role(aff_rec.role), "session_affinity" + + # 4. Default fallback role + if fallback_to_default: + from .settings_service import get_hub_settings + def_role = get_hub_settings().get("default_role") or self.config.default_role or "manager" + return RoleRegistry.resolve_canonical_role(str(def_role).strip().lower()), "default_fallback" + + return None, "none" + + def resolve_role( + self, + request: Dict[str, Any], + explicit_role: Optional[str] = None, + model: Optional[str] = None, + provider: Optional[str] = None, + session_id: Optional[str] = None, + fallback_to_default: bool = False, + ) -> Optional[str]: + """Determine logical role from explicit parameter, request payload, metadata, model/provider, or session.""" + role, _ = self.resolve_role_with_source( + request=request, + explicit_role=explicit_role, + model=model, + provider=provider, + session_id=session_id, + fallback_to_default=fallback_to_default, + ) + return role def resolve_session_id(self, request: Dict[str, Any], explicit_session_id: Optional[str] = None) -> Optional[str]: if explicit_session_id: @@ -69,7 +165,15 @@ class RouterEngine: session_id: Optional[str] = None, ) -> Dict[str, Any]: """Execute request with session affinity and role-aware failover.""" - target_role = self.resolve_role(request, role) or self.config.default_role + target_role, _ = self.resolve_role_with_source( + request, + explicit_role=role, + model=request.get("model"), + provider=request.get("provider"), + session_id=session_id, + fallback_to_default=True, + ) + target_role = target_role or self.config.default_role or "manager" target_session = self.resolve_session_id(request, session_id) role_policy = self.config.get_role_policy(target_role) diff --git a/src/antigravity_provider/router/security_guard.py b/src/antigravity_provider/router/security_guard.py new file mode 100644 index 0000000..186b7bb --- /dev/null +++ b/src/antigravity_provider/router/security_guard.py @@ -0,0 +1,573 @@ +"""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", +} + + +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 = Path(path).expanduser().resolve() + 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 = Path(path).expanduser().resolve() + # 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]) + """ + try: + target = Path(path).expanduser().resolve() + except Exception as exc: + return False, f"Недопустимый путь '{path}': {exc}", "Используйте стандартный относительный путь" + + # 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) + else: + try: + # Windows and POSIX-compatible shlex split + tokens = shlex.split(cmd_line, posix=(os.name != "nt")) + except Exception: + tokens = cmd_line.split() + + 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 (os.name == "nt" 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" не считался + # абсолютным путём, склеивался с каталогом проекта в путь с + # буквальным "~" внутри и признавался допустимым. Проверено: + # команда с тильдой проходила, та же команда с абсолютным + # путём отклонялась. То есть самый естественный способ + # написать опасную команду обходил защиту ровно там, ради + # чего она и делалась — на каталоге учётных данных. + expanded = os.path.expandvars(os.path.expanduser(target_arg)) + target_path = Path(expanded) if Path(expanded).is_absolute() else (base_cwd / expanded) + 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 diff --git a/src/antigravity_provider/router/settings_service.py b/src/antigravity_provider/router/settings_service.py index 1fe66e3..e7f1e41 100644 --- a/src/antigravity_provider/router/settings_service.py +++ b/src/antigravity_provider/router/settings_service.py @@ -23,6 +23,7 @@ DEFAULT_SETTINGS: Dict[str, Any] = { "quota_threshold_percent": 10.0, "quota_threshold_action": "notify", "email_masking_mode": "none", + "default_role": "manager", } @@ -105,6 +106,9 @@ def get_hub_settings() -> Dict[str, Any]: email_mode = "none" merged["email_masking_mode"] = email_mode + default_role = str(merged.get("default_role", "manager")).strip().lower() + merged["default_role"] = default_role or "manager" + _SETTINGS_CACHE = dict(merged) _SETTINGS_CACHE_MTIME = current_mtime _SETTINGS_CACHE_PATH = sfile_str diff --git a/src/antigravity_provider/router/unified_health.py b/src/antigravity_provider/router/unified_health.py index f6ee1dc..3b21b18 100644 --- a/src/antigravity_provider/router/unified_health.py +++ b/src/antigravity_provider/router/unified_health.py @@ -197,16 +197,22 @@ class SystemReadiness: # ═══════════════════════════════════════════════════════════════ -# Event Log Service +# ═══════════════════════════════════════════════════════════════ +# Event Log Service & Forensic Audit # ═══════════════════════════════════════════════════════════════ @dataclass class HubEvent: timestamp: str - category: str # account | quota | routing | auth | system + category: str # account | quota | routing | auth | system | security message: str details: Optional[str] = None level: str = "info" # info | warning | error | success + actor: str = "system" # user:web | reviewer:manual | agent: | system + action: Optional[str] = None + target_profile: Optional[str] = None + target_role: Optional[str] = None + outcome: str = "success" # success | denied | failed | dry_run class EventLogService: @@ -226,9 +232,36 @@ class EventLogService: cls._instance = cls() return cls._instance - def log(self, category: str, message: str, details: Optional[str] = None, level: str = "info"): + def log( + self, + category: str, + message: str, + details: Optional[str] = None, + level: str = "info", + actor: str = "system", + action: Optional[str] = None, + target_profile: Optional[str] = None, + target_role: Optional[str] = None, + outcome: str = "success", + ): + from antigravity_provider.router.security_guard import scrub_string + ts = time.strftime("%H:%M:%S") - event = HubEvent(timestamp=ts, category=category, message=message, details=details, level=level) + clean_msg = scrub_string(str(message)) + clean_details = scrub_string(str(details)) if details is not None else None + + event = HubEvent( + timestamp=ts, + category=category, + message=clean_msg, + details=clean_details, + level=level, + actor=str(actor or "system"), + action=str(action) if action else None, + target_profile=str(target_profile) if target_profile else None, + target_role=str(target_role) if target_role else None, + outcome=str(outcome or "success"), + ) with self._lock: self._events.append(event) # Cap at last 200 events @@ -251,8 +284,9 @@ class EventLogService: log_file = paths.get_log_file() clean_msg = sanitize_text(event.message) clean_details = sanitize_text(event.details) if event.details else None + actor_tag = f" [{event.actor}]" if event.actor != "system" else "" with open(log_file, "a", encoding="utf-8") as f: - f.write(f"[{event.timestamp}] [{event.category.upper()}] [{event.level.upper()}] {clean_msg}\n") + f.write(f"[{event.timestamp}] [{event.category.upper()}]{actor_tag} [{event.level.upper()}] {clean_msg}\n") if clean_details: f.write(f" Details: {clean_details}\n") except Exception: diff --git a/src/antigravity_provider/router/web/server.py b/src/antigravity_provider/router/web/server.py index 9a1f337..eb8aca1 100644 --- a/src/antigravity_provider/router/web/server.py +++ b/src/antigravity_provider/router/web/server.py @@ -55,10 +55,11 @@ app = FastAPI(title="Hermes Hub Web API", version="1.0.0") # Собственному интерфейсу CORS не нужен: он отдаётся тем же сервером. Список # разрешённых источников оставлен настройкой — он понадобится, когда одна # панель будет смотреть на несколько хабов. +_raw_cors = str(_bootstrap_settings().get("web_api_allowed_origins", "")).split(",") _cors_origins = [ o.strip() - for o in str(_bootstrap_settings().get("web_api_allowed_origins", "")).split(",") - if o.strip() + for o in _raw_cors + if o.strip() and o.strip() != "*" ] if _cors_origins: app.add_middleware( @@ -66,7 +67,7 @@ if _cors_origins: allow_origins=_cors_origins, allow_credentials=True, allow_methods=["GET", "POST", "OPTIONS"], - allow_headers=["X-Hub-Token", "Content-Type"], + allow_headers=["X-Hub-Token", "Content-Type", "X-Hub-Actor"], ) @@ -194,6 +195,19 @@ def get_snapshot(authorized: bool = Depends(get_auth_token)): snap_dict = dataclasses.asdict(snapshot) snap_dict = sanitize_snapshot(snap_dict) + server_host = _web_settings().get("web_api_host", "127.0.0.1") + is_external = (server_host != "127.0.0.1" and server_host != "localhost") + snap_dict["network_security"] = { + "is_external_bind": is_external, + "is_tls": False, + "host": server_host, + "warning": ( + f"Внимание: Web API привязан к внешнему сетевому интерфейсу ({server_host}) поверх открытого HTTP. Токен авторизации и почты аккаунтов передаются по сети в открытом виде. Рекомендуется использовать HTTPS, VPN или SSH-туннель." + if is_external + else None + ), + } + return JSONResponse(content=jsonable_encoder(snap_dict)) @app.post("/api/action") @@ -210,7 +224,8 @@ async def handle_action(request: Request, authorized: bool = Depends(get_auth_to def _async_runner(func, name): threading.Thread(target=func, name=name, daemon=True).start() - result = ActionExecutor.execute(action, data.get("data", {}), async_runner=_async_runner) + actor = request.headers.get("X-Hub-Actor") or (f"web:{request.client.host}" if request.client else "user:web") + result = ActionExecutor.execute(action, data.get("data", {}), async_runner=_async_runner, actor=actor) if result.get("unknown"): raise HTTPException(status_code=404, detail="Неизвестное действие") @@ -261,8 +276,10 @@ def get_settings(authorized: bool = Depends(get_auth_token)): raw = _web_settings() has_token = bool(raw.get("web_api_token")) last_check = UpdateManager.get_last_check_result() + server_host = raw.get("web_api_host", "127.0.0.1") + is_external = (server_host != "127.0.0.1" and server_host != "localhost") settings_out: Dict[str, Any] = { - "web_api_host": raw.get("web_api_host", "127.0.0.1"), + "web_api_host": server_host, "web_api_port": raw.get("web_api_port", 5800), "web_api_token_configured": has_token, "theme": raw.get("theme", "system"), @@ -273,6 +290,16 @@ def get_settings(authorized: bool = Depends(get_auth_token)): "installed_commit": get_installed_commit(), "version": __version__, "last_update_check": last_check.to_dict() if last_check else None, + "network_security": { + "is_external_bind": is_external, + "is_tls": False, + "host": server_host, + "warning": ( + f"Внимание: Web API привязан к внешнему сетевому интерфейсу ({server_host}) поверх открытого HTTP. Токен авторизации и почты аккаунтов передаются по сети в открытом виде. Рекомендуется использовать HTTPS, VPN или SSH-туннель." + if is_external + else None + ), + }, } for k, v in raw.items(): if k not in settings_out and not any(secret in k.lower() for secret in ['token', 'secret', 'key', 'password', 'jwt']): diff --git a/src/antigravity_provider/router/workflow_service.py b/src/antigravity_provider/router/workflow_service.py index 99059d9..214ebf3 100644 --- a/src/antigravity_provider/router/workflow_service.py +++ b/src/antigravity_provider/router/workflow_service.py @@ -1,13 +1,9 @@ -"""Persistent agents, workflow graph and live execution for Hermes Hub. - -The router role registry remains the source of truth for logical agents and -Provider -> Account -> Model assignment. This module adds the pieces that do -not fit the routing schema: Agent Files, editor layout, workflow transitions, -execution checkpoints and a bounded event journal. -""" +"""Hermes Hub A30 / A36 workflow runtime and persistence layer.""" from __future__ import annotations +import datetime import json +import logging import re import threading import time @@ -17,75 +13,94 @@ from pathlib import Path from typing import Any, Optional from antigravity_provider import paths -from antigravity_provider.router.router_config import RolePolicy, load_router_config, save_router_config +from antigravity_provider.router.router_config import ( + RolePolicy, + load_router_config, + save_router_config, +) + +logger = logging.getLogger("hermes.router.workflow") -AGENT_STATES = {"waiting", "working", "reviewing", "error", "completed", "not_implemented"} -EDGE_CONDITIONS = {"SUCCESS", "REVIEW_PASSED", "REVIEW_FAILED", "NEXT", "ERROR", "ALWAYS"} +_AUTH_BEARER_RE = re.compile(r"Bearer\s+[A-Za-z0-9._~+/-]+", re.IGNORECASE) +_ACCESS_TOKEN_PARAM_RE = re.compile(r"(access_token=)[^&]+", re.IGNORECASE) + + +def sanitize_run_data(data: Any) -> Any: + """Sanitize data for saving to workflow_run_state.json, masking secrets and tokens.""" + if isinstance(data, dict): + result = {} + for k, v in data.items(): + k_lower = str(k).lower() + if any(s in k_lower for s in ["api_key", "token", "password", "secret", "client_secret", "jwt"]): + result[k] = "***" + elif isinstance(v, (dict, list)): + result[k] = sanitize_run_data(v) + elif isinstance(v, str): + s_val = _AUTH_BEARER_RE.sub("Bearer ***", v) + s_val = _ACCESS_TOKEN_PARAM_RE.sub(r"\1***", s_val) + result[k] = s_val + else: + result[k] = v + return result + elif isinstance(data, list): + return [sanitize_run_data(item) for item in data] + elif isinstance(data, str): + s_val = _AUTH_BEARER_RE.sub("Bearer ***", data) + s_val = _ACCESS_TOKEN_PARAM_RE.sub(r"\1***", s_val) + return s_val + return data + + +def get_last_run_state(path: Optional[Path] = None) -> Optional[dict[str, Any]]: + """Return the last saved run state from workflow_run_state.json.""" + target_path = path or paths.get_workflow_run_state_path() + if not target_path.is_file(): + return None + try: + data = json.loads(target_path.read_text(encoding="utf-8")) + return data if isinstance(data, dict) else None + except Exception: + return None + +EDGE_CONDITIONS = { + "SUCCESS", + "ALWAYS", + "NEXT", + "REVIEW_PASSED", + "REVIEW_FAILED", + "ERROR", + "COMPLETED", + "ACCEPTED", +} def _utc_timestamp() -> str: - import datetime - - return datetime.datetime.now(datetime.timezone.utc).isoformat(timespec="seconds") + return datetime.datetime.now(datetime.timezone.utc).isoformat() def _slug(value: str) -> str: - result = re.sub(r"[^a-z0-9]+", "-", value.strip().lower()).strip("-") - return result or f"agent-{uuid.uuid4().hex[:8]}" + cleaned = re.sub(r"[^a-zA-Z0-9_-]+", "-", value.strip()).strip("-").lower() + return cleaned or f"agent-{uuid.uuid4().hex[:6]}" -def sanitize_run_data(node: Any) -> Any: - """Recursively strip or mask any credentials or secret keys from workflow run state.""" - secret_key_substrings = [ - "api_key", "token", "password", "secret", "jwt", "bearer", - "access_token", "refresh_token", "client_secret", "authorization", - ] - if isinstance(node, dict): - sanitized: dict[str, Any] = {} - for k, v in node.items(): - k_lower = str(k).lower() - if any(s in k_lower for s in secret_key_substrings) and k_lower not in ("auth_status", "author", "auth_required"): - sanitized[k] = "***" - else: - sanitized[k] = sanitize_run_data(v) - return sanitized - elif isinstance(node, list): - return [sanitize_run_data(x) for x in node] - elif isinstance(node, str): - val = node - val = re.sub(r'Bearer\s+[a-zA-Z0-9_\-\.]{8,}', 'Bearer ***', val, flags=re.IGNORECASE) - val = re.sub(r'sk-[a-zA-Z0-9_\-]{8,}', 'sk-***', val) - val = re.sub(r'gho_[a-zA-Z0-9_\-]{8,}', 'gho_***', val) - val = re.sub(r'((?:access_token|refresh_token|api_key|token|password|secret|key)=)([^\s&,"]+)', r'\g<1>***', val, flags=re.IGNORECASE) - return val - return node +def _safe_agent_file(path_str: str, agent_id: str) -> tuple[Path, str]: + home = paths.get_hermes_home().resolve() + if not path_str or not path_str.strip(): + relative = f"agents/{_slug(agent_id)}.md" + return (home / relative).resolve(), relative + raw = Path(path_str.strip()) + if raw.is_absolute(): + resolved = raw.resolve() + else: + resolved = (home / raw).resolve() -def get_last_run_state(run_state_path: Optional[Path] = None) -> Optional[dict[str, Any]]: - """Return the last saved workflow run state from workflow_run_state.json, if any.""" - p = run_state_path or paths.get_workflow_run_state_path() - if not p.is_file(): - return None try: - data = json.loads(p.read_text(encoding="utf-8")) - if isinstance(data, dict): - return sanitize_run_data(data) - except Exception: - pass - return None - - -def _safe_agent_file(value: str, agent_id: str) -> tuple[Path, str]: - """Resolve an Agent File below HERMES_HOME/agents and reject traversal.""" - root = paths.get_agent_files_dir().resolve() - candidate_name = Path(value or f"{agent_id}.md").name - if not candidate_name.lower().endswith(".md"): - candidate_name += ".md" - target = (root / candidate_name).resolve() - if target.parent != root: - raise ValueError("Agent File должен находиться в каталоге agents") - return target, f"agents/{candidate_name}" + rel = resolved.relative_to(home).as_posix() + except ValueError as exc: + raise ValueError("Agent File должен находиться внутри каталога HERMES_HOME") from exc + return resolved, rel @dataclass @@ -112,6 +127,7 @@ class WorkflowEdge: target: str condition: str = "SUCCESS" label: str = "" + max_iterations: Optional[int] = None @dataclass @@ -124,6 +140,62 @@ class WorkflowDefinition: start_agent_id: Optional[str] = None +def get_canonical_a36_pipeline() -> WorkflowDefinition: + """Return canonical Antigravity 4-agent workflow with nested feedback loops.""" + return WorkflowDefinition( + id="a36-pipeline", + name="Конвейер Antigravity (Оркестратор, 2 кодера, ревьюер)", + start_agent_id="manager", + max_iterations=5, + edges=[ + WorkflowEdge( + id="edge-manager-to-dev1", + source="manager", + target="developer-1", + condition="SUCCESS", + label="Постановка задачи", + ), + WorkflowEdge( + id="edge-dev1-to-dev2", + source="developer-1", + target="developer-2", + condition="SUCCESS", + label="Реализация на проверку", + ), + WorkflowEdge( + id="edge-dev2-to-dev1", + source="developer-2", + target="developer-1", + condition="REVIEW_FAILED", + label="Доработка Кодеру 1", + max_iterations=5, + ), + WorkflowEdge( + id="edge-dev2-to-reviewer", + source="developer-2", + target="code-reviewer", + condition="REVIEW_PASSED", + label="Одобрено Кодером 2", + ), + WorkflowEdge( + id="edge-reviewer-to-dev2", + source="code-reviewer", + target="developer-2", + condition="REVIEW_FAILED", + label="Переделка Кодеру 2", + max_iterations=5, + ), + WorkflowEdge( + id="edge-reviewer-to-manager", + source="code-reviewer", + target="manager", + condition="REVIEW_PASSED", + label="Приёмка", + ), + ], + ) + + @dataclass class WorkflowEvent: timestamp: str @@ -206,12 +278,6 @@ class WorkflowService: return try: raw = json.loads(self.state_path.read_text(encoding="utf-8")) - - # Идентификаторы агентов повторяют идентификаторы ролей, а роли со - # старыми именами переименовываются в канонические при загрузке - # конфигурации. Без такого же переименования здесь сохранённый - # workflow ссылался бы на исчезнувших агентов, и граф падал бы с - # «Ребро ссылается на отсутствующего агента». from antigravity_provider.router.role_registry import RoleRegistry def _canon(agent_id: str) -> str: @@ -228,12 +294,18 @@ class WorkflowService: item["id"] = _canon(item["id"]) if item.get("role"): item["role"] = _canon(item["role"]) - # Первым выигрывает агент под старым именем: именно им - # пользовался владелец, канонический мог быть дописан пустым. self.agents.setdefault(item["id"], AgentDefinition(**item)) wf = raw.get("workflow") or {} edges = [] + valid_edge_keys = { + "id", + "source", + "target", + "condition", + "label", + "max_iterations", + } for edge in wf.pop("edges", []): if not isinstance(edge, dict): continue @@ -241,7 +313,8 @@ class WorkflowService: for key in ("source", "target", "from_agent", "to_agent"): if edge.get(key): edge[key] = _canon(edge[key]) - edges.append(WorkflowEdge(**edge)) + filtered_edge = {k: v for k, v in edge.items() if k in valid_edge_keys} + edges.append(WorkflowEdge(**filtered_edge)) self.workflow = WorkflowDefinition(edges=edges, **wf) self.events = [WorkflowEvent(**event) for event in raw.get("events", [])[-200:]] self.run = raw.get("run") or self._idle_run() @@ -317,8 +390,8 @@ class WorkflowService: from antigravity_provider.router.role_registry import get_role_definition definition = get_role_definition(role_id) - name = getattr(definition, "name", None) or getattr(definition, "display_name", None) or name - description = getattr(definition, "description", "") + name = getattr(definition, "display_name_ru", None) or getattr(definition, "name", None) or name + description = getattr(definition, "description_ru", "") or getattr(definition, "description", "") except (ImportError, AttributeError, TypeError): pass agent = AgentDefinition( @@ -332,12 +405,28 @@ class WorkflowService: self.agents[role_id] = agent self._ensure_file(target, agent) changed = True + + if not self.workflow.edges: + a36_roles = {"manager", "developer-1", "developer-2", "code-reviewer"} + if a36_roles.issubset(self.agents.keys()): + self.workflow = get_canonical_a36_pipeline() + if "manager" in self.agents: + self.agents["manager"].position = {"x": 60.0, "y": 140.0} + if "developer-1" in self.agents: + self.agents["developer-1"].position = {"x": 320.0, "y": 140.0} + if "developer-2" in self.agents: + self.agents["developer-2"].position = {"x": 580.0, "y": 140.0} + if "code-reviewer" in self.agents: + self.agents["code-reviewer"].position = {"x": 840.0, "y": 140.0} + changed = True + if changed: self._save() @staticmethod def _ensure_file(target: Path, agent: AgentDefinition) -> None: if not target.exists(): + target.parent.mkdir(parents=True, exist_ok=True) body = f"# {agent.name}\n\n## Роль\n\n{agent.role}\n\n## Назначение\n\n{agent.description or 'Инструкции ещё не заполнены.'}\n" target.write_text(body, encoding="utf-8") @@ -390,6 +479,7 @@ class WorkflowService: with self._lock: agent = self._require_agent(agent_id) target, relative = _safe_agent_file(agent.agent_file, agent.id) + target.parent.mkdir(parents=True, exist_ok=True) temporary = target.with_suffix(".md.tmp") temporary.write_text(str(content), encoding="utf-8") temporary.replace(target) @@ -404,6 +494,8 @@ class WorkflowService: if not name: raise ValueError("Укажите название агента") with self._lock: + if self.run.get("status") in {"running", "stopping"}: + raise ValueError("Нельзя менять конфигурацию агентов во время выполнения workflow") if agent_id in self.agents: raise ValueError("Агент с таким идентификатором уже существует") profile_id = str(data.get("account") or data.get("profile_id") or "").strip() @@ -446,6 +538,8 @@ class WorkflowService: def update_agent(self, agent_id: str, data: dict[str, Any]) -> AgentDefinition: with self._lock: + if self.run.get("status") in {"running", "stopping"}: + raise ValueError("Нельзя менять конфигурацию агентов во время выполнения workflow") agent = self._require_agent(agent_id) config = load_router_config() policy = config.roles.get(agent.role) @@ -463,10 +557,11 @@ class WorkflowService: policy.preferred_chain = [profile_id] + [item for item in policy.preferred_chain if item != profile_id] if "model" in data: model = str(data.get("model") or "").strip() or None - if model and profile_id: - profile = config.profiles[profile_id] - if model not in profile.preferred_models: - raise ValueError("Модель не доступна выбранному аккаунту") + if model and policy.preferred_chain: + eff_profile_id = policy.preferred_chain[0] + eff_profile = config.profiles.get(eff_profile_id) + if eff_profile and eff_profile.preferred_models and model not in eff_profile.preferred_models: + raise ValueError(f"Модель {model} не доступна для профиля {eff_profile_id}") policy.default_model = model if not save_router_config(config): raise OSError("Не удалось сохранить назначение агента") @@ -487,6 +582,8 @@ class WorkflowService: def delete_agent(self, agent_id: str, force: bool = False) -> dict[str, Any]: with self._lock: + if self.run.get("status") in {"running", "stopping"}: + raise ValueError("Нельзя удалять агентов во время выполнения workflow") agent = self._require_agent(agent_id) edge_ids = [edge.id for edge in self.workflow.edges if edge.source == agent_id or edge.target == agent_id] route_used = bool(load_router_config().roles.get(agent.role)) @@ -498,7 +595,17 @@ class WorkflowService: if not save_router_config(config): raise OSError("Не удалось удалить роль из маршрутизатора") self.workflow.edges = [edge for edge in self.workflow.edges if edge.id not in edge_ids] - self.agents.pop(agent_id) + if self.workflow.start_agent_id == agent_id: + self.workflow.start_agent_id = None + if self.workflow.escalation_agent_id == agent_id: + self.workflow.escalation_agent_id = None + target, _ = _safe_agent_file(agent.agent_file, agent.id) + if target.exists(): + try: + target.unlink() + except OSError: + pass + self.agents.pop(agent_id, None) self._event("AGENT_DELETED", f"Удалён агент «{agent.name}»", agent_id=agent_id, level="warning") self._save() return {"deleted": True, "consequences": consequences} @@ -509,18 +616,35 @@ class WorkflowService: raise ValueError("Нельзя менять граф в режиме LIVE во время выполнения") edges: list[WorkflowEdge] = [] seen: set[str] = set() - for raw in data.get("edges", []): - source, target = str(raw.get("source") or ""), str(raw.get("target") or "") - condition = str(raw.get("condition") or "SUCCESS").upper() + for raw_edge in data.get("edges", []): + source, target = str(raw_edge.get("source") or ""), str(raw_edge.get("target") or "") + condition = str(raw_edge.get("condition") or "SUCCESS").upper() if source not in self.agents or target not in self.agents: raise ValueError("Ребро ссылается на отсутствующего агента") if condition not in EDGE_CONDITIONS: raise ValueError(f"Неизвестное условие перехода: {condition}") - edge_id = str(raw.get("id") or f"edge-{uuid.uuid4().hex[:10]}") + edge_id = str(raw_edge.get("id") or f"edge-{uuid.uuid4().hex[:10]}") if edge_id in seen: raise ValueError("Идентификаторы рёбер должны быть уникальны") seen.add(edge_id) - edges.append(WorkflowEdge(edge_id, source, target, condition, str(raw.get("label") or ""))) + edge_max_it = raw_edge.get("max_iterations") + if edge_max_it is not None: + try: + edge_max_it = int(edge_max_it) + if not 1 <= edge_max_it <= 100: + edge_max_it = None + except (ValueError, TypeError): + edge_max_it = None + edges.append( + WorkflowEdge( + id=edge_id, + source=source, + target=target, + condition=condition, + label=str(raw_edge.get("label") or ""), + max_iterations=edge_max_it, + ) + ) max_iterations = int(data.get("max_iterations") or self.workflow.max_iterations) if not 1 <= max_iterations <= 100: raise ValueError("Предел итераций должен быть от 1 до 100") @@ -577,7 +701,13 @@ class WorkflowService: self._stop.set() self._event("WORKFLOW_STOP_REQUESTED", "Запрошена остановка workflow", level="warning") self._save() - self._save_run_state("STOPPED", step_index=len(self._completed_steps), current_agent=self.run.get("current_agent_id"), iteration=self.run.get("iteration", 1), interruption_reason="Остановлено пользователем") + self._save_run_state( + "STOPPED", + step_index=len(self._completed_steps), + current_agent=self.run.get("current_agent_id"), + iteration=self.run.get("iteration", 1), + interruption_reason="Остановлено пользователем", + ) return dict(self.run) def _execute(self) -> None: @@ -587,6 +717,7 @@ class WorkflowService: context = str(self.run.get("current_task") or "") current = str(self.run.get("current_agent_id") or "") visited: dict[str, int] = {} + edge_counts: dict[str, int] = {} try: engine = get_router_engine() engine.reload_config() @@ -594,8 +725,14 @@ class WorkflowService: with self._lock: agent = self._require_agent(current) visited[current] = visited.get(current, 0) + 1 - iteration = max(visited.values()) - self.run.update({"current_agent_id": current, "iteration": iteration}) + iteration = visited[current] + global_iteration = sum(visited.values()) + self.run.update({ + "current_agent_id": current, + "iteration": iteration, + "global_iteration": global_iteration, + "max_iterations": self.workflow.max_iterations, + }) step_idx = len(self._completed_steps) if iteration > self.workflow.max_iterations: message = f"Достигнут предел итераций: {self.workflow.max_iterations}" @@ -605,7 +742,7 @@ class WorkflowService: break file_data = self.read_agent_file(current) if not file_data["exists"]: - raise FileNotFoundError(f"{file_data['path']}: {file_data['reason']}") + raise FileNotFoundError(f"{file_data['path']}: {file_data.get('reason')}") self.run.setdefault("agent_states", {})[current] = ( "reviewing" if "review" in agent.role.lower() else "working" ) @@ -665,7 +802,7 @@ class WorkflowService: (item for item in self.workflow.edges if item.source == current and item.condition in {status, "ALWAYS"}), None, ) - if not edge and status not in {"ERROR", "REVIEW_FAILED"}: + if not edge and status in {"SUCCESS", "NEXT"}: edge = next( (item for item in self.workflow.edges if item.source == current and item.condition in {"SUCCESS", "NEXT"}), None, @@ -703,6 +840,16 @@ class WorkflowService: interruption_reason=self.run.get("error") if self.run["status"] == "failed" else None, ) break + + edge_counts[edge.id] = edge_counts.get(edge.id, 0) + 1 + edge_limit = edge.max_iterations if edge.max_iterations is not None else self.workflow.max_iterations + if edge_counts[edge.id] > edge_limit: + message = f"Достигнут предел итераций для перехода {edge.source} → {edge.target}: {edge_limit}" + self.run.update({"status": "failed", "error": message}) + self._event("WORKFLOW_MAX_ITERATIONS", message, level="error", agent_id=current, iteration=iteration) + self._save_run_state("FAILED", step_index=step_idx + 1, current_agent=current, iteration=iteration, interruption_reason=message) + break + self._event( "WORKFLOW_TRANSITION", f"Переход {current} → {edge.target}: {edge.condition}", @@ -746,7 +893,6 @@ class WorkflowService: self.run["current_agent_id"] = current or self.run.get("current_agent_id") self._save() - @staticmethod def _response_text(response: Any) -> str: if not isinstance(response, dict): @@ -763,9 +909,26 @@ class WorkflowService: explicit = response.get("status") or response.get("structured_status") if explicit and str(explicit).upper() in EDGE_CONDITIONS: return str(explicit).upper() - for status in ("REVIEW_FAILED", "REVIEW_PASSED", "SUCCESS", "ERROR"): - if re.search(rf"\b{status}\b", text.upper()): + + text_upper = text.upper() + # 1. Match leading status keyword or STATUS: + prefix_match = re.search( + r"^\s*(?:STATUS\s*:\s*|\[STATUS\s*:\s*)?(REVIEW_FAILED|REVIEW_PASSED|COMPLETED|ACCEPTED|SUCCESS|ERROR)\b", + text_upper, + re.MULTILINE, + ) + if prefix_match: + return prefix_match.group(1) + + # 2. Match multi-word distinct statuses anywhere + for status in ("REVIEW_FAILED", "REVIEW_PASSED", "COMPLETED", "ACCEPTED"): + if re.search(rf"\b{status}\b", text_upper): return status + + # 3. Explicit error status markers vs regular discussion of errors + if re.search(r"\bSTATUS\s*:\s*ERROR\b", text_upper) or re.search(r"\b\[ERROR\]\b", text_upper): + return "ERROR" + return "SUCCESS" def _event(self, event_type: str, message: str, **kwargs: Any) -> None: diff --git a/tests/test_a35_role_resolution.py b/tests/test_a35_role_resolution.py new file mode 100644 index 0000000..6dc873b --- /dev/null +++ b/tests/test_a35_role_resolution.py @@ -0,0 +1,271 @@ +from __future__ import annotations + +import tempfile +import unittest +from pathlib import Path +from unittest.mock import MagicMock, patch + +from antigravity_provider.router.router_config import ( + RolePolicy, + RouterConfig, + RouterProfileConfig, + save_router_config, +) +from antigravity_provider.router.router_engine import RouterEngine, get_router_engine +from antigravity_provider.hermes_plugin import antigravity_llm_execution +from antigravity_provider.router.settings_service import save_hub_settings, invalidate_settings_cache + + +class TestA35RoleResolution(unittest.TestCase): + """P0-1 & P0-2: Role resolution and failover safety for Hermes Hub integration.""" + + def setUp(self): + self.tmp_dir = tempfile.mkdtemp(prefix="hermes_test_a35_") + self.config_path = Path(self.tmp_dir) / "router_profiles.yaml" + self.settings_path = Path(self.tmp_dir) / "hub_settings.json" + + self.profiles = { + "ag-orch-fallback": RouterProfileConfig( + profile_id="ag-orch-fallback", + provider="antigravity", + preferred_models=["gemini-3.7-flash"], + ), + "ag-w1": RouterProfileConfig( + profile_id="ag-w1", + provider="antigravity", + preferred_models=["gemini-3.7-flash"], + ), + "ag-w2": RouterProfileConfig( + profile_id="ag-w2", + provider="antigravity", + preferred_models=["gemini-3.1-pro-high"], + ), + "ag-w3": RouterProfileConfig( + profile_id="ag-w3", + provider="antigravity", + preferred_models=["claude-opus-4-6-thinking"], + ), + } + + self.roles = { + "manager": RolePolicy( + role_name="manager", + preferred_chain=["ag-orch-fallback"], + default_model="gemini-3.7-flash", + ), + "developer-1": RolePolicy( + role_name="developer-1", + preferred_chain=["ag-w1"], + default_model="gemini-3.7-flash", + ), + "developer-2": RolePolicy( + role_name="developer-2", + preferred_chain=["ag-w2"], + default_model="gemini-3.1-pro-high", + ), + "code-reviewer": RolePolicy( + role_name="code-reviewer", + preferred_chain=["ag-w3"], + default_model="claude-opus-4-6-thinking", + ), + } + + self.config = RouterConfig( + enabled=True, + default_role="manager", + roles=self.roles, + profiles=self.profiles, + ) + save_router_config(self.config, self.config_path) + + self.env_patcher = patch.dict( + "os.environ", + { + "HERMES_HOME": self.tmp_dir, + "HERMES_ROUTER_PROFILES": str(self.config_path), + }, + ) + self.env_patcher.start() + invalidate_settings_cache() + self.engine = RouterEngine(self.config) + + def tearDown(self): + self.env_patcher.stop() + invalidate_settings_cache() + + def test_explicit_role_resolution_level1(self): + """Level 1: Explicit role in arguments, request or metadata takes highest priority.""" + # 1. Via explicit_role arg + role, source = self.engine.resolve_role_with_source({}, explicit_role="developer-2") + self.assertEqual(role, "developer-2") + self.assertEqual(source, "explicit") + + # 2. Via request['role'] + role, source = self.engine.resolve_role_with_source({"role": "code-reviewer"}) + self.assertEqual(role, "code-reviewer") + self.assertEqual(source, "explicit") + + # 3. Via request['metadata']['role'] + role, source = self.engine.resolve_role_with_source({"metadata": {"role": "manager"}}) + self.assertEqual(role, "manager") + self.assertEqual(source, "explicit") + + def test_model_and_provider_resolution_level2(self): + """Level 2: Resolution by model and provider dynamically configured in router roles.""" + # gemini-3.1-pro-high -> configured default_model for developer-2 + role, source = self.engine.resolve_role_with_source({}, model="gemini-3.1-pro-high") + self.assertEqual(role, "developer-2") + self.assertEqual(source, "model_match") + + # claude-opus-4-6-thinking -> configured default_model for code-reviewer + role, source = self.engine.resolve_role_with_source({}, model="claude-opus-4-6-thinking") + self.assertEqual(role, "code-reviewer") + self.assertEqual(source, "model_match") + + # gemini-3.7-flash -> configured default_model for manager / developer-1 + role, source = self.engine.resolve_role_with_source({}, model="gemini-3.7-flash") + self.assertIn(role, ["manager", "developer-1"]) + self.assertEqual(source, "model_match") + + def test_session_affinity_resolution_level3(self): + """Level 3: Resolution by session affinity for session_id.""" + sess_id = "sess-affinity-test-123" + # Register affinity record in router engine + self.engine.affinity.set_affinity(sess_id, role="developer-2", profile_id="ag-w2") + + role, source = self.engine.resolve_role_with_source({}, session_id=sess_id) + self.assertEqual(role, "developer-2") + self.assertEqual(source, "session_affinity") + + def test_default_fallback_role_level4(self): + """Level 4: Default fallback role when no explicit, model, or session affinity matched.""" + # By default, default_fallback gives 'manager' + role, source = self.engine.resolve_role_with_source( + {"messages": [{"role": "user", "content": "hello"}]}, + fallback_to_default=True, + ) + self.assertEqual(role, "manager") + self.assertEqual(source, "default_fallback") + + # Configurable via hub_settings.json + save_hub_settings({"default_role": "code-reviewer"}) + role, source = self.engine.resolve_role_with_source( + {"messages": [{"role": "user", "content": "hello"}]}, + fallback_to_default=True, + ) + self.assertEqual(role, "code-reviewer") + self.assertEqual(source, "default_fallback") + + def test_no_prompt_guessing_returns_none_when_fallback_disabled(self): + """Zero prompt guessing: unspecified role returns None when fallback_to_default=False.""" + req = {"messages": [{"role": "system", "content": "You are a senior coding agent developer"}]} + role, source = self.engine.resolve_role_with_source(req, fallback_to_default=False) + self.assertIsNone(role) + self.assertEqual(source, "none") + + def test_router_error_never_returned_as_assistant_content_to_hermes(self): + """Safety Fuse: Router failover exhaustion (router_error) passes call downstream to next_call.""" + downstream_calls = [] + + def mock_next(req): + downstream_calls.append(req) + return { + "choices": [ + { + "message": {"role": "assistant", "content": "clean-downstream-response"}, + "finish_reason": "stop", + } + ] + } + + # Mock engine route_request to return failover exhaustion error payload + exhausted_payload = { + "router_error": True, + "error_type": "exhausted", + "message": "All 3 profiles in role 'manager' failed", + "failover_trail": ["ag-orch-fallback: timeout"], + } + + with patch("antigravity_provider.router.get_router_engine", return_value=self.engine): + with patch.object(self.engine, "route_request", return_value=exhausted_payload): + res = antigravity_llm_execution( + request={"messages": [{"role": "user", "content": "test safety"}]}, + next_call=mock_next, + provider="antigravity", + model="gemini-3.7-flash", + session_id="sess-safety-1", + ) + + # Verified: Downstream call was executed and router error text did NOT become the assistant message + self.assertEqual(len(downstream_calls), 1) + content = res["choices"][0]["message"]["content"] + self.assertEqual(content, "clean-downstream-response") + self.assertNotIn("exhausted", content.lower()) + self.assertNotIn("router_error", content.lower()) + + def test_empty_profile_config_passes_cleanly_to_next_call(self): + """Empty profile configuration must not break Hermes and falls through cleanly.""" + empty_config = RouterConfig(enabled=True, roles={}, profiles={}) + empty_engine = RouterEngine(empty_config) + + downstream_calls = [] + + def mock_next(req): + downstream_calls.append(req) + return { + "choices": [ + { + "message": {"role": "assistant", "content": "clean-passthrough-response"}, + "finish_reason": "stop", + } + ] + } + + with patch("antigravity_provider.router.get_router_engine", return_value=empty_engine): + res = antigravity_llm_execution( + request={"messages": [{"role": "user", "content": "test passthrough"}]}, + next_call=mock_next, + provider="antigravity", + model="gemini-3.7-flash", + ) + + self.assertEqual(len(downstream_calls), 1) + self.assertEqual(res["choices"][0]["message"]["content"], "clean-passthrough-response") + + def test_hermes_call_records_event_log_and_telemetry(self): + """Successful Hermes routing records chosen role, reason, profile, and telemetry.""" + from antigravity_provider.router.unified_health import EventLogService + + mock_completion = { + "choices": [{"message": {"role": "assistant", "content": "model-output-ok"}}], + "router_metadata": { + "provider": "antigravity", + "profile_id": "ag-w2", + "selected_model": "gemini-3.1-pro-high", + }, + } + + with patch("antigravity_provider.router.get_router_engine", return_value=self.engine): + with patch.object(self.engine, "route_request", return_value=mock_completion): + res = antigravity_llm_execution( + request={"messages": [{"role": "user", "content": "test event logging"}]}, + provider="antigravity", + model="gemini-3.1-pro-high", + session_id="sess-log-1", + ) + + content = ( + res.choices[0].message.content + if hasattr(res, "choices") + else res["choices"][0]["message"]["content"] + ) + self.assertEqual(content, "model-output-ok") + events = EventLogService.get().get_events(limit=10, category="routing") + self.assertTrue(any("developer-2" in e.message for e in events)) + matching_event = next(e for e in events if "developer-2" in e.message) + self.assertIn("по модели и провайдеру", matching_event.message) + self.assertIn("ag-w2", matching_event.details) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_a36_pipeline.py b/tests/test_a36_pipeline.py new file mode 100644 index 0000000..e325514 --- /dev/null +++ b/tests/test_a36_pipeline.py @@ -0,0 +1,308 @@ +from __future__ import annotations + +import json +import tempfile +import time +import unittest +from pathlib import Path +from unittest.mock import MagicMock, patch + +from antigravity_provider.router.router_config import ( + RolePolicy, + RouterConfig, + RouterProfileConfig, + save_router_config, +) +from antigravity_provider.router.workflow_service import ( + WorkflowService, + WorkflowDefinition, + WorkflowEdge, + get_canonical_a36_pipeline, +) + + +class TestA36AntigravityPipeline(unittest.TestCase): + """P0-1 .. P0-5: Antigravity pipeline graph, loops, limits, models and execution.""" + + def setUp(self): + self.tmp_dir = tempfile.mkdtemp(prefix="hermes_test_a36_") + self.config_path = Path(self.tmp_dir) / "router_profiles.yaml" + self.state_path = Path(self.tmp_dir) / "workflow_state.json" + + self.profiles = { + "ag-orch-fallback": RouterProfileConfig( + profile_id="ag-orch-fallback", + provider="antigravity", + preferred_models=["gemini-3.7-flash"], + ), + "ag-w1": RouterProfileConfig( + profile_id="ag-w1", + provider="antigravity", + preferred_models=["gemini-3.7-flash", "gemini-3.7-flash-high"], + ), + "ag-w2": RouterProfileConfig( + profile_id="ag-w2", + provider="antigravity", + preferred_models=["gemini-3.1-pro-high", "gemini-3.1-pro-low"], + ), + "ag-w3": RouterProfileConfig( + profile_id="ag-w3", + provider="antigravity", + preferred_models=["claude-opus-4-6-thinking"], + ), + } + + self.roles = { + "manager": RolePolicy( + role_name="manager", + preferred_chain=["ag-orch-fallback"], + default_model="gemini-3.7-flash", + ), + "developer-1": RolePolicy( + role_name="developer-1", + preferred_chain=["ag-w1"], + default_model="gemini-3.7-flash", + ), + "developer-2": RolePolicy( + role_name="developer-2", + preferred_chain=["ag-w2"], + default_model="gemini-3.1-pro-high", + ), + "code-reviewer": RolePolicy( + role_name="code-reviewer", + preferred_chain=["ag-w3"], + default_model="claude-opus-4-6-thinking", + ), + } + + self.config = RouterConfig( + enabled=True, + default_role="manager", + roles=self.roles, + profiles=self.profiles, + ) + save_router_config(self.config, self.config_path) + + self.env_patcher = patch.dict( + "os.environ", + { + "HERMES_HOME": self.tmp_dir, + "HERMES_ROUTER_PROFILES": str(self.config_path), + }, + ) + self.env_patcher.start() + self.wf_service = WorkflowService(self.state_path) + + def tearDown(self): + self.env_patcher.stop() + + def test_canonical_pipeline_graph_structure(self): + """P0-1: Canonical graph matches orchestrator, two coders, reviewer layout.""" + snapshot = self.wf_service.snapshot() + agents = {a["id"]: a for a in snapshot["agents"]} + + # 1. Check all 4 agents exist + self.assertIn("manager", agents) + self.assertIn("developer-1", agents) + self.assertIn("developer-2", agents) + self.assertIn("code-reviewer", agents) + + # 2. Check model bindings + self.assertEqual(agents["manager"]["execution_config"]["model"], "gemini-3.7-flash") + self.assertEqual(agents["developer-1"]["execution_config"]["model"], "gemini-3.7-flash") + self.assertEqual(agents["developer-2"]["execution_config"]["model"], "gemini-3.1-pro-high") + self.assertEqual(agents["code-reviewer"]["execution_config"]["model"], "claude-opus-4-6-thinking") + + # 3. Check account bindings + self.assertEqual(agents["manager"]["execution_config"]["account"], "ag-orch-fallback") + self.assertEqual(agents["developer-1"]["execution_config"]["account"], "ag-w1") + self.assertEqual(agents["developer-2"]["execution_config"]["account"], "ag-w2") + self.assertEqual(agents["code-reviewer"]["execution_config"]["account"], "ag-w3") + + # 4. Check edges and feedback loops + definition = snapshot["definition"] + self.assertEqual(definition["start_agent_id"], "manager") + edges = {(e["source"], e["target"], e["condition"]) for e in definition["edges"]} + + # Forward flow + self.assertIn(("manager", "developer-1", "SUCCESS"), edges) + self.assertIn(("developer-1", "developer-2", "SUCCESS"), edges) + self.assertIn(("developer-2", "code-reviewer", "REVIEW_PASSED"), edges) + self.assertIn(("code-reviewer", "manager", "REVIEW_PASSED"), edges) + + # Inner feedback loop: developer-2 -> developer-1 on REVIEW_FAILED + self.assertIn(("developer-2", "developer-1", "REVIEW_FAILED"), edges) + + # Outer feedback loop: code-reviewer -> developer-2 (NOT developer-1!) on REVIEW_FAILED + self.assertIn(("code-reviewer", "developer-2", "REVIEW_FAILED"), edges) + + def test_live_execution_with_triggered_inner_loop(self): + """P0-3: Live execution run with Coder 2 returning work to Coder 1.""" + step_call_count = {"developer-1": 0, "developer-2": 0, "code-reviewer": 0, "manager": 0} + step_history = [] + + class FakePipelineEngine: + def reload_config(self): + return None + + def route_request(self, request, role=None, session_id=None): + step_call_count[role] = step_call_count.get(role, 0) + 1 + step_history.append((role, step_call_count[role])) + + if role == "manager": + if step_call_count["manager"] == 1: + return { + "choices": [{"message": {"content": "SUCCESS: Task dispatched to Developer 1"}}], + "router_metadata": {"profile_id": "ag-orch-fallback", "selected_model": "gemini-3.7-flash"}, + } + else: + return { + "choices": [{"message": {"content": "ACCEPTED: Project verified and accepted by Orchestrator"}}], + "router_metadata": {"profile_id": "ag-orch-fallback", "selected_model": "gemini-3.7-flash"}, + } + elif role == "developer-1": + if step_call_count["developer-1"] == 1: + return { + "choices": [{"message": {"content": "SUCCESS: Initial code implementation"}}], + "router_metadata": {"profile_id": "ag-w1", "selected_model": "gemini-3.7-flash"}, + } + else: + return { + "choices": [{"message": {"content": "SUCCESS: Fixed error handling per Coder 2 feedback"}}], + "router_metadata": {"profile_id": "ag-w1", "selected_model": "gemini-3.7-flash"}, + } + elif role == "developer-2": + if step_call_count["developer-2"] == 1: + # First check: Reject and return to developer-1 + return { + "choices": [{"message": {"content": "REVIEW_FAILED: Missing error handling and edge cases"}}], + "router_metadata": {"profile_id": "ag-w2", "selected_model": "gemini-3.1-pro-high"}, + } + else: + # Second check: Approve and advance to reviewer + return { + "choices": [{"message": {"content": "REVIEW_PASSED: Code approved by Coder 2"}}], + "router_metadata": {"profile_id": "ag-w2", "selected_model": "gemini-3.1-pro-high"}, + } + elif role == "code-reviewer": + return { + "choices": [{"message": {"content": "REVIEW_PASSED: Security and architecture approved"}}], + "router_metadata": {"profile_id": "ag-w3", "selected_model": "claude-opus-4-6-thinking"}, + } + return {"choices": [{"message": {"content": "SUCCESS"}}]} + + with patch("antigravity_provider.router.router_engine.get_router_engine", return_value=FakePipelineEngine()): + self.wf_service.start("Создать модуль аутентификации с валидацией токенов") + thread = self.wf_service._thread + self.assertIsNotNone(thread) + thread.join(timeout=5) + self.assertFalse(thread.is_alive(), "Workflow thread hung during execution") + + # Verify completed execution status + self.assertEqual( + self.wf_service.run["status"], + "completed", + f"Run failed with error: {self.wf_service.run.get('error')}, events: {[e.message for e in self.wf_service.events]}", + ) + + # Verify transition sequence + expected_sequence = [ + ("manager", 1), + ("developer-1", 1), + ("developer-2", 1), # Returns REVIEW_FAILED -> triggers loop back to dev-1 + ("developer-1", 2), # dev-1 fixes code + ("developer-2", 2), # dev-2 approves -> REVIEW_PASSED + ("code-reviewer", 1), # reviewer approves -> REVIEW_PASSED + ("manager", 2), # manager acceptance -> completed + ] + self.assertEqual(step_history, expected_sequence) + + # Verify transition events + transitions = [e.message for e in self.wf_service.events if e.type == "WORKFLOW_TRANSITION"] + self.assertIn("Переход developer-2 → developer-1: REVIEW_FAILED", transitions) + self.assertIn("Переход developer-2 → code-reviewer: REVIEW_PASSED", transitions) + self.assertIn("Переход code-reviewer → manager: REVIEW_PASSED", transitions) + + def test_iteration_limit_cutoff_and_event(self): + """P0-2: Loop iteration cutoff emits WORKFLOW_MAX_ITERATIONS and sets failed status.""" + self.wf_service.workflow.max_iterations = 2 + self.wf_service._save() + + class InfiniteLoopEngine: + def reload_config(self): + return None + + def route_request(self, request, role=None, session_id=None): + if role == "manager": + return {"choices": [{"message": {"content": "SUCCESS: Start task"}}]} + elif role == "developer-1": + return {"choices": [{"message": {"content": "SUCCESS: Dev 1 draft"}}]} + elif role == "developer-2": + # Always reject to simulate unending loop + return {"choices": [{"message": {"content": "REVIEW_FAILED: Reject again"}}]} + return {"choices": [{"message": {"content": "SUCCESS"}}]} + + with patch("antigravity_provider.router.router_engine.get_router_engine", return_value=InfiniteLoopEngine()): + self.wf_service.start("Тест предела итераций") + thread = self.wf_service._thread + self.assertIsNotNone(thread) + thread.join(timeout=5) + self.assertFalse(thread.is_alive()) + + self.assertEqual(self.wf_service.run["status"], "failed") + self.assertIn("Достигнут предел итераций", self.wf_service.run["error"]) + self.assertTrue(any(e.type == "WORKFLOW_MAX_ITERATIONS" for e in self.wf_service.events)) + + def test_agent_model_reconfiguration(self): + """P0-4: Changing model on agent updates configuration and router policy.""" + # Update developer-1 model to gemini-3.7-flash-high + updated = self.wf_service.update_agent("developer-1", {"model": "gemini-3.7-flash-high"}) + self.assertEqual(updated.id, "developer-1") + + snap = self.wf_service.snapshot() + dev1 = next(a for a in snap["agents"] if a["id"] == "developer-1") + self.assertEqual(dev1["execution_config"]["model"], "gemini-3.7-flash-high") + + def test_multi_account_parallelism_without_global_mutex(self): + """P0-4: Requests to distinct Antigravity accounts execute concurrently without blocking.""" + from antigravity_provider.router.router_engine import RouterEngine + + engine = RouterEngine(self.config) + execution_times = {} + + def slow_execution(profile_id, duration=0.2): + t0 = time.monotonic() + time.sleep(duration) + execution_times[profile_id] = round(time.monotonic() - t0, 3) + return { + "choices": [{"message": {"content": f"output from {profile_id}"}}], + "router_metadata": {"profile_id": profile_id, "provider": "antigravity"}, + } + + mock_adapter = MagicMock() + mock_adapter.invoke.side_effect = lambda profile, req: slow_execution(profile.profile_id) + + with patch("antigravity_provider.router.router_engine.get_adapter", return_value=mock_adapter): + import concurrent.futures + + start_t = time.monotonic() + with concurrent.futures.ThreadPoolExecutor(max_workers=3) as executor: + f1 = executor.submit(engine.route_request, {"model": "gemini-3.7-flash"}, role="developer-1") + f2 = executor.submit(engine.route_request, {"model": "gemini-3.1-pro-high"}, role="developer-2") + f3 = executor.submit(engine.route_request, {"model": "claude-opus-4-6-thinking"}, role="code-reviewer") + + r1 = f1.result(timeout=2) + r2 = f2.result(timeout=2) + r3 = f3.result(timeout=2) + + total_elapsed = time.monotonic() - start_t + + # 3 calls taking 0.2s each running in parallel should take ~0.2-0.35s total, NOT 0.6s+ + self.assertLess(total_elapsed, 0.55, f"Execution was serialized instead of parallel: {total_elapsed:.3f}s") + self.assertEqual(r1["choices"][0]["message"]["content"], "output from ag-w1") + self.assertEqual(r2["choices"][0]["message"]["content"], "output from ag-w2") + self.assertEqual(r3["choices"][0]["message"]["content"], "output from ag-w3") + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_a37_isolation_guards.py b/tests/test_a37_isolation_guards.py new file mode 100644 index 0000000..731227e --- /dev/null +++ b/tests/test_a37_isolation_guards.py @@ -0,0 +1,398 @@ +"""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, "защита мешает штатной работе внутри проекта"