Move the four providers onto _runtime and _shell_command; expected runtime scripts take the runtime config; add unique_preconditions. Claude-Session: https://claude.ai/code/session_01Agoc9bRVE9KL7mmWtey9Vq
404 lines
13 KiB
Python
404 lines
13 KiB
Python
"""Qwen Code 0.20 hook schema and exact ownership rules."""
|
|
|
|
from __future__ import annotations
|
|
|
|
from dataclasses import dataclass
|
|
import json
|
|
import math
|
|
import os
|
|
import shutil
|
|
import subprocess # nosec B404
|
|
from typing import Any, Dict, List, Optional, Sequence, Tuple
|
|
|
|
from ._runtime import same_agent_id, same_path
|
|
from .qwen_command import (
|
|
QwenRuntimeReference,
|
|
build_qwen_command,
|
|
qwen_runtime_reference,
|
|
)
|
|
|
|
|
|
AGENT_KEY = "qwen"
|
|
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 = ("PreToolUse", "PostToolUse", "PostToolUseFailure")
|
|
|
|
KNOWN_EVENTS = {
|
|
"PreToolUse",
|
|
"PostToolUse",
|
|
"PostToolUseFailure",
|
|
"PostToolBatch",
|
|
"Notification",
|
|
"UserPromptSubmit",
|
|
"UserPromptExpansion",
|
|
"SessionStart",
|
|
"Stop",
|
|
"MessageDisplay",
|
|
"SubagentStart",
|
|
"SubagentStop",
|
|
"PreCompact",
|
|
"PostCompact",
|
|
"SessionEnd",
|
|
"PermissionRequest",
|
|
"PermissionDenied",
|
|
"StopFailure",
|
|
"TodoCreated",
|
|
"TodoCompleted",
|
|
"InstructionsLoaded",
|
|
}
|
|
|
|
REGEX_MATCHER_EVENTS = {
|
|
"PreToolUse",
|
|
"PostToolUse",
|
|
"PostToolUseFailure",
|
|
"PermissionRequest",
|
|
"PermissionDenied",
|
|
"SubagentStart",
|
|
"SubagentStop",
|
|
"PreCompact",
|
|
"PostCompact",
|
|
"SessionStart",
|
|
"SessionEnd",
|
|
"StopFailure",
|
|
"Notification",
|
|
"InstructionsLoaded",
|
|
"UserPromptExpansion",
|
|
}
|
|
|
|
_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]
|
|
QwenHooks = Dict[str, Any]
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class QwenRuntimeContract:
|
|
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 _optional_boolean(value: JsonObject, field: str, label: str) -> None:
|
|
if field in value and not isinstance(value[field], bool):
|
|
raise ValueError(f'{label} field "{field}" must be a boolean')
|
|
|
|
|
|
def _optional_string_map(value: JsonObject, field: str, label: str) -> None:
|
|
if field not in value:
|
|
return
|
|
item = value[field]
|
|
if not isinstance(item, dict) or any(
|
|
not isinstance(entry, str) for entry in item.values()
|
|
):
|
|
raise ValueError(f'{label} field "{field}" must map names to strings')
|
|
|
|
|
|
def _validate_timeout(value: JsonObject, label: str) -> None:
|
|
if "timeout" not in value:
|
|
return
|
|
timeout = value["timeout"]
|
|
try:
|
|
finite = math.isfinite(timeout)
|
|
except (TypeError, OverflowError):
|
|
finite = False
|
|
if isinstance(timeout, bool) or not isinstance(timeout, (int, float)) or not finite:
|
|
raise ValueError(f"{label} timeout must be a non-negative finite number")
|
|
if timeout < 0:
|
|
raise ValueError(f"{label} timeout must be a non-negative finite number")
|
|
|
|
|
|
def _validate_handler(value: Any, label: str) -> JsonObject:
|
|
if not isinstance(value, dict):
|
|
raise ValueError(f"{label} must be an object")
|
|
handler = dict(value)
|
|
kind = handler.get("type")
|
|
if kind not in {"command", "http", "prompt"}:
|
|
raise ValueError(f'{label} has unsupported type "{kind}"')
|
|
_validate_timeout(handler, label)
|
|
for field in ("name", "description", "statusMessage", "source"):
|
|
_optional_string(handler, field, label)
|
|
if kind == "command":
|
|
if not isinstance(handler.get("command"), str) or not handler["command"]:
|
|
raise ValueError(f"{label} requires a non-empty command")
|
|
_optional_string_map(handler, "env", label)
|
|
_optional_boolean(handler, "async", label)
|
|
if "shell" in handler and handler["shell"] not in {"bash", "powershell"}:
|
|
raise ValueError(f'{label} shell must be "bash" or "powershell"')
|
|
elif kind == "http":
|
|
if not isinstance(handler.get("url"), str) or not handler["url"]:
|
|
raise ValueError(f"{label} requires a non-empty url")
|
|
_optional_string_map(handler, "headers", label)
|
|
_optional_boolean(handler, "once", label)
|
|
_optional_string(handler, "if", label)
|
|
allowed = handler.get("allowedEnvVars")
|
|
if "allowedEnvVars" in handler and (
|
|
not isinstance(allowed, list)
|
|
or any(not isinstance(item, str) for item in allowed)
|
|
):
|
|
raise ValueError(f"{label} allowedEnvVars must be an array of strings")
|
|
else:
|
|
if not isinstance(handler.get("prompt"), str) or not handler["prompt"]:
|
|
raise ValueError(f"{label} requires a non-empty prompt")
|
|
_optional_string(handler, "model", label)
|
|
return handler
|
|
|
|
|
|
def _validate_group(value: Any, event: str, index: int) -> JsonObject:
|
|
label = f"Qwen Code 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, f"{label}.hooks[{handler_index}]")
|
|
for handler_index, handler in enumerate(handlers)
|
|
]
|
|
return group
|
|
|
|
|
|
def read_qwen_hooks(value: Any) -> QwenHooks:
|
|
if not isinstance(value, dict):
|
|
raise ValueError('Qwen Code settings field "hooks" must be an object')
|
|
hooks: QwenHooks = {}
|
|
for event, groups in value.items():
|
|
if event not in KNOWN_EVENTS:
|
|
hooks[event] = groups
|
|
continue
|
|
if not isinstance(groups, list):
|
|
raise ValueError(
|
|
f'Qwen Code settings field "hooks.{event}" must be an array'
|
|
)
|
|
hooks[event] = [
|
|
_validate_group(group, event, index)
|
|
for index, group in enumerate(groups)
|
|
]
|
|
return hooks
|
|
|
|
|
|
def _matcher_entries(sources: Sequence[QwenHooks]) -> List[JsonObject]:
|
|
entries: List[JsonObject] = []
|
|
for hooks in sources:
|
|
for event in REGEX_MATCHER_EVENTS:
|
|
for index, group in enumerate(hooks.get(event, [])):
|
|
matcher = group.get("matcher")
|
|
if isinstance(matcher, str) and matcher.strip() not in {"", "*"}:
|
|
entries.append({
|
|
"label": f"Qwen Code settings group hooks.{event}[{index}]",
|
|
"pattern": matcher,
|
|
})
|
|
return entries
|
|
|
|
|
|
def validate_javascript_matchers(sources: Sequence[QwenHooks]) -> None:
|
|
entries = _matcher_entries(sources)
|
|
if not entries:
|
|
return
|
|
node_path = shutil.which("node")
|
|
if node_path is None:
|
|
raise FileNotFoundError(
|
|
"Node.js runtime is required to validate Qwen Code 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(
|
|
"Qwen Code matcher validation timed out after "
|
|
f"{_REGEX_TIMEOUT_SECONDS} seconds"
|
|
) from error
|
|
except OSError as error:
|
|
raise OSError(f"Run Node.js Qwen Code 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(
|
|
"Qwen Code matcher must be a valid JavaScript regular expression: "
|
|
+ message
|
|
)
|
|
|
|
|
|
def build_qwen_group(script_path: str, name: str) -> JsonObject:
|
|
return {
|
|
"hooks": [{
|
|
"type": "command",
|
|
"name": name,
|
|
"command": build_qwen_command(script_path),
|
|
"shell": "powershell" if os.name == "nt" else "bash",
|
|
"timeout": HOOK_TIMEOUT_MILLISECONDS,
|
|
}]
|
|
}
|
|
|
|
|
|
def _exact_current_group(group: JsonObject) -> bool:
|
|
return set(group) == {"hooks"}
|
|
|
|
|
|
def _exact_legacy_group(group: JsonObject) -> bool:
|
|
return set(group) == {"hooks", "matcher"} and group.get("matcher") == "*"
|
|
|
|
|
|
def _current_reference(
|
|
handler: JsonObject, script_name: str, hook_name: str
|
|
) -> Optional[QwenRuntimeReference]:
|
|
if (
|
|
set(handler) == {"command", "name", "shell", "timeout", "type"}
|
|
and handler.get("type") == "command"
|
|
and handler.get("name") == hook_name
|
|
and handler.get("shell") == ("powershell" if os.name == "nt" else "bash")
|
|
and handler.get("timeout") == HOOK_TIMEOUT_MILLISECONDS
|
|
and isinstance(handler.get("command"), str)
|
|
):
|
|
return qwen_runtime_reference(handler["command"], script_name)
|
|
return None
|
|
|
|
|
|
def _legacy_reference(
|
|
handler: JsonObject, script_name: str
|
|
) -> Optional[QwenRuntimeReference]:
|
|
if (
|
|
set(handler) == {"command", "shell", "timeout", "type"}
|
|
and handler.get("type") == "command"
|
|
and handler.get("shell") == ("powershell" if os.name == "nt" else "bash")
|
|
and handler.get("timeout") == HOOK_TIMEOUT_MILLISECONDS
|
|
and isinstance(handler.get("command"), str)
|
|
):
|
|
return qwen_runtime_reference(handler["command"], script_name)
|
|
return None
|
|
|
|
|
|
def _managed_reference(
|
|
handler: JsonObject,
|
|
script_name: str,
|
|
hook_name: str,
|
|
include_legacy: bool,
|
|
) -> Optional[QwenRuntimeReference]:
|
|
current = _current_reference(handler, script_name, hook_name)
|
|
if current is not None or not include_legacy:
|
|
return current
|
|
return _legacy_reference(handler, script_name)
|
|
|
|
|
|
_EVENT_CONTRACTS = (
|
|
("PreToolUse", GUARD_SCRIPT, GUARD_HOOK_NAME),
|
|
("PostToolUse", AUDIT_SCRIPT, AUDIT_HOOK_NAME),
|
|
("PostToolUseFailure", AUDIT_SCRIPT, AUDIT_HOOK_NAME),
|
|
)
|
|
|
|
|
|
def managed_qwen_removals(
|
|
hooks: QwenHooks, agent_id: Optional[str] = None
|
|
) -> List[ManagedRemoval]:
|
|
removals: List[ManagedRemoval] = []
|
|
for event, script_name, hook_name in _EVENT_CONTRACTS:
|
|
for group_index, group in enumerate(hooks.get(event, [])):
|
|
handlers = group["hooks"]
|
|
indexes = tuple(
|
|
index
|
|
for index, handler in enumerate(handlers)
|
|
if (
|
|
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_current_group(group) or _exact_legacy_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[QwenRuntimeReference]]:
|
|
references: Dict[str, List[QwenRuntimeReference]] = {}
|
|
for group in groups:
|
|
if not _exact_current_group(group):
|
|
continue
|
|
for handler in group["hooks"]:
|
|
reference = _current_reference(handler, script_name, hook_name)
|
|
if reference is None:
|
|
continue
|
|
key = os.path.normcase(reference.agent_id)
|
|
references.setdefault(key, []).append(reference)
|
|
return references
|
|
|
|
|
|
def qwen_runtime_contracts(hooks: QwenHooks) -> List[QwenRuntimeContract]:
|
|
guards = _references_for_event(
|
|
hooks.get("PreToolUse", []), GUARD_SCRIPT, GUARD_HOOK_NAME
|
|
)
|
|
posts = _references_for_event(
|
|
hooks.get("PostToolUse", []), AUDIT_SCRIPT, AUDIT_HOOK_NAME
|
|
)
|
|
failures = _references_for_event(
|
|
hooks.get("PostToolUseFailure", []), AUDIT_SCRIPT, AUDIT_HOOK_NAME
|
|
)
|
|
contracts: List[QwenRuntimeContract] = []
|
|
for key, guard in guards.items():
|
|
post = posts.get(key, [])
|
|
failure = failures.get(key, [])
|
|
if len(guard) != 1 or len(post) != 1 or len(failure) != 1:
|
|
continue
|
|
if not same_path(post[0].script_path, failure[0].script_path):
|
|
continue
|
|
contracts.append(QwenRuntimeContract(
|
|
guard[0].agent_id,
|
|
guard[0].script_path,
|
|
post[0].script_path,
|
|
))
|
|
return contracts
|