Split the 657-line operation service into eor, submit, and verify modules, add the AGENT_REVOKED and KEY_RETIRED codes the Backend already emits, and condense every multi-line comment. Mirror Node, Python, and Go at 2.1.0 with the deep-health 503, non-idempotent retry, rotation grace, and path-encoding fixes. Document which routes and response fields the self-hosted server does not provide. Claude-Session: https://claude.ai/code/session_01Agoc9bRVE9KL7mmWtey9Vq
308 lines
9.6 KiB
Python
308 lines
9.6 KiB
Python
"""Factory Droid hook schema, command construction, and ownership rules."""
|
|
|
|
from __future__ import annotations
|
|
|
|
from dataclasses import dataclass
|
|
import json
|
|
import math
|
|
import os
|
|
import re
|
|
import shutil
|
|
import subprocess # nosec B404
|
|
import sys
|
|
from typing import Any, Dict, List, Optional, Sequence, Set, Tuple
|
|
|
|
from elydora._runtime_paths import runtime_root
|
|
|
|
from ._runtime import managed_script_reference, same_agent_id, same_path
|
|
from ._shell_command import (
|
|
parse_posix_command,
|
|
parse_powershell_source,
|
|
posix_source,
|
|
powershell_source,
|
|
)
|
|
|
|
|
|
AGENT_KEY = "droid"
|
|
GUARD_SCRIPT = "guard.py"
|
|
AUDIT_SCRIPT = "hook.py"
|
|
HOOK_TIMEOUT_SECONDS = 10
|
|
TOOL_EVENTS = ("PreToolUse", "PostToolUse")
|
|
|
|
_HANDLER_KEYS = {"command", "timeout", "type"}
|
|
_GROUP_KEYS = {"hooks", "matcher"}
|
|
_REGEX_TIMEOUT_SECONDS = 10
|
|
_REGEX_VALIDATOR = """import fs from 'node:fs';
|
|
const entries = JSON.parse(fs.readFileSync(0, 'utf8'));
|
|
for (const entry of entries) {
|
|
try {
|
|
new RegExp(entry.pattern);
|
|
} catch (error) {
|
|
const message = error instanceof Error ? error.message : String(error);
|
|
process.stderr.write(entry.label + ': ' + message);
|
|
process.exit(1);
|
|
}
|
|
}
|
|
"""
|
|
|
|
JsonObject = Dict[str, Any]
|
|
DroidHookMap = Dict[str, Any]
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class RuntimeContract:
|
|
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 build_command(script_path: str) -> str:
|
|
if not os.path.isabs(sys.executable) or not os.path.isabs(script_path):
|
|
raise ValueError(
|
|
"Factory Droid hook commands require absolute executable and script paths"
|
|
)
|
|
if os.name == "nt":
|
|
return powershell_source(script_path)
|
|
return posix_source(script_path)
|
|
|
|
|
|
def build_group(script_path: str) -> JsonObject:
|
|
return {
|
|
"matcher": "*",
|
|
"hooks": [{
|
|
"type": "command",
|
|
"command": build_command(script_path),
|
|
"timeout": HOOK_TIMEOUT_SECONDS,
|
|
}],
|
|
}
|
|
|
|
|
|
def _validate_handler(value: Any, label: str) -> JsonObject:
|
|
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} type must be "command"')
|
|
command = handler.get("command")
|
|
if not isinstance(command, str) or not command.strip():
|
|
raise ValueError(f"{label} command must be a non-empty string")
|
|
timeout = handler.get("timeout")
|
|
if timeout is not None and (
|
|
isinstance(timeout, bool)
|
|
or not isinstance(timeout, (int, float))
|
|
or not math.isfinite(timeout)
|
|
or timeout <= 0
|
|
):
|
|
raise ValueError(f"{label} timeout must be a positive finite number")
|
|
return handler
|
|
|
|
|
|
def _validate_group(value: Any, label: str) -> JsonObject:
|
|
if not isinstance(value, dict):
|
|
raise ValueError(f"{label} must be an object")
|
|
group = dict(value)
|
|
matcher = group.get("matcher")
|
|
if "matcher" in group and not isinstance(matcher, str):
|
|
raise ValueError(f"{label} matcher must be a string")
|
|
command_regex = group.get("commandRegex")
|
|
if "commandRegex" in group and not isinstance(command_regex, str):
|
|
raise ValueError(f"{label} commandRegex must be a string")
|
|
handlers = group.get("hooks")
|
|
if not isinstance(handlers, list):
|
|
raise ValueError(f"{label} must contain a hooks array")
|
|
group["hooks"] = [
|
|
_validate_handler(handler, f"{label}.hooks[{index}]")
|
|
for index, handler in enumerate(handlers)
|
|
]
|
|
return group
|
|
|
|
|
|
def read_hook_map(value: Any, label: str) -> DroidHookMap:
|
|
if not isinstance(value, dict):
|
|
raise ValueError(f"{label} must contain a JSON object")
|
|
hooks: DroidHookMap = {}
|
|
for event, groups in value.items():
|
|
if not isinstance(groups, list):
|
|
raise ValueError(f'{label} field "{event}" must be an array')
|
|
hooks[event] = [
|
|
_validate_group(group, f'{label} field "{event}"[{index}]')
|
|
for index, group in enumerate(groups)
|
|
]
|
|
return hooks
|
|
|
|
|
|
def _regex_entries(hooks: DroidHookMap) -> List[JsonObject]:
|
|
entries: List[JsonObject] = []
|
|
for event, groups in hooks.items():
|
|
for index, group in enumerate(groups):
|
|
matcher = group.get("matcher")
|
|
if isinstance(matcher, str) and matcher not in {"", "*"}:
|
|
entries.append({
|
|
"label": f'Factory Droid hooks field "{event}"[{index}] matcher',
|
|
"pattern": matcher,
|
|
})
|
|
command_regex = group.get("commandRegex")
|
|
if isinstance(command_regex, str):
|
|
entries.append({
|
|
"label": (
|
|
f'Factory Droid hooks field "{event}"[{index}] commandRegex'
|
|
),
|
|
"pattern": command_regex,
|
|
})
|
|
return entries
|
|
|
|
|
|
def validate_javascript_regexes(sources: Sequence[DroidHookMap]) -> None:
|
|
entries = [entry for source in sources for entry in _regex_entries(source)]
|
|
if not entries:
|
|
return
|
|
node_path = shutil.which("node")
|
|
if node_path is None:
|
|
raise FileNotFoundError(
|
|
"Node.js runtime is required to validate Factory Droid hook expressions"
|
|
)
|
|
try:
|
|
result = subprocess.run( # nosec B603
|
|
[node_path, "--input-type=module", "--eval", _REGEX_VALIDATOR],
|
|
input=json.dumps(entries),
|
|
text=True,
|
|
capture_output=True,
|
|
check=False,
|
|
timeout=_REGEX_TIMEOUT_SECONDS,
|
|
)
|
|
except subprocess.TimeoutExpired as error:
|
|
raise TimeoutError(
|
|
"Factory Droid hook expression validation timed out after "
|
|
f"{_REGEX_TIMEOUT_SECONDS} seconds"
|
|
) from error
|
|
except OSError as error:
|
|
raise OSError(f"Run Node.js Factory Droid matcher validator: {error}") from error
|
|
if result.returncode == 0:
|
|
return
|
|
message = (
|
|
result.stderr.strip()
|
|
or result.stdout.strip()
|
|
or f"Node.js exited with code {result.returncode}"
|
|
)
|
|
raise ValueError(
|
|
"Factory Droid matcher must be a valid JavaScript regular expression: "
|
|
f"{message}"
|
|
)
|
|
|
|
|
|
def _parse_legacy_windows_command(command: str) -> Optional[Tuple[str, str]]:
|
|
match = re.fullmatch(r'"([^"\r\n]+)" "([^"\r\n]+)"', command)
|
|
return (match.group(1), match.group(2)) if match else None
|
|
|
|
|
|
def _parse_generated_command(
|
|
command: str,
|
|
include_legacy: bool,
|
|
) -> Optional[Tuple[str, str]]:
|
|
if os.name != "nt":
|
|
return parse_posix_command(command)
|
|
current = parse_powershell_source(command)
|
|
return current or (
|
|
_parse_legacy_windows_command(command) if include_legacy else None
|
|
)
|
|
|
|
|
|
def managed_agent_id(
|
|
handler: JsonObject,
|
|
script_name: str,
|
|
include_legacy: bool = False,
|
|
) -> Optional[str]:
|
|
if set(handler) != _HANDLER_KEYS:
|
|
return None
|
|
if (
|
|
handler.get("type") != "command"
|
|
or handler.get("timeout") != HOOK_TIMEOUT_SECONDS
|
|
):
|
|
return None
|
|
command = handler.get("command")
|
|
if not isinstance(command, str):
|
|
return None
|
|
parsed = _parse_generated_command(command, include_legacy)
|
|
if parsed is None or not same_path(parsed[0], sys.executable):
|
|
return None
|
|
reference = managed_script_reference(parsed[1], script_name)
|
|
return None if reference is None else reference[0]
|
|
|
|
|
|
def _exact_owned_group(group: JsonObject, indexes: Tuple[int, ...]) -> bool:
|
|
hooks = group["hooks"]
|
|
return (
|
|
set(group) == _GROUP_KEYS
|
|
and group.get("matcher") == "*"
|
|
and bool(indexes)
|
|
and len(indexes) == len(hooks)
|
|
)
|
|
|
|
|
|
def managed_removals(
|
|
hooks: DroidHookMap,
|
|
agent_id: Optional[str] = None,
|
|
) -> List[ManagedRemoval]:
|
|
removals: List[ManagedRemoval] = []
|
|
for event, script_name in (
|
|
("PreToolUse", GUARD_SCRIPT),
|
|
("PostToolUse", AUDIT_SCRIPT),
|
|
):
|
|
for group_index, group in enumerate(hooks.get(event, [])):
|
|
indexes = tuple(
|
|
index
|
|
for index, handler in enumerate(group["hooks"])
|
|
if (
|
|
(
|
|
managed_id := managed_agent_id(
|
|
handler,
|
|
script_name,
|
|
include_legacy=True,
|
|
)
|
|
)
|
|
is not None
|
|
and (agent_id is None or same_agent_id(managed_id, agent_id))
|
|
)
|
|
)
|
|
if indexes:
|
|
removals.append(ManagedRemoval(
|
|
event,
|
|
group_index,
|
|
indexes,
|
|
_exact_owned_group(group, indexes),
|
|
))
|
|
return removals
|
|
|
|
|
|
def _managed_ids(groups: List[JsonObject], script_name: str) -> Set[str]:
|
|
return {
|
|
agent_id
|
|
for group in groups
|
|
for handler in group["hooks"]
|
|
if (agent_id := managed_agent_id(handler, script_name)) is not None
|
|
}
|
|
|
|
|
|
def runtime_contracts(hooks: DroidHookMap) -> List[RuntimeContract]:
|
|
guards = _managed_ids(hooks.get("PreToolUse", []), GUARD_SCRIPT)
|
|
audits = _managed_ids(hooks.get("PostToolUse", []), AUDIT_SCRIPT)
|
|
contracts = []
|
|
for agent_id in sorted(guards):
|
|
if not any(same_agent_id(agent_id, audit_id) for audit_id in audits):
|
|
continue
|
|
root = os.path.join(runtime_root(), agent_id)
|
|
contracts.append(RuntimeContract(
|
|
agent_id,
|
|
os.path.join(root, GUARD_SCRIPT),
|
|
os.path.join(root, AUDIT_SCRIPT),
|
|
))
|
|
return contracts
|