teleo-infrastructure/scripts/leo_behavior_manifest.py
2026-07-14 09:31:49 +02:00

305 lines
11 KiB
Python

#!/usr/bin/env python3
"""Fingerprint every local runtime layer that can make Leo answer differently.
The manifest deliberately records hashes and a small allowlist of non-secret
configuration fields. It never emits profile file contents or credentials.
Canonical Postgres state is outside this manifest and must be fingerprinted by
the database harness that owns the connection.
"""
from __future__ import annotations
import argparse
import hashlib
import json
import os
import subprocess
from datetime import datetime, timezone
from pathlib import Path
from typing import Any
import yaml
SCHEMA = "livingip.leoBehaviorManifest.v1"
DEFAULT_PROFILE = Path("/home/teleo/.hermes/profiles/leoclean")
DEFAULT_HERMES_ROOT = Path("/home/teleo/.hermes/hermes-agent")
def sha256_bytes(value: bytes) -> str:
return hashlib.sha256(value).hexdigest()
def canonical_sha256(value: Any) -> str:
encoded = json.dumps(value, sort_keys=True, separators=(",", ":"), ensure_ascii=True).encode("utf-8")
return sha256_bytes(encoded)
def file_sha256(path: Path) -> str:
digest = hashlib.sha256()
with path.open("rb") as handle:
for chunk in iter(lambda: handle.read(1024 * 1024), b""):
digest.update(chunk)
return digest.hexdigest()
def _safe_relative(path: Path, root: Path) -> str:
try:
return path.relative_to(root).as_posix()
except ValueError:
return str(path)
def _resolved_node_fingerprint(path: Path, seen_directories: frozenset[tuple[int, int]] = frozenset()) -> dict[str, Any]:
"""Hash a symlink target, following nested links while stopping directory cycles."""
try:
if path.is_symlink():
target = os.readlink(path)
resolved = path.resolve(strict=True)
return {
"kind": "symlink",
"target": target,
"resolved": _resolved_node_fingerprint(resolved, seen_directories),
}
stat = path.stat()
if path.is_file():
return {
"kind": "file",
"bytes": stat.st_size,
"mode": oct(stat.st_mode & 0o777),
"sha256": file_sha256(path),
}
if path.is_dir():
identity = (stat.st_dev, stat.st_ino)
if identity in seen_directories:
return {"kind": "directory_cycle"}
child_seen = seen_directories | {identity}
children = [
{"name": child.name, "fingerprint": _resolved_node_fingerprint(child, child_seen)}
for child in sorted(path.iterdir(), key=lambda item: item.name)
]
return {
"kind": "directory",
"entry_count": len(children),
"sha256": canonical_sha256(children),
}
return {"kind": "other", "mode": oct(stat.st_mode & 0o777)}
except (OSError, RuntimeError) as exc:
return {"kind": "unavailable", "error": type(exc).__name__}
def path_manifest(profile: Path, paths: tuple[Path, ...]) -> dict[str, Any]:
files: list[dict[str, Any]] = []
missing: list[str] = []
symlinks: list[dict[str, Any]] = []
for requested in paths:
if not requested.exists() and not requested.is_symlink():
missing.append(_safe_relative(requested, profile))
continue
candidates = [requested] if requested.is_file() or requested.is_symlink() else sorted(requested.rglob("*"))
for path in candidates:
relative = _safe_relative(path, profile)
if path.is_symlink():
symlinks.append(
{
"path": relative,
"target": os.readlink(path),
"target_fingerprint": _resolved_node_fingerprint(path),
}
)
continue
if not path.is_file():
continue
try:
stat = path.stat()
files.append(
{
"path": relative,
"bytes": stat.st_size,
"mode": oct(stat.st_mode & 0o777),
"sha256": file_sha256(path),
}
)
except OSError as exc:
files.append({"path": relative, "status": "unavailable", "error": type(exc).__name__})
files.sort(key=lambda item: item["path"])
symlinks.sort(key=lambda item: item["path"])
stable = {"files": files, "missing": sorted(missing), "symlinks": symlinks}
return {
**stable,
"file_count": len(files),
"total_bytes": sum(int(item.get("bytes", 0)) for item in files),
"sha256": canonical_sha256(stable),
}
def _nested(config: dict[str, Any], *path: str) -> Any:
value: Any = config
for key in path:
value = value.get(key) if isinstance(value, dict) else None
return value
def safe_model_config(config_path: Path) -> dict[str, Any]:
try:
raw = yaml.safe_load(config_path.read_text(encoding="utf-8")) or {}
except (OSError, TypeError, yaml.YAMLError) as exc:
return {"status": "unavailable", "error": type(exc).__name__}
return {
"status": "observed",
"provider": _nested(raw, "model", "provider"),
"default_model": _nested(raw, "model", "default"),
"smart_routing": _nested(raw, "model", "smart_routing"),
"smart_routing_model": _nested(raw, "model", "smart_routing_model"),
"max_tokens": _nested(raw, "model", "max_tokens"),
"reasoning_effort": _nested(raw, "agent", "reasoning_effort"),
"memory_enabled": _nested(raw, "memory", "enabled"),
"memory_search": _nested(raw, "memory", "search"),
}
def git_head(repo: Path) -> str | None:
try:
completed = subprocess.run(
["git", "-C", str(repo), "rev-parse", "HEAD"],
check=False,
capture_output=True,
text=True,
timeout=10,
env={**os.environ, "GIT_OPTIONAL_LOCKS": "0"},
)
except (OSError, subprocess.TimeoutExpired):
return None
value = completed.stdout.strip()
return value if completed.returncode == 0 and len(value) == 40 else None
def component(
profile: Path,
*,
role: str,
mutability: str,
replayability: str,
paths: tuple[Path, ...],
) -> dict[str, Any]:
return {
"role": role,
"mutability": mutability,
"replayability": replayability,
"content": path_manifest(profile, paths),
}
def build_manifest(profile: Path = DEFAULT_PROFILE, hermes_root: Path = DEFAULT_HERMES_ROOT) -> dict[str, Any]:
profile = profile.resolve()
hermes_root = hermes_root.resolve()
config = safe_model_config(profile / "config.yaml")
components = {
"profile_config": component(
profile,
role="Selects model routing, tools, memory, and gateway behavior.",
mutability="deployment_static",
replayability="hashable_but_may_contain_unpinned_provider_defaults",
paths=(profile / "config.yaml",),
),
"runtime_identity": component(
profile,
role="SOUL.md identity text injected into the Hermes system prompt.",
mutability="file_static_until_edit_or_render",
replayability="fully_hashable",
paths=(profile / "SOUL.md",),
),
"procedural_skills": component(
profile,
role="On-demand operating instructions available to the model.",
mutability="deployment_static",
replayability="fully_hashable",
paths=(profile / "skills",),
),
"runtime_middleware": component(
profile,
role="Pre-LLM context injection and post-LLM validation or response replacement.",
mutability="deployment_static",
replayability="fully_hashable",
paths=(profile / "plugins", hermes_root / "run_agent.py"),
),
"database_tools": component(
profile,
role="Commands that query Postgres, stage proposals, and return receipts.",
mutability="deployment_static",
replayability="fully_hashable",
paths=(profile / "bin",),
),
"persistent_memory": component(
profile,
role="Hermes MEMORY.md and USER.md content injected independently of canonical Postgres.",
mutability="conversation_dynamic_noncanonical",
replayability="hashable_but_should_not_define_durable_knowledge",
paths=(profile / "memories",),
),
"conversation_sessions": component(
profile,
role="Session transcripts and local state used for conversation continuity.",
mutability="request_dynamic_noncanonical",
replayability="hashable_but_not_a_knowledge_source",
paths=(
profile / "sessions",
profile / "state",
profile / "state.db",
profile / "state.db-wal",
profile / "state.db-shm",
),
),
"generated_prompt_cache": component(
profile,
role="Hermes-generated cache of the skills prompt.",
mutability="generated_cache",
replayability="discard_and_rebuild",
paths=(profile / ".skills_prompt_snapshot.json",),
),
}
stable = {
"schema": SCHEMA,
"model_runtime": {
**config,
"base_model_weights": "external_provider_managed_not_locally_hashable",
"actual_per_turn_model_must_be_recorded_by_the_call_receipt": True,
},
"hermes_runtime": {
"git_head": git_head(hermes_root),
"source_tree": path_manifest(
profile, (hermes_root / "run_agent.py", hermes_root / "agent" / "prompt_builder.py")
),
},
"components": components,
"canonical_database": {
"included": False,
"reason": "Fingerprint through a read-only Postgres manifest in the database-owning harness.",
},
}
return {
**stable,
"generated_at_utc": datetime.now(timezone.utc).isoformat(),
"profile_root": str(profile),
"hermes_root": str(hermes_root),
"behavior_sha256": canonical_sha256(stable),
}
def main() -> int:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--profile", type=Path, default=DEFAULT_PROFILE)
parser.add_argument("--hermes-root", type=Path, default=DEFAULT_HERMES_ROOT)
parser.add_argument("--output", type=Path)
args = parser.parse_args()
manifest = build_manifest(args.profile, args.hermes_root)
encoded = json.dumps(manifest, indent=2, sort_keys=True) + "\n"
if args.output:
args.output.parent.mkdir(parents=True, exist_ok=True)
args.output.write_text(encoded, encoding="utf-8")
print(encoded, end="")
return 0
if __name__ == "__main__":
raise SystemExit(main())