#!/usr/bin/env python3 """Apply persistent non-interactive settings for managed coding clients.""" from __future__ import annotations import json import os import re import shutil import subprocess from pathlib import Path from typing import Any import yaml def configure_claude_state(path: Path) -> None: """Disable Claude's long-session resume chooser without losing state.""" value: dict[str, Any] = {} if path.is_file(): try: loaded = json.loads(path.read_text(encoding="utf-8")) except (OSError, json.JSONDecodeError): loaded = {} if isinstance(loaded, dict): value = loaded value["bypassPermissionsModeAccepted"] = True value["resumeReturnDismissed"] = True path.parent.mkdir(parents=True, exist_ok=True) path.write_text(json.dumps(value, indent=2) + "\n", encoding="utf-8") os.chmod(path, 0o600) def configure_claude_settings(path: Path) -> None: """Layer unattended deny rules and a command hook onto Claude settings.""" value: dict[str, Any] = {} if path.is_file(): try: loaded = json.loads(path.read_text(encoding="utf-8")) except (OSError, json.JSONDecodeError): loaded = {} if isinstance(loaded, dict): value = loaded permissions = value.get("permissions") if not isinstance(permissions, dict): permissions = {} legacy_managed_denies = [ "Bash(kubectl apply *)", "Bash(kubectl delete *)", "Bash(kubectl patch *)", "Bash(kubectl scale *)", "Bash(kubectl exec *)", "Bash(kubectl port-forward *)", "Bash(flux reconcile *)", "Bash(flux suspend *)", "Bash(flux resume *)", "Bash(vault kv *)", ] managed_denies = [ "Bash(git push --force *)", "Bash(git reset --hard *)", "Bash(git clean -f *)", ] existing_denies = permissions.get("deny") if not isinstance(existing_denies, list): existing_denies = [] previous_managed = {*legacy_managed_denies, *managed_denies} existing_denies = [ str(item) for item in existing_denies if str(item) not in previous_managed ] permissions["deny"] = [ *existing_denies, *managed_denies, ] value["permissions"] = permissions hooks = value.get("hooks") if not isinstance(hooks, dict): hooks = {} managed_hook = { "matcher": "Bash", "hooks": [ { "type": "command", "command": "/opt/hermes/.venv/bin/python /opt/coordinator/claude_command_policy.py", "timeout": 10, } ], } existing_hooks = hooks.get("PreToolUse") if not isinstance(existing_hooks, list): existing_hooks = [] existing_hooks = [ item for item in existing_hooks if "claude_command_policy.py" not in json.dumps(item, sort_keys=True) ] hooks["PreToolUse"] = [*existing_hooks, managed_hook] value["hooks"] = hooks path.parent.mkdir(parents=True, exist_ok=True) path.write_text(json.dumps(value, indent=2) + "\n", encoding="utf-8") os.chmod(path, 0o600) OWNER_PERMISSIONS_BEGIN = "# begin hermes-agent owner permissions" OWNER_PERMISSIONS_END = "# end hermes-agent owner permissions" def configure_codex_owner_permissions(path: Path) -> None: """Use the stable Codex 0.147 approval and sandbox configuration keys.""" content = path.read_text(encoding="utf-8") if path.is_file() else "" content = re.sub( rf"(?ms)^{re.escape(OWNER_PERMISSIONS_BEGIN)}\n.*?^{re.escape(OWNER_PERMISSIONS_END)}\n?", "", content, ) # Remove stale root-level values from the pre-0.147 Hermes migration. # Stop at the first TOML table so identically named nested keys remain intact. lines = content.splitlines() first_table = next( (index for index, line in enumerate(lines) if line.lstrip().startswith("[")), len(lines), ) root = [ line for line in lines[:first_table] if not re.match( r"^\s*(default_permissions|approval_policy|sandbox_mode)\s*=", line, ) ] remainder = lines[first_table:] owner_block = [ OWNER_PERMISSIONS_BEGIN, 'approval_policy = "never"', 'sandbox_mode = "danger-full-access"', OWNER_PERMISSIONS_END, "", ] rendered = "\n".join([*owner_block, *root, *remainder]).rstrip() + "\n" path.parent.mkdir(parents=True, exist_ok=True) path.write_text(rendered, encoding="utf-8") os.chmod(path, 0o600) def validate_codex_runtime(codex_bin: str = "codex") -> None: """Fail pod initialization when Codex cannot load its config or auth.""" resolved = shutil.which(codex_bin) if resolved is None: raise RuntimeError(f"Codex executable not found: {codex_bin}") result = subprocess.run( [resolved, "login", "status"], check=False, capture_output=True, text=True, timeout=20, ) if result.returncode != 0: detail = (result.stderr or result.stdout or "unknown error").strip() raise RuntimeError(f"Codex runtime validation failed: {detail}") def configure_codex_runtime( config_path: Path, migrate_fn=None, codex_home: Path | None = None, ) -> Any: """Expose Hermes tools to Codex and keep the owner lane unattended.""" config = yaml.safe_load(config_path.read_text(encoding="utf-8")) or {} if migrate_fn is None: from hermes_cli.codex_runtime_plugin_migration import migrate migrate_fn = migrate if codex_home is None: codex_home = Path( os.environ.get( "CODEX_HOME", str(Path(os.environ.get("HOME", "/opt/data/home")) / ".codex"), ) ) report = migrate_fn( config, codex_home=codex_home, default_permission_profile=None, ) if report.errors: raise RuntimeError("; ".join(str(item) for item in report.errors)) configure_codex_owner_permissions(codex_home / "config.toml") print(report.summary()) return report def main() -> None: """Configure provider clients under the persistent Hermes home.""" home = Path(os.environ.get("CLAUDE_CONFIG_DIR", "/opt/data/home/.claude")) configure_claude_state(home / ".claude.json") configure_claude_settings(home / "settings.json") hermes_home = Path(os.environ.get("HERMES_HOME", "/opt/data")) configure_codex_runtime(hermes_home / "config.yaml") validate_codex_runtime() if __name__ == "__main__": main()