#!/usr/bin/env python3 """ Pre-commit hook to validate hardcoded settings key strings against the allow/block lists defined in settings_routes.py. Catches new settings keys that would be rejected by the runtime namespace gate before they reach the release server. """ import ast import re import sys from pathlib import Path SETTINGS_ROUTES = ( Path(__file__).resolve().parent.parent / "src" / "local_deep_research" / "web" / "routes" / "settings_routes.py" ) WRITE_FUNC_NAMES = {"set_setting"} # Regex patterns for JS files JS_SAVE_SETTING_RE = re.compile(r"saveSetting\s*\(\s*['\"]([^'\"]+)['\"]") JS_SETTINGS_API_URL_RE = re.compile( r"['\"]\/settings\/api\/([a-z][a-z0-9_]*\.[a-z0-9_.]+)['\"]" ) JS_INLINE_COMMENT_RE = re.compile(r"/\*.*?\*/") # Excluded paths/directories SKIP_PATH_SEGMENTS = { "tests", "test", ".pre-commit-hooks", "migrations", "node_modules", "dist", "build", "vendor", "defaults", # JSON schema files define keys — not write call sites } SKIP_NAME_PREFIXES = ("test_", "conftest") SKIP_NAME_SUFFIXES = ("_test.py", "_test.js", ".min.js") SKIP_EXACT_NAMES = {"settings_routes.py"} def load_prefixes(): """Parse ALLOWED_SETTING_PREFIXES and BLOCKED_SETTING_PREFIXES from settings_routes.py using AST.""" try: source = SETTINGS_ROUTES.read_text(encoding="utf-8") except FileNotFoundError: print( f"FATAL: Cannot find settings_routes.py at {SETTINGS_ROUTES}\n" "The hook cannot validate settings keys without it." ) sys.exit(1) tree = ast.parse(source, filename=str(SETTINGS_ROUTES)) allowed = None blocked = None for node in ast.walk(tree): if not isinstance(node, ast.Assign): continue for target in node.targets: if not isinstance(target, ast.Name): continue if target.id == "ALLOWED_SETTING_PREFIXES": allowed = _extract_frozenset_strings(node.value) elif target.id == "BLOCKED_SETTING_PREFIXES": blocked = _extract_frozenset_strings(node.value) if allowed is None or blocked is None: print( "FATAL: Could not parse ALLOWED/BLOCKED_SETTING_PREFIXES " f"from {SETTINGS_ROUTES}" ) sys.exit(1) return allowed, blocked def _extract_frozenset_strings(node): """Extract string values from frozenset({...}) or frozenset((...,)).""" if not isinstance(node, ast.Call): return None if not (isinstance(node.func, ast.Name) and node.func.id == "frozenset"): return None if not node.args: return None collection = node.args[0] strings = set() if isinstance(collection, (ast.Set, ast.Tuple, ast.List)): for elt in collection.elts: if isinstance(elt, ast.Constant) and isinstance(elt.value, str): strings.add(elt.value) return strings if strings else None def is_key_allowed(key, allowed, blocked): """Mirrors _is_allowed_new_setting_key from settings_routes.py.""" if not isinstance(key, str) or not key or ".." in key: return False key_lower = key.lower() for prefix in blocked: if key_lower.startswith(prefix): return False for prefix in allowed: if key_lower.startswith(prefix): return True return False def should_skip(filepath): """Check if a file should be skipped.""" p = Path(filepath) name = p.name if name in SKIP_EXACT_NAMES: return True if name.startswith(SKIP_NAME_PREFIXES): return True if name.endswith(SKIP_NAME_SUFFIXES): return True if SKIP_PATH_SEGMENTS.intersection(p.parts): return True return False class SettingsKeyChecker(ast.NodeVisitor): """AST visitor for Python files — detects set_setting("key", ...) calls.""" def __init__(self, filename, allowed, blocked): self.filename = filename self.allowed = allowed self.blocked = blocked self.errors = [] def visit_Call(self, node): key = self._extract_write_key(node) if key is not None and not is_key_allowed( key, self.allowed, self.blocked ): self.errors.append((node.lineno, key)) self.generic_visit(node) def _extract_write_key(self, node): """Extract a hardcoded settings key from a set_setting call. Returns the key string if found, or None if the call is not applicable or uses a dynamic key. """ # Pattern 1: set_setting("key", ...) if isinstance(node.func, ast.Name) and node.func.id in WRITE_FUNC_NAMES: return self._first_string_arg(node) # Pattern 2: obj.set_setting("key", ...) if ( isinstance(node.func, ast.Attribute) and node.func.attr in WRITE_FUNC_NAMES ): return self._first_string_arg(node) return None @staticmethod def _first_string_arg(node): """Return the first positional arg if it's a settings-key-like string. A settings key contains at least one dot (e.g. "llm.provider") or underscore (e.g. "local_search_embedding_model"). Bare words like "value" or "config" are not settings keys and are skipped to avoid false positives on non-settings call sites. """ if node.args and isinstance(node.args[0], ast.Constant): val = node.args[0].value if ( isinstance(val, str) and len(val) > 2 and ("." in val or "_" in val) ): return val return None def check_python_file(filepath, allowed, blocked): """Check a Python file for settings key namespace violations.""" try: with open(filepath, "r", encoding="utf-8") as f: content = f.read() except Exception: return [] try: tree = ast.parse(content, filename=filepath) except SyntaxError: return [] checker = SettingsKeyChecker(filepath, allowed, blocked) checker.visit(tree) return checker.errors def check_js_file(filepath, allowed, blocked): """Check a JS file for settings key namespace violations.""" errors = [] try: with open(filepath, "r", encoding="utf-8") as f: lines = f.readlines() except Exception: return [] for i, line in enumerate(lines, 1): stripped = line.strip() if stripped.startswith("//") or stripped.startswith("*"): continue # Strip inline /* ... */ comments to avoid false positives line = JS_INLINE_COMMENT_RE.sub("", line) # saveSetting('key', ...) for m in JS_SAVE_SETTING_RE.finditer(line): key = m.group(1) if not is_key_allowed(key, allowed, blocked): errors.append((i, key)) # '/settings/api/key' literal URLs for m in JS_SETTINGS_API_URL_RE.finditer(line): key = m.group(1) if not is_key_allowed(key, allowed, blocked): errors.append((i, key)) return errors def main(): if len(sys.argv) < 2: print("Usage: check-settings-key-namespace.py ...") sys.exit(1) allowed, blocked = load_prefixes() all_errors = [] for filepath in sys.argv[1:]: if should_skip(filepath): continue if filepath.endswith(".py"): errors = check_python_file(filepath, allowed, blocked) elif filepath.endswith((".js", ".mjs")): errors = check_js_file(filepath, allowed, blocked) else: continue if errors: all_errors.append((filepath, errors)) if all_errors: allowed_sorted = sorted(allowed) print("\nSettings key namespace violations found:\n") for filepath, errors in all_errors: print(f"{filepath}:") for line_num, key in errors: print( f" Line {line_num}: '{key}' does not match any allowed prefix" ) print( f"\nAllowed prefixes: {', '.join(allowed_sorted)}\n" "Fix: Add the prefix to ALLOWED_SETTING_PREFIXES in " "settings_routes.py, or rename the key." ) sys.exit(1) else: sys.exit(0) if __name__ == "__main__": main()