Files
Elydora-Python-SDK/elydora/plugins/gemini_contract.py
T
雷电芽衣 6dd0197cab chore(python): audit providers claudecode codex cursor gemini
Add _runtime (paths, config validation, status checks) and _shell_command
(quoting, parsers) and move the four providers onto them.

Claude-Session: https://claude.ai/code/session_01Agoc9bRVE9KL7mmWtey9Vq
2026-09-02 23:36:33 -04:00

341 lines
10 KiB
Python

"""Gemini CLI hook schema, controls, and exact ownership rules."""
from __future__ import annotations
from dataclasses import dataclass
import math
import os
from typing import Any, Dict, List, Optional, Tuple
from ._runtime import same_agent_id
from .gemini_command import (
GeminiRuntimeReference,
build_gemini_command,
gemini_runtime_reference,
)
AGENT_KEY = "gemini"
CONFIG_FILE = "settings.json"
GUARD_SCRIPT = "guard.py"
AUDIT_SCRIPT = "hook.py"
GUARD_HOOK_NAME = "elydora-guard"
AUDIT_HOOK_NAME = "elydora-audit"
HOOK_TIMEOUT_MILLISECONDS = 10_000
MANAGED_EVENTS = ("BeforeTool", "AfterTool")
KNOWN_EVENTS = {
"BeforeTool",
"AfterTool",
"BeforeAgent",
"Notification",
"AfterAgent",
"SessionStart",
"SessionEnd",
"PreCompress",
"BeforeModel",
"AfterModel",
"BeforeToolSelection",
}
JsonObject = Dict[str, Any]
GeminiHooks = Dict[str, List[JsonObject]]
@dataclass(frozen=True)
class GeminiHookControls:
enabled: bool
disabled: Tuple[str, ...]
@dataclass(frozen=True)
class GeminiRuntimeContract:
agent_id: str
guard_path: str
audit_path: str
@dataclass(frozen=True)
class ManagedRemoval:
event: str
group_index: int
handler_indexes: Tuple[int, ...]
remove_group: bool
def _optional_string(value: JsonObject, field: str, label: str) -> None:
if field in value and not isinstance(value[field], str):
raise ValueError(f'{label} field "{field}" must be a string')
def _validate_timeout(value: JsonObject, label: str) -> None:
if "timeout" not in value:
return
timeout = value["timeout"]
if (
isinstance(timeout, bool)
or not isinstance(timeout, (int, float))
or not math.isfinite(float(timeout))
or timeout < 0
):
raise ValueError(f"{label} timeout must be a non-negative finite number")
def _validate_environment(value: JsonObject, label: str) -> None:
if "env" not in value:
return
environment = value["env"]
if not isinstance(environment, dict) or any(
not isinstance(item, str) for item in environment.values()
):
raise ValueError(f"{label} env must map names to strings")
def _validate_handler(
value: Any, event: str, group_index: int, handler_index: int
) -> JsonObject:
label = (
f"Gemini CLI settings handler hooks.{event}[{group_index}]"
f".hooks[{handler_index}]"
)
if not isinstance(value, dict):
raise ValueError(f"{label} must be an object")
handler = dict(value)
if handler.get("type") != "command":
raise ValueError(
f'{label} has unsupported type "{handler.get("type")}"'
)
for field in ("name", "description", "source"):
_optional_string(handler, field, label)
_validate_timeout(handler, label)
_validate_environment(handler, label)
if not isinstance(handler.get("command"), str) or not handler["command"]:
raise ValueError(f"{label} requires a non-empty command")
return handler
def _validate_group(value: Any, event: str, index: int) -> JsonObject:
label = f"Gemini CLI settings group hooks.{event}[{index}]"
if not isinstance(value, dict):
raise ValueError(f"{label} must be an object")
group = dict(value)
if "matcher" in group and not isinstance(group["matcher"], str):
raise ValueError(f"{label} matcher must be a string")
if "sequential" in group and not isinstance(group["sequential"], bool):
raise ValueError(f"{label} sequential must be a boolean")
handlers = group.get("hooks")
if not isinstance(handlers, list):
raise ValueError(f"{label} must contain a hooks array")
group["hooks"] = [
_validate_handler(handler, event, index, handler_index)
for handler_index, handler in enumerate(handlers)
]
return group
def read_gemini_hooks(value: Any) -> GeminiHooks:
if not isinstance(value, dict):
raise ValueError('Gemini CLI settings field "hooks" must be an object')
hooks: GeminiHooks = {}
for event, groups in value.items():
if not isinstance(groups, list):
raise ValueError(
f'Gemini CLI settings field "hooks.{event}" must be an array'
)
hooks[event] = (
[
_validate_group(group, event, index)
for index, group in enumerate(groups)
]
if event in KNOWN_EVENTS
else list(groups)
)
return hooks
def read_gemini_hook_controls(value: Any) -> GeminiHookControls:
if not isinstance(value, dict):
raise ValueError(
'Gemini CLI settings field "hooksConfig" must be an object'
)
supported = {"enabled", "disabled", "notifications"}
extra = next((field for field in value if field not in supported), None)
if extra is not None:
raise ValueError(
"Gemini CLI settings field \"hooksConfig\" contains unsupported "
f'field "{extra}"'
)
if "enabled" in value and not isinstance(value["enabled"], bool):
raise ValueError(
'Gemini CLI settings field "hooksConfig.enabled" must be a boolean'
)
if "notifications" in value and not isinstance(
value["notifications"], bool
):
raise ValueError(
"Gemini CLI settings field \"hooksConfig.notifications\" must be "
"a boolean"
)
disabled = value.get("disabled", [])
if not isinstance(disabled, list) or any(
not isinstance(item, str) for item in disabled
):
raise ValueError(
"Gemini CLI settings field \"hooksConfig.disabled\" must be an "
"array of strings"
)
return GeminiHookControls(value.get("enabled") is not False, tuple(disabled))
def managed_gemini_hooks_enabled(controls: GeminiHookControls) -> bool:
return (
controls.enabled
and GUARD_HOOK_NAME not in controls.disabled
and AUDIT_HOOK_NAME not in controls.disabled
)
def disabled_managed_gemini_entries(
controls: GeminiHookControls,
) -> Tuple[str, ...]:
return tuple(
entry
for entry in controls.disabled
if entry in {GUARD_HOOK_NAME, AUDIT_HOOK_NAME}
or gemini_runtime_reference(entry, GUARD_SCRIPT, True) is not None
or gemini_runtime_reference(entry, AUDIT_SCRIPT, True) is not None
)
def build_gemini_group(script_path: str, name: str) -> JsonObject:
return {
"hooks": [{
"type": "command",
"name": name,
"command": build_gemini_command(script_path),
"timeout": HOOK_TIMEOUT_MILLISECONDS,
}]
}
def _exact_managed_group(group: JsonObject) -> bool:
return set(group) == {"hooks"}
def _current_managed_reference(
handler: JsonObject, script_name: str, hook_name: str
) -> Optional[GeminiRuntimeReference]:
if (
set(handler) == {"command", "name", "timeout", "type"}
and handler.get("type") == "command"
and handler.get("name") == hook_name
and handler.get("timeout") == HOOK_TIMEOUT_MILLISECONDS
and isinstance(handler.get("command"), str)
):
return gemini_runtime_reference(handler["command"], script_name)
return None
def _legacy_managed_reference(
handler: JsonObject, script_name: str
) -> Optional[GeminiRuntimeReference]:
if (
set(handler) == {"command", "type"}
and handler.get("type") == "command"
and isinstance(handler.get("command"), str)
):
return gemini_runtime_reference(handler["command"], script_name, True)
return None
def _managed_reference(
handler: JsonObject,
script_name: str,
hook_name: str,
include_legacy: bool,
) -> Optional[GeminiRuntimeReference]:
current = _current_managed_reference(handler, script_name, hook_name)
if current is not None or not include_legacy:
return current
return _legacy_managed_reference(handler, script_name)
def managed_gemini_removals(
hooks: GeminiHooks, agent_id: Optional[str] = None
) -> List[ManagedRemoval]:
removals = []
for event, script_name, hook_name in (
("BeforeTool", GUARD_SCRIPT, GUARD_HOOK_NAME),
("AfterTool", AUDIT_SCRIPT, AUDIT_HOOK_NAME),
):
for group_index, group in enumerate(hooks.get(event, [])):
handlers = group.get("hooks", [])
indexes = tuple(
index
for index, handler in enumerate(handlers)
if isinstance(handler, dict)
and (
reference := _managed_reference(
handler, script_name, hook_name, True
)
)
is not None
and (
agent_id is None
or same_agent_id(reference.agent_id, agent_id)
)
)
if indexes:
removals.append(
ManagedRemoval(
event,
group_index,
indexes,
_exact_managed_group(group)
and len(indexes) == len(handlers),
)
)
return removals
def _references_for_event(
groups: List[JsonObject], script_name: str, hook_name: str
) -> Dict[str, List[GeminiRuntimeReference]]:
result: Dict[str, List[GeminiRuntimeReference]] = {}
for group in groups:
if not _exact_managed_group(group):
continue
for handler in group["hooks"]:
reference = _current_managed_reference(
handler, script_name, hook_name
)
if reference is None:
continue
key = os.path.normcase(reference.agent_id)
result.setdefault(key, []).append(reference)
return result
def gemini_runtime_contracts(
hooks: GeminiHooks,
) -> List[GeminiRuntimeContract]:
guards = _references_for_event(
hooks.get("BeforeTool", []), GUARD_SCRIPT, GUARD_HOOK_NAME
)
audits = _references_for_event(
hooks.get("AfterTool", []), AUDIT_SCRIPT, AUDIT_HOOK_NAME
)
contracts = []
for key, guard in guards.items():
audit = audits.get(key, [])
if len(guard) != 1 or len(audit) != 1:
continue
contracts.append(
GeminiRuntimeContract(
guard[0].agent_id,
guard[0].script_path,
audit[0].script_path,
)
)
return contracts