fix: validate Auggie matchers with JavaScript

This commit is contained in:
ZacharyZhang-NY committed 2026-07-19 09:01:59 -04:00
1 parent 453804dff9
commit 86152c8179
5 files changed
+127 -8

No files matched your search

+1
View File
@@ -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. - 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. - 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. - 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 ## Code Quality
+74
View File
@@ -3,6 +3,8 @@
from __future__ import annotations from __future__ import annotations
import os import os
import shutil
import subprocess # nosec B404
from typing import List from typing import List
from ._file_io import ( from ._file_io import (
@@ -17,6 +19,7 @@ from .augment_contract import (
AGENT_KEY, AGENT_KEY,
AUDIT_SCRIPT, AUDIT_SCRIPT,
AugmentDocument, AugmentDocument,
AugmentHooks,
JsonObject, JsonObject,
RuntimeContract, RuntimeContract,
build_handler, build_handler,
@@ -32,6 +35,76 @@ from .base import AgentPlugin, InstallConfig, PluginStatus
from .hook_template import generate_hook_script 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: def _read_config() -> AugmentDocument:
config_path = resolve_config_path() config_path = resolve_config_path()
root = _read_json(config_path, "Auggie settings") root = _read_json(config_path, "Auggie settings")
@@ -102,6 +175,7 @@ class AugmentPlugin(AgentPlugin):
if not agent_id: if not agent_id:
raise ValueError("agent_id is required") raise ValueError("agent_id is required")
source = _read_config() source = _read_config()
_validate_matchers(source.hooks)
guard_path = config.get("guard_script_path", "") guard_path = config.get("guard_script_path", "")
_require_runtime(guard_path, "Elydora guard runtime") _require_runtime(guard_path, "Elydora guard runtime")
-7
View File
@@ -5,7 +5,6 @@ from __future__ import annotations
from dataclasses import dataclass from dataclasses import dataclass
import math import math
import os import os
import re
import sys import sys
from typing import Any, Dict, List, Optional, Set, Tuple 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 "matcher" in group:
if not isinstance(matcher, str): if not isinstance(matcher, str):
raise ValueError(f"{label} matcher must be a string") 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: if "metadata" in group:
_validate_metadata(group["metadata"], label) _validate_metadata(group["metadata"], label)
handlers = group.get("hooks") handlers = group.get("hooks")
+51
View File
@@ -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)
+1 -1
View File
@@ -159,7 +159,7 @@ def test_install_preserves_user_settings_and_is_idempotent(
], ],
"PreToolUse": [ "PreToolUse": [
{ {
"matcher": "launch-process", "matcher": "(?<tool>launch-process)",
"hooks": [ "hooks": [
{ {
"type": "command", "type": "command",