358 lines
13 KiB
Python
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,
|
|
}
|