From 86152c81799c600004b6ae0183a0ee15b02ccb42 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=9B=B7=E7=94=B5=E8=8A=BD=E8=A1=A3?= Date: Sun, 19 Jul 2026 09:01:59 -0400 Subject: [PATCH] fix: validate Auggie matchers with JavaScript --- AGENTS.md | 1 + elydora/plugins/augment.py | 74 ++++++++++++++++++++++++ elydora/plugins/augment_contract.py | 7 --- tests/test_augment_matcher_validation.py | 51 ++++++++++++++++ tests/test_augment_plugin.py | 2 +- 5 files changed, 127 insertions(+), 8 deletions(-) create mode 100644 tests/test_augment_matcher_validation.py diff --git a/AGENTS.md b/AGENTS.md index cac7d82..f74ecf1 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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 diff --git a/elydora/plugins/augment.py b/elydora/plugins/augment.py index 92e5628..2a949c1 100644 --- a/elydora/plugins/augment.py +++ b/elydora/plugins/augment.py @@ -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") diff --git a/elydora/plugins/augment_contract.py b/elydora/plugins/augment_contract.py index 403cd78..cc6edfe 100644 --- a/elydora/plugins/augment_contract.py +++ b/elydora/plugins/augment_contract.py @@ -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") diff --git a/tests/test_augment_matcher_validation.py b/tests/test_augment_matcher_validation.py new file mode 100644 index 0000000..5605e80 --- /dev/null +++ b/tests/test_augment_matcher_validation.py @@ -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": "(?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) diff --git a/tests/test_augment_plugin.py b/tests/test_augment_plugin.py index 8ad7a97..2397828 100644 --- a/tests/test_augment_plugin.py +++ b/tests/test_augment_plugin.py @@ -159,7 +159,7 @@ def test_install_preserves_user_settings_and_is_idempotent( ], "PreToolUse": [ { - "matcher": "launch-process", + "matcher": "(?launch-process)", "hooks": [ { "type": "command",