teleo-infrastructure/scripts/leo_oos_readonly_guard.py
2026-07-16 08:41:12 +02:00

358 lines
13 KiB
Python

#!/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
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)
def _action_schema(action: str) -> dict[str, Any]:
spec = _ACTION_SPECS[action]
fields = (*spec["required"], *spec["optional"])
return {
"type": "object",
"properties": {
"action": {"type": "string", "enum": [action]},
**{field: _FIELD_SCHEMAS[field] for field in fields},
},
"required": ["action", *spec["required"]],
"additionalProperties": False,
}
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",
"oneOf": [_action_schema(action) for action in sorted(_ACTION_SPECS)],
},
}
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,
}