#!/usr/bin/env python3 """Fail-closed Hermes tool surface for live no-post Leo OOS trials.""" from __future__ import annotations import hashlib import json import os import signal import subprocess import sys import uuid from pathlib import Path from typing import Any STRUCTURED_READ_TOOL_NAME = "teleo-kb" BRIDGE_TIMEOUT_SECONDS = 45 BRIDGE_TERMINATE_GRACE_SECONDS = 5 KNOWN_RUNNER_ADDED_TOOLS = frozenset({"process"}) PROPOSAL_STATUSES = ("pending_review", "approved", "applied", "canceled", "rejected", "all") _FIELD_SCHEMAS = { "query": { "type": "string", "minLength": 1, "maxLength": 4096, "description": "Natural-language lookup text.", }, "claim_id": { "type": "string", "description": "Canonical claim UUID.", }, "proposal_id": { "type": "string", "description": "Proposal UUID.", }, "status": { "type": "string", "enum": list(PROPOSAL_STATUSES), "description": "Proposal status filter.", }, "limit": { "type": "integer", "minimum": 1, "maximum": 100, "description": "Maximum rows to return.", }, "context_limit": { "type": "integer", "minimum": 1, "maximum": 100, "description": "Maximum identity/context rows.", }, "format": { "type": "string", "enum": ["markdown", "json"], "description": "Response format.", }, } _ACTION_SPECS = { "search": {"required": ("query",), "optional": ("limit", "context_limit", "format")}, "context": {"required": ("query",), "optional": ("limit", "context_limit", "format")}, "show": {"required": ("claim_id",), "optional": ("format",)}, "evidence": {"required": ("claim_id",), "optional": ("limit", "format")}, "edges": {"required": ("claim_id",), "optional": ("limit", "format")}, "list-proposals": {"required": (), "optional": ("status", "limit", "format")}, "search-proposals": {"required": ("query",), "optional": ("status", "limit", "format")}, "show-proposal": {"required": ("proposal_id",), "optional": ("format",)}, "status": {"required": (), "optional": ("format",)}, "decision-matrix-status": {"required": (), "optional": ("format",)}, } READ_ONLY_BRIDGE_COMMANDS = frozenset(_ACTION_SPECS) STRUCTURED_READ_TOOL_SCHEMA = { "name": STRUCTURED_READ_TOOL_NAME, "description": ( "Read Leo's canonical Teleo knowledge base through a bounded, read-only interface. " "This tool cannot stage, approve, apply, or mutate knowledge." ), "parameters": { "type": "object", "properties": { "action": { "type": "string", "enum": sorted(_ACTION_SPECS), "description": ( "Read operation. query is required for search, context, and search-proposals; claim_id is " "required for show, evidence, and edges; proposal_id is required for show-proposal." ), }, **_FIELD_SCHEMAS, }, "required": ["action"], "additionalProperties": False, }, } class ReadOnlyGuardError(RuntimeError): """The requested command cannot be proven read-only.""" def _uuid_argument(value: str) -> str: try: return str(uuid.UUID(value)) except ValueError as exc: raise ReadOnlyGuardError("expected a UUID") from exc def _required_text(tool_args: dict[str, Any], field: str, *, max_length: int = 4096) -> str: value = tool_args.get(field) if not isinstance(value, str) or not value.strip(): raise ReadOnlyGuardError(f"{field} is required") normalized = value.strip() if len(normalized) > max_length: raise ReadOnlyGuardError(f"{field} is too long") return normalized def _bounded_integer(tool_args: dict[str, Any], field: str) -> int: value = tool_args.get(field) if not isinstance(value, int) or isinstance(value, bool) or not 1 <= value <= 100: raise ReadOnlyGuardError(f"{field} must be an integer between 1 and 100") return value def _enum_text(tool_args: dict[str, Any], field: str, choices: tuple[str, ...]) -> str: value = tool_args.get(field) if not isinstance(value, str) or value not in choices: raise ReadOnlyGuardError(f"{field} is not an allowed value") return value def structured_read_argv(tool_args: dict[str, Any]) -> list[str]: """Compile typed arguments into bridge argv without an executable.""" if not isinstance(tool_args, dict): raise ReadOnlyGuardError("tool arguments must be an object") action = tool_args.get("action") if not isinstance(action, str) or action not in _ACTION_SPECS: raise ReadOnlyGuardError("action must name an available read-only KB operation") spec = _ACTION_SPECS[action] allowed_fields = {"action", *spec["required"], *spec["optional"]} unexpected = sorted(set(tool_args) - allowed_fields) if unexpected: raise ReadOnlyGuardError(f"fields are not valid for {action}: {', '.join(unexpected)}") for field in spec["required"]: _required_text(tool_args, field, max_length=64 if field.endswith("_id") else 4096) argv = [action] if action in {"search", "context"}: argv.append(_required_text(tool_args, "query")) elif action in {"show", "evidence", "edges"}: argv.append(_uuid_argument(_required_text(tool_args, "claim_id", max_length=64))) elif action == "search-proposals": argv.append(_required_text(tool_args, "query")) elif action == "show-proposal": argv.append(_uuid_argument(_required_text(tool_args, "proposal_id", max_length=64))) if "status" in tool_args: argv.extend(["--status", _enum_text(tool_args, "status", PROPOSAL_STATUSES)]) if "limit" in tool_args: argv.extend(["--limit", str(_bounded_integer(tool_args, "limit"))]) if "context_limit" in tool_args: argv.extend(["--context-limit", str(_bounded_integer(tool_args, "context_limit"))]) if "format" in tool_args: argv.extend(["--format", _enum_text(tool_args, "format", ("markdown", "json"))]) return argv def _canonical_sha256(value: Any) -> str: encoded = json.dumps(value, sort_keys=True, separators=(",", ":"), ensure_ascii=True).encode("utf-8") return hashlib.sha256(encoded).hexdigest() def _verified_kb_tool(temp_profile: Path, expected_sha256: str) -> Path: candidate = temp_profile / "bin" / "kb_tool.py" if ( temp_profile.is_symlink() or (temp_profile / "bin").is_symlink() or candidate.is_symlink() or not candidate.is_file() ): raise ReadOnlyGuardError("temporary-profile KB tool must be a regular non-symlink file") resolved = candidate.resolve(strict=True) if resolved.parent != (temp_profile / "bin").resolve(strict=True): raise ReadOnlyGuardError("temporary-profile KB tool escaped the profile bin directory") observed_sha256 = hashlib.sha256(resolved.read_bytes()).hexdigest() if observed_sha256 != expected_sha256: raise ReadOnlyGuardError("temporary-profile KB tool hash does not match the frozen source") return resolved def _terminate_process_group(proc: subprocess.Popen[str]) -> None: try: os.killpg(proc.pid, signal.SIGTERM) except OSError: pass try: proc.communicate(timeout=BRIDGE_TERMINATE_GRACE_SECONDS) return except subprocess.TimeoutExpired: pass try: os.killpg(proc.pid, signal.SIGKILL) except OSError: pass try: proc.communicate(timeout=BRIDGE_TERMINATE_GRACE_SECONDS) except (OSError, subprocess.TimeoutExpired): pass def restricted_structured_read_handler( kb_tool: Path, temp_profile: Path, tool_args: dict[str, Any], *, allow_kb_reads: bool, expected_kb_tool_sha256: str, **_kwargs: Any, ) -> str: """Execute one typed read without exposing a shell command surface.""" if not allow_kb_reads: return json.dumps( { "output": "", "exit_code": 126, "error": "database tools are disabled for the no-DB ablation", "error_category": "kb_reads_disabled", } ) try: exact_kb_tool = _verified_kb_tool(temp_profile, expected_kb_tool_sha256) if kb_tool != exact_kb_tool: raise ReadOnlyGuardError("configured KB tool is not the frozen temporary-profile tool") argv = [str(Path(sys.executable).resolve(strict=True)), str(exact_kb_tool), "--local"] argv.extend(structured_read_argv(tool_args)) except (OSError, ReadOnlyGuardError) as exc: return json.dumps( { "output": "", "exit_code": 126, "error": str(exc), "error_category": "invalid_read_arguments", } ) child_environment = { "HOME": str(temp_profile), "HERMES_HOME": str(temp_profile), "PATH": f"{temp_profile / 'bin'}:{os.environ.get('PATH', '')}", "TELEO_KB_MODE": "local", } try: proc = subprocess.Popen( argv, cwd=temp_profile, env=child_environment, text=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE, start_new_session=True, ) except OSError as exc: return json.dumps( { "output": "", "exit_code": 126, "error": f"bridge launch failed: {type(exc).__name__}", "error_category": "bridge_launch", } ) try: stdout, stderr = proc.communicate(timeout=BRIDGE_TIMEOUT_SECONDS) except subprocess.TimeoutExpired: _terminate_process_group(proc) return json.dumps( { "output": "", "exit_code": 124, "error": f"bridge command exceeded {BRIDGE_TIMEOUT_SECONDS}s", "error_category": "bridge_timeout", } ) return json.dumps( { "output": stdout, "exit_code": proc.returncode, "error": stderr or None, "error_category": None if proc.returncode == 0 and not stderr else "bridge_exit", } ) def install_read_only_tool_surface( temp_profile: Path, *, allow_kb_reads: bool, expected_kb_tool_sha256: str, ) -> dict[str, Any]: """Replace Hermes' registry with skills plus one structured KB read tool.""" import tools.skills_tool # noqa: F401 - registers the two skill readers import toolsets from hermes_cli import tools_config from tools.registry import registry toolset_name = "leo-oos-kb-readonly" if allow_kb_reads else "leo-oos-no-db-ablation" allowed_tools = ["skills_list", "skill_view", STRUCTURED_READ_TOOL_NAME] toolsets.TOOLSETS[toolset_name] = { "description": "Fail-closed Leo OOS tool surface", "tools": allowed_tools, "includes": [], } tools_config._get_platform_tools = lambda *_args, **_kwargs: {toolset_name} missing = sorted(name for name in ("skills_list", "skill_view") if name not in registry._tools) if missing: raise ReadOnlyGuardError(f"required tools are not registered: {missing}") allowed_entries = {name: registry._tools[name] for name in ("skills_list", "skill_view")} registry._tools.clear() registry._tools.update(allowed_entries) kb_tool = _verified_kb_tool(temp_profile, expected_kb_tool_sha256) registry.register( name=STRUCTURED_READ_TOOL_NAME, toolset=toolset_name, schema=STRUCTURED_READ_TOOL_SCHEMA, handler=lambda tool_args, **kwargs: restricted_structured_read_handler( kb_tool, temp_profile, tool_args, allow_kb_reads=allow_kb_reads, expected_kb_tool_sha256=expected_kb_tool_sha256, **kwargs, ), description=STRUCTURED_READ_TOOL_SCHEMA["description"], ) definitions = registry.get_definitions({STRUCTURED_READ_TOOL_NAME}, quiet=True) if len(definitions) != 1: raise ReadOnlyGuardError("structured KB read tool definition is not model-visible") definition_sha256 = _canonical_sha256(definitions[0]) return { "mode": "read_only_kb" if allow_kb_reads else "no_db_ablation", "allowed_tools": allowed_tools, "actual_registry_tools": sorted(registry._tools), "read_only_bridge_commands": sorted(READ_ONLY_BRIDGE_COMMANDS) if allow_kb_reads else [], "send_message_tool_enabled": "send_message" in registry._tools, "shell_tool_enabled": "terminal" in registry._tools, "structured_read_tool_name": STRUCTURED_READ_TOOL_NAME, "structured_read_tool_definition_sha256": definition_sha256, "structured_read_tool_restricted_to_frozen_kb_tool": True, "kb_tool_sha256": expected_kb_tool_sha256, "bridge_timeout_seconds": BRIDGE_TIMEOUT_SECONDS, "mutating_bridge_commands_exposed": False, "provider_credentials_forwarded_to_kb_tool": False, } def reassert_read_only_tool_surface( temp_profile: Path, *, expected_kb_tool_sha256: str, expected_definition_sha256: str, ) -> dict[str, Any]: """Remove the runner's known additions before the first model turn.""" from tools.registry import registry _verified_kb_tool(temp_profile, expected_kb_tool_sha256) exact_tools = {"skills_list", "skill_view", STRUCTURED_READ_TOOL_NAME} initial_tools = set(registry._tools) missing = sorted(exact_tools - initial_tools) unexpected = sorted(initial_tools - exact_tools) unsupported = sorted(set(unexpected) - KNOWN_RUNNER_ADDED_TOOLS) if missing or unsupported: raise ReadOnlyGuardError( f"post-runner registry mismatch; missing={missing}; unsupported additions={unsupported}" ) retained_entries = {name: registry._tools[name] for name in exact_tools} registry._tools.clear() registry._tools.update(retained_entries) definitions = registry.get_definitions({STRUCTURED_READ_TOOL_NAME}, quiet=True) if len(definitions) != 1: raise ReadOnlyGuardError("structured KB read tool definition disappeared after runner initialization") definition_sha256 = _canonical_sha256(definitions[0]) if definition_sha256 != expected_definition_sha256: raise ReadOnlyGuardError("structured KB read tool schema changed after runner initialization") final_tools = sorted(registry._tools) expected_final_tools = sorted(exact_tools) return { "initial_registry_tools": sorted(initial_tools), "removed_known_runner_tools": unexpected, "actual_registry_tools": final_tools, "structured_read_tool_definition_sha256": definition_sha256, "final_surface_matches_preexecution": final_tools == expected_final_tools, }