fix: align Kimi hooks with official contracts
This commit is contained in:
1 parent
cb6173e6ee
commit
978e15e251
13 files changed
+2105
-745
No files matched your search
+67
-442
@@ -2,472 +2,97 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import copy
|
||||
from dataclasses import dataclass
|
||||
import os
|
||||
import shlex
|
||||
import shutil
|
||||
# subprocess provides argument quoting only; this module starts no process.
|
||||
import subprocess # nosec B404
|
||||
import sys
|
||||
from typing import Any, Dict, FrozenSet, List, Optional
|
||||
|
||||
from tomlkit import aot, document, dumps, inline_table, parse, table
|
||||
from tomlkit.items import AoT, Array
|
||||
from typing import List
|
||||
|
||||
from .base import AgentPlugin, InstallConfig, PluginStatus
|
||||
from ._file_io import (
|
||||
read_json as _read_json,
|
||||
regular_file_exists as _regular_file_exists,
|
||||
remove_file as _remove_file,
|
||||
require_runtime as _require_runtime,
|
||||
write_json_atomic as _write_json_atomic,
|
||||
write_text_atomic as _write_text_atomic,
|
||||
from .kimi_contract import (
|
||||
AGENT_KEY,
|
||||
KimiDocument,
|
||||
TomlObject,
|
||||
build_kimi_hook,
|
||||
kimi_runtime_contracts,
|
||||
remove_managed_kimi_hooks,
|
||||
render_kimi_document,
|
||||
)
|
||||
from .hook_template import generate_hook_script
|
||||
from .kimi_installation import (
|
||||
commit_kimi_installation,
|
||||
commit_kimi_uninstall,
|
||||
preflight_kimi_installation,
|
||||
prepare_kimi_installation,
|
||||
prepare_kimi_uninstall,
|
||||
)
|
||||
from .kimi_io import kimi_runtime_files_exist, read_kimi_documents
|
||||
|
||||
|
||||
AGENT_KEY = "kimi"
|
||||
GUARD_SCRIPT = "guard.py"
|
||||
AUDIT_SCRIPT = "hook.py"
|
||||
HOOK_TIMEOUT_SECONDS = 10
|
||||
ELYDORA_DIR = os.path.join(os.path.expanduser("~"), ".elydora")
|
||||
|
||||
SHARED_EVENTS: FrozenSet[str] = frozenset({
|
||||
"PreToolUse",
|
||||
"PostToolUse",
|
||||
"PostToolUseFailure",
|
||||
"UserPromptSubmit",
|
||||
"Stop",
|
||||
"StopFailure",
|
||||
"SessionStart",
|
||||
"SessionEnd",
|
||||
"SubagentStart",
|
||||
"SubagentStop",
|
||||
"PreCompact",
|
||||
"PostCompact",
|
||||
"Notification",
|
||||
})
|
||||
MODERN_EVENTS = SHARED_EVENTS | {
|
||||
"PermissionRequest",
|
||||
"PermissionResult",
|
||||
"Interrupt",
|
||||
}
|
||||
LEGACY_EVENTS = SHARED_EVENTS
|
||||
SUPPORTED_FIELDS = frozenset({"event", "matcher", "command", "timeout"})
|
||||
|
||||
TomlObject = Dict[str, Any]
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class KimiContract:
|
||||
runtime_name: str
|
||||
label: str
|
||||
config_path: str
|
||||
events: FrozenSet[str]
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class KimiDocument:
|
||||
contract: KimiContract
|
||||
exists: bool
|
||||
raw: str
|
||||
value: Any
|
||||
hooks: List[TomlObject]
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class RuntimeContract:
|
||||
guard: str
|
||||
audit: str
|
||||
config_path: str
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ConfigMutation:
|
||||
kind: str
|
||||
source: KimiDocument
|
||||
raw: str = ""
|
||||
|
||||
|
||||
def _home_dir() -> str:
|
||||
return os.path.expanduser("~")
|
||||
|
||||
|
||||
def _path_exists(path: str, label: str) -> bool:
|
||||
try:
|
||||
os.stat(path)
|
||||
return True
|
||||
except (FileNotFoundError, NotADirectoryError):
|
||||
return False
|
||||
except OSError as error:
|
||||
raise OSError(f"Inspect {label} at {path}: {error}") from error
|
||||
|
||||
|
||||
def _legacy_cli_on_path() -> bool:
|
||||
try:
|
||||
return shutil.which("kimi-cli") is not None
|
||||
except OSError as error:
|
||||
raise OSError(f"Inspect kimi-cli executable on PATH: {error}") from error
|
||||
|
||||
|
||||
def _resolve_contracts() -> List[KimiContract]:
|
||||
home = _home_dir()
|
||||
explicit_home = os.environ.get("KIMI_CODE_HOME") or None
|
||||
kimi_home = explicit_home or os.path.join(home, ".kimi-code")
|
||||
modern = KimiContract(
|
||||
runtime_name="Kimi Code",
|
||||
label="Kimi Code hooks config",
|
||||
config_path=os.path.join(kimi_home, "config.toml"),
|
||||
events=MODERN_EVENTS,
|
||||
)
|
||||
legacy = KimiContract(
|
||||
runtime_name="kimi-cli",
|
||||
label="kimi-cli legacy hooks config",
|
||||
config_path=os.path.join(home, ".kimi", "config.toml"),
|
||||
events=LEGACY_EVENTS,
|
||||
)
|
||||
modern_detected = explicit_home is not None or _path_exists(
|
||||
kimi_home, "Kimi Code home"
|
||||
)
|
||||
legacy_detected = _path_exists(
|
||||
legacy.config_path, legacy.label
|
||||
) or _legacy_cli_on_path()
|
||||
if legacy_detected and not modern_detected:
|
||||
return [legacy]
|
||||
return [modern, legacy] if legacy_detected else [modern]
|
||||
|
||||
|
||||
def _unwrap(value: Any) -> Any:
|
||||
unwrap = getattr(value, "unwrap", None)
|
||||
return unwrap() if callable(unwrap) else value
|
||||
|
||||
|
||||
def _validate_hook(value: Any, contract: KimiContract, index: int) -> TomlObject:
|
||||
raw = _unwrap(value)
|
||||
if not isinstance(raw, dict):
|
||||
raise ValueError(f"{contract.label} hook {index + 1} must be a table")
|
||||
hook = dict(raw)
|
||||
unsupported = next((key for key in hook if key not in SUPPORTED_FIELDS), None)
|
||||
if unsupported is not None:
|
||||
raise ValueError(
|
||||
f'{contract.label} hook {index + 1} has unsupported field "{unsupported}"'
|
||||
)
|
||||
event = hook.get("event")
|
||||
if not isinstance(event, str) or event not in contract.events:
|
||||
raise ValueError(
|
||||
f'{contract.label} hook {index + 1} has unsupported event "{event}"'
|
||||
)
|
||||
command = hook.get("command")
|
||||
if not isinstance(command, str) or not command:
|
||||
raise ValueError(
|
||||
f"{contract.label} hook {index + 1} requires a non-empty command"
|
||||
)
|
||||
matcher = hook.get("matcher")
|
||||
if matcher is not None and not isinstance(matcher, str):
|
||||
raise ValueError(f"{contract.label} hook {index + 1} matcher must be a string")
|
||||
timeout = hook.get("timeout")
|
||||
if timeout is not None and (
|
||||
isinstance(timeout, bool)
|
||||
or not isinstance(timeout, int)
|
||||
or timeout < 1
|
||||
or timeout > 600
|
||||
):
|
||||
raise ValueError(
|
||||
f"{contract.label} hook {index + 1} timeout must be an integer from 1 to 600"
|
||||
)
|
||||
return hook
|
||||
|
||||
|
||||
def _read_hooks(value: Any, contract: KimiContract) -> List[TomlObject]:
|
||||
if "hooks" not in value:
|
||||
return []
|
||||
container = value["hooks"]
|
||||
raw = _unwrap(container)
|
||||
if not isinstance(raw, list):
|
||||
raise ValueError(f'{contract.label} field "hooks" must be an array')
|
||||
return [_validate_hook(item, contract, index) for index, item in enumerate(raw)]
|
||||
|
||||
|
||||
def _read_config(contract: KimiContract) -> KimiDocument:
|
||||
try:
|
||||
with open(
|
||||
contract.config_path,
|
||||
"r",
|
||||
encoding="utf-8",
|
||||
newline="",
|
||||
) as file:
|
||||
raw = file.read()
|
||||
except FileNotFoundError:
|
||||
value = document()
|
||||
return KimiDocument(contract, False, "", value, [])
|
||||
except OSError as error:
|
||||
raise OSError(
|
||||
f"Read {contract.label} at {contract.config_path}: {error}"
|
||||
) from error
|
||||
|
||||
try:
|
||||
value = parse(raw)
|
||||
except Exception as error:
|
||||
raise ValueError(
|
||||
f"Failed to parse {contract.label} at {contract.config_path}: {error}"
|
||||
) from error
|
||||
return KimiDocument(contract, True, raw, value, _read_hooks(value, contract))
|
||||
|
||||
|
||||
def _read_all_configs() -> List[KimiDocument]:
|
||||
return [_read_config(contract) for contract in _resolve_contracts()]
|
||||
|
||||
|
||||
def _build_command(script_path: str) -> str:
|
||||
arguments = [sys.executable, script_path]
|
||||
return (
|
||||
subprocess.list2cmdline(arguments)
|
||||
if os.name == "nt"
|
||||
else shlex.join(arguments)
|
||||
)
|
||||
|
||||
|
||||
def _build_hook(event: str, script_path: str) -> TomlObject:
|
||||
return {
|
||||
"event": event,
|
||||
"command": _build_command(script_path),
|
||||
"timeout": HOOK_TIMEOUT_SECONDS,
|
||||
}
|
||||
|
||||
|
||||
def _render_hooks(
|
||||
def _installed_hooks(
|
||||
source: KimiDocument,
|
||||
keep_indices: List[int],
|
||||
additions: List[TomlObject],
|
||||
) -> str:
|
||||
candidate = copy.deepcopy(source.value)
|
||||
if "hooks" in candidate:
|
||||
container: Any = candidate["hooks"]
|
||||
if not isinstance(container, (AoT, Array)):
|
||||
raise ValueError(f'{source.contract.label} field "hooks" must be an array')
|
||||
keep = set(keep_indices)
|
||||
for index in range(len(source.hooks) - 1, -1, -1):
|
||||
if index not in keep:
|
||||
del container[index]
|
||||
else:
|
||||
container = aot()
|
||||
candidate.add("hooks", container)
|
||||
|
||||
for hook in additions:
|
||||
item = table() if isinstance(container, AoT) else inline_table()
|
||||
for key, value in hook.items():
|
||||
item[key] = value
|
||||
container.append(item)
|
||||
if len(container) == 0:
|
||||
del candidate["hooks"]
|
||||
return dumps(candidate)
|
||||
|
||||
|
||||
def _normalize(value: str) -> str:
|
||||
return os.path.normcase(value)
|
||||
|
||||
|
||||
def _command_references(command: str, path: str) -> bool:
|
||||
return _normalize(path) in _normalize(command)
|
||||
|
||||
|
||||
def _is_managed_hook(
|
||||
hook: TomlObject,
|
||||
event: str,
|
||||
script_name: str,
|
||||
agent_id: str = "",
|
||||
) -> bool:
|
||||
if hook.get("event") != event or not isinstance(hook.get("command"), str):
|
||||
return False
|
||||
command = str(hook["command"])
|
||||
if agent_id:
|
||||
script_path = os.path.join(ELYDORA_DIR, agent_id, script_name)
|
||||
return _command_references(command, script_path)
|
||||
normalized = _normalize(command)
|
||||
return (
|
||||
_normalize(f"{os.sep}.elydora{os.sep}") in normalized
|
||||
and _normalize(f"{os.sep}{script_name}") in normalized
|
||||
)
|
||||
|
||||
|
||||
def _kept_hook_indices(source: KimiDocument, agent_id: str = "") -> List[int]:
|
||||
guard_path: str,
|
||||
audit_path: str,
|
||||
) -> List[TomlObject]:
|
||||
return [
|
||||
index
|
||||
for index, hook in enumerate(source.hooks)
|
||||
if not _is_managed_hook(hook, "PreToolUse", GUARD_SCRIPT, agent_id)
|
||||
and not _is_managed_hook(hook, "PostToolUse", AUDIT_SCRIPT, agent_id)
|
||||
*remove_managed_kimi_hooks(source.hooks),
|
||||
build_kimi_hook("PreToolUse", guard_path),
|
||||
build_kimi_hook("PostToolUse", audit_path),
|
||||
build_kimi_hook("PostToolUseFailure", audit_path),
|
||||
]
|
||||
|
||||
|
||||
def _runtime_contract(source: KimiDocument) -> Optional[RuntimeContract]:
|
||||
guard = next(
|
||||
(
|
||||
hook
|
||||
for hook in source.hooks
|
||||
if _is_managed_hook(hook, "PreToolUse", GUARD_SCRIPT)
|
||||
),
|
||||
None,
|
||||
)
|
||||
audit = next(
|
||||
(
|
||||
hook
|
||||
for hook in source.hooks
|
||||
if _is_managed_hook(hook, "PostToolUse", AUDIT_SCRIPT)
|
||||
),
|
||||
None,
|
||||
)
|
||||
if guard is None or audit is None:
|
||||
return None
|
||||
return RuntimeContract(
|
||||
guard=str(guard["command"]),
|
||||
audit=str(audit["command"]),
|
||||
config_path=source.contract.config_path,
|
||||
)
|
||||
|
||||
|
||||
def _runtime_scripts_exist(contracts: List[RuntimeContract]) -> bool:
|
||||
try:
|
||||
with os.scandir(ELYDORA_DIR) as iterator:
|
||||
entries = sorted(iterator, key=lambda entry: entry.name)
|
||||
except FileNotFoundError:
|
||||
return False
|
||||
except OSError as error:
|
||||
raise OSError(f"Read Elydora runtime directory at {ELYDORA_DIR}: {error}") from error
|
||||
|
||||
for entry in entries:
|
||||
try:
|
||||
if not entry.is_dir(follow_symlinks=False):
|
||||
continue
|
||||
except OSError as error:
|
||||
raise OSError(f"Read Elydora runtime entry at {entry.path}: {error}") from error
|
||||
guard_path = os.path.join(entry.path, GUARD_SCRIPT)
|
||||
audit_path = os.path.join(entry.path, AUDIT_SCRIPT)
|
||||
if not any(
|
||||
_command_references(contract.guard, guard_path)
|
||||
and _command_references(contract.audit, audit_path)
|
||||
for contract in contracts
|
||||
):
|
||||
continue
|
||||
config_path = os.path.join(entry.path, "config.json")
|
||||
runtime_config = _read_json(config_path, "Elydora runtime config")
|
||||
if runtime_config is None:
|
||||
continue
|
||||
agent_name = runtime_config.get("agent_name")
|
||||
if not isinstance(agent_name, str):
|
||||
raise ValueError(
|
||||
f'Elydora runtime config at {config_path} field "agent_name" '
|
||||
"must be a string"
|
||||
)
|
||||
if agent_name != AGENT_KEY:
|
||||
continue
|
||||
return _regular_file_exists(
|
||||
guard_path, "Elydora guard runtime"
|
||||
) and _regular_file_exists(audit_path, "Elydora audit runtime")
|
||||
return False
|
||||
|
||||
|
||||
def _apply_mutation(mutation: ConfigMutation) -> None:
|
||||
path = mutation.source.contract.config_path
|
||||
if mutation.kind == "write":
|
||||
_write_text_atomic(path, mutation.raw, 0o600, mutation.source.contract.label)
|
||||
elif mutation.kind == "remove":
|
||||
_remove_file(path, mutation.source.contract.label)
|
||||
|
||||
|
||||
class KimiPlugin(AgentPlugin):
|
||||
"""Install Elydora into detected Kimi user hook contracts."""
|
||||
|
||||
manages_guard_runtime = True
|
||||
|
||||
def preflight_install(self, config: InstallConfig) -> None:
|
||||
documents = read_kimi_documents()
|
||||
preflight_kimi_installation(config, documents)
|
||||
|
||||
def install(self, config: InstallConfig) -> None:
|
||||
agent_id = config.get("agent_id", "")
|
||||
if not agent_id:
|
||||
raise ValueError("agent_id is required")
|
||||
sources = _read_all_configs()
|
||||
guard_path = config.get("guard_script_path", "")
|
||||
_require_runtime(guard_path, "Elydora guard runtime")
|
||||
agent_dir = os.path.join(ELYDORA_DIR, agent_id)
|
||||
audit_path = os.path.join(agent_dir, AUDIT_SCRIPT)
|
||||
mutations = [
|
||||
ConfigMutation(
|
||||
"write",
|
||||
documents = read_kimi_documents()
|
||||
paths = preflight_kimi_installation(config, documents)
|
||||
rendered = [
|
||||
render_kimi_document(
|
||||
source,
|
||||
_render_hooks(
|
||||
source,
|
||||
_kept_hook_indices(source),
|
||||
[
|
||||
_build_hook("PreToolUse", guard_path),
|
||||
_build_hook("PostToolUse", audit_path),
|
||||
],
|
||||
),
|
||||
_installed_hooks(source, paths.guard_path, paths.audit_path),
|
||||
)
|
||||
for source in sources
|
||||
for source in documents
|
||||
]
|
||||
runtime_config: TomlObject = {
|
||||
"org_id": config.get("org_id", ""),
|
||||
"agent_id": agent_id,
|
||||
"kid": config.get("kid", ""),
|
||||
"base_url": config.get("base_url", "https://api.elydora.com"),
|
||||
"token": config.get("token", ""),
|
||||
"agent_name": AGENT_KEY,
|
||||
}
|
||||
audit_script = generate_hook_script(
|
||||
org_id=config.get("org_id", ""),
|
||||
agent_id=agent_id,
|
||||
kid=config.get("kid", ""),
|
||||
base_url=config.get("base_url", "https://api.elydora.com"),
|
||||
changes = prepare_kimi_installation(config, paths, rendered)
|
||||
commit_kimi_installation(changes)
|
||||
runtimes = " and ".join(
|
||||
source.contract.runtime_name for source in documents
|
||||
)
|
||||
_write_json_atomic(
|
||||
os.path.join(agent_dir, "config.json"),
|
||||
runtime_config,
|
||||
0o600,
|
||||
"Elydora runtime config",
|
||||
print(
|
||||
f"{runtimes}: global PreToolUse, PostToolUse, "
|
||||
"and PostToolUseFailure hooks installed."
|
||||
)
|
||||
_write_text_atomic(
|
||||
os.path.join(agent_dir, "private.key"),
|
||||
config.get("private_key", ""),
|
||||
0o600,
|
||||
"Elydora private key",
|
||||
)
|
||||
_write_text_atomic(audit_path, audit_script, 0o700, "Elydora audit runtime")
|
||||
for mutation in mutations:
|
||||
_apply_mutation(mutation)
|
||||
runtimes = " and ".join(source.contract.runtime_name for source in sources)
|
||||
print(f"{runtimes}: global PreToolUse and PostToolUse hooks installed.")
|
||||
|
||||
def uninstall(self, agent_id: str = "") -> None:
|
||||
sources = _read_all_configs()
|
||||
mutations: List[ConfigMutation] = []
|
||||
for source in sources:
|
||||
keep_indices = _kept_hook_indices(source, agent_id)
|
||||
if len(keep_indices) == len(source.hooks):
|
||||
mutations.append(ConfigMutation("none", source))
|
||||
continue
|
||||
raw = _render_hooks(source, keep_indices, [])
|
||||
mutations.append(
|
||||
ConfigMutation("write" if raw.strip() else "remove", source, raw)
|
||||
documents = read_kimi_documents()
|
||||
rendered = [
|
||||
render_kimi_document(
|
||||
source,
|
||||
remove_managed_kimi_hooks(source.hooks, agent_id),
|
||||
)
|
||||
for mutation in mutations:
|
||||
_apply_mutation(mutation)
|
||||
for source in documents
|
||||
]
|
||||
commit_kimi_uninstall(prepare_kimi_uninstall(rendered))
|
||||
|
||||
def status(self) -> PluginStatus:
|
||||
sources = _read_all_configs()
|
||||
contracts = [
|
||||
contract
|
||||
for source in sources
|
||||
for contract in [_runtime_contract(source)]
|
||||
if contract is not None
|
||||
]
|
||||
if not contracts:
|
||||
return PluginStatus(
|
||||
installed=False,
|
||||
agent=AGENT_KEY,
|
||||
details="Not installed",
|
||||
)
|
||||
installed = _runtime_scripts_exist(contracts)
|
||||
paths = ", ".join(contract.config_path for contract in contracts)
|
||||
documents = read_kimi_documents()
|
||||
contracts = kimi_runtime_contracts(documents)
|
||||
installed = bool(contracts) and kimi_runtime_files_exist(contracts)
|
||||
config_path = (
|
||||
contracts[-1].config_path
|
||||
if contracts
|
||||
else documents[0].contract.config_path
|
||||
)
|
||||
details = (
|
||||
f"Config: {paths}"
|
||||
f"Config: {config_path}"
|
||||
if installed
|
||||
else f"Configured at {paths}; runtime scripts missing"
|
||||
else f"Configured at {config_path}; managed contract incomplete"
|
||||
)
|
||||
return PluginStatus(
|
||||
installed=installed,
|
||||
agent=AGENT_KEY,
|
||||
details=details if contracts else "Not installed",
|
||||
)
|
||||
return PluginStatus(installed=installed, agent=AGENT_KEY, details=details)
|
||||
Reference in new issue
Block a user