fix: validate Auggie matchers with JavaScript
This commit is contained in:
1 parent
453804dff9
commit
86152c8179
5 files changed
+127
-8
No files matched your search
@@ -25,6 +25,7 @@ This repository owns the published `elydora` package, its CLI, synchronous and a
|
||||
- Select Kimi Code and legacy kimi-cli config targets from existing runtime evidence; a fresh installation targets current Kimi Code and keeps migration markers isolated to active runtimes.
|
||||
- Keep Grok Build writes inside its native global `$GROK_HOME/hooks/*.json` contract. Treat Claude Code and Cursor compatibility files plus project `.grok/hooks` as read-only integration sources.
|
||||
- Write Auggie hooks only to `~/.augment/settings.json`; keep system and workspace settings read-only. Generate `.cmd` wrappers on Windows and `.sh` wrappers on Unix because Auggie dispatches supported script paths, and express hook timeouts in milliseconds.
|
||||
- Validate Auggie matcher syntax during installation with Node.js `new RegExp`; keep status and uninstall independent from the JavaScript validator so recovery remains available offline.
|
||||
|
||||
## Code Quality
|
||||
|
||||
|
||||
@@ -3,6 +3,8 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import shutil
|
||||
import subprocess # nosec B404
|
||||
from typing import List
|
||||
|
||||
from ._file_io import (
|
||||
@@ -17,6 +19,7 @@ from .augment_contract import (
|
||||
AGENT_KEY,
|
||||
AUDIT_SCRIPT,
|
||||
AugmentDocument,
|
||||
AugmentHooks,
|
||||
JsonObject,
|
||||
RuntimeContract,
|
||||
build_handler,
|
||||
@@ -32,6 +35,76 @@ from .base import AgentPlugin, InstallConfig, PluginStatus
|
||||
from .hook_template import generate_hook_script
|
||||
|
||||
|
||||
_REGEX_VALIDATION_TIMEOUT_SECONDS = 10
|
||||
_REGEX_VALIDATOR = """import fs from "node:fs";
|
||||
const pattern = fs.readFileSync(0, "utf8");
|
||||
try {
|
||||
new RegExp(pattern);
|
||||
} catch (error) {
|
||||
process.stderr.write(error instanceof Error ? error.message : String(error));
|
||||
process.exit(1);
|
||||
}
|
||||
"""
|
||||
|
||||
|
||||
def _resolve_node_runtime() -> str:
|
||||
node_path = shutil.which("node")
|
||||
if node_path is None:
|
||||
raise FileNotFoundError(
|
||||
"Node.js runtime is required to validate Auggie matcher expressions"
|
||||
)
|
||||
return node_path
|
||||
|
||||
|
||||
def _validate_matcher(node_path: str, label: str, matcher: str) -> None:
|
||||
try:
|
||||
result = subprocess.run( # nosec B603
|
||||
[
|
||||
node_path,
|
||||
"--input-type=module",
|
||||
"--eval",
|
||||
_REGEX_VALIDATOR,
|
||||
],
|
||||
input=matcher,
|
||||
text=True,
|
||||
capture_output=True,
|
||||
check=False,
|
||||
timeout=_REGEX_VALIDATION_TIMEOUT_SECONDS,
|
||||
)
|
||||
except subprocess.TimeoutExpired as error:
|
||||
raise TimeoutError(
|
||||
f"{label} matcher validation timed out after "
|
||||
f"{_REGEX_VALIDATION_TIMEOUT_SECONDS} seconds"
|
||||
) from error
|
||||
except OSError as error:
|
||||
raise OSError(f"Run Node.js 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(
|
||||
f"{label} matcher must be a valid JavaScript regular expression: {message}"
|
||||
)
|
||||
|
||||
|
||||
def _validate_matchers(hooks: AugmentHooks) -> None:
|
||||
matchers = [
|
||||
(event, group_index, group["matcher"])
|
||||
for event in sorted(hooks)
|
||||
for group_index, group in enumerate(hooks[event])
|
||||
if "matcher" in group
|
||||
]
|
||||
if not matchers:
|
||||
return
|
||||
node_path = _resolve_node_runtime()
|
||||
for event, group_index, matcher in matchers:
|
||||
label = f"Auggie settings group hooks.{event}[{group_index}]"
|
||||
_validate_matcher(node_path, label, matcher)
|
||||
|
||||
|
||||
def _read_config() -> AugmentDocument:
|
||||
config_path = resolve_config_path()
|
||||
root = _read_json(config_path, "Auggie settings")
|
||||
@@ -102,6 +175,7 @@ class AugmentPlugin(AgentPlugin):
|
||||
if not agent_id:
|
||||
raise ValueError("agent_id is required")
|
||||
source = _read_config()
|
||||
_validate_matchers(source.hooks)
|
||||
guard_path = config.get("guard_script_path", "")
|
||||
_require_runtime(guard_path, "Elydora guard runtime")
|
||||
|
||||
|
||||
@@ -5,7 +5,6 @@ from __future__ import annotations
|
||||
from dataclasses import dataclass
|
||||
import math
|
||||
import os
|
||||
import re
|
||||
import sys
|
||||
from typing import Any, Dict, List, Optional, Set, Tuple
|
||||
|
||||
@@ -241,12 +240,6 @@ def _validate_group(value: Any, event: str, group_index: int) -> JsonObject:
|
||||
if "matcher" in group:
|
||||
if not isinstance(matcher, str):
|
||||
raise ValueError(f"{label} matcher must be a string")
|
||||
try:
|
||||
re.compile(matcher)
|
||||
except re.error as error:
|
||||
raise ValueError(
|
||||
f"{label} matcher must be a valid regular expression: {error}"
|
||||
) from error
|
||||
if "metadata" in group:
|
||||
_validate_metadata(group["metadata"], label)
|
||||
handlers = group.get("hooks")
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import subprocess
|
||||
from typing import NoReturn
|
||||
|
||||
import pytest
|
||||
|
||||
from elydora.plugins import augment
|
||||
|
||||
|
||||
MATCHER_HOOKS = {
|
||||
"PreToolUse": [
|
||||
{
|
||||
"matcher": "(?<tool>launch-process)",
|
||||
"hooks": [],
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
|
||||
def test_matcher_validation_requires_node(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
monkeypatch.setattr(augment.shutil, "which", lambda _: None)
|
||||
|
||||
with pytest.raises(FileNotFoundError, match="Node.js runtime is required"):
|
||||
augment._validate_matchers(MATCHER_HOOKS)
|
||||
|
||||
|
||||
def test_matcher_validation_skips_node_without_matchers(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
def fail_lookup(_: str) -> NoReturn:
|
||||
raise AssertionError("Node.js lookup must be skipped")
|
||||
|
||||
monkeypatch.setattr(augment.shutil, "which", fail_lookup)
|
||||
|
||||
augment._validate_matchers({"SessionStart": [{"hooks": []}]})
|
||||
|
||||
|
||||
def test_matcher_validation_surfaces_timeout(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
def time_out(*args: object, **kwargs: object) -> NoReturn:
|
||||
raise subprocess.TimeoutExpired(cmd="node", timeout=10)
|
||||
|
||||
monkeypatch.setattr(augment.shutil, "which", lambda _: "node")
|
||||
monkeypatch.setattr(augment.subprocess, "run", time_out)
|
||||
|
||||
with pytest.raises(TimeoutError, match="timed out after 10 seconds"):
|
||||
augment._validate_matchers(MATCHER_HOOKS)
|
||||
@@ -159,7 +159,7 @@ def test_install_preserves_user_settings_and_is_idempotent(
|
||||
],
|
||||
"PreToolUse": [
|
||||
{
|
||||
"matcher": "launch-process",
|
||||
"matcher": "(?<tool>launch-process)",
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
|
||||
Reference in new issue
Block a user