#!/usr/bin/env python3 """ Pre-commit hook to prevent absolute module paths in the codebase. Module path configuration values MUST use relative imports (starting with ".") instead of absolute paths (starting with "local_deep_research."). The module whitelist security boundary requires relative paths for search engines, and absolute paths elsewhere are a packaging anti-pattern. See: src/local_deep_research/security/module_whitelist.py """ import ast import json import sys from pathlib import Path # The absolute base that must NOT appear as a module path prefix ABSOLUTE_BASE = "local_deep_research" # Known legitimate uses of absolute package references that are NOT module_path # config values. These are used for importlib.resources, package= kwargs, and # normalization constants where the full package name is required by Python APIs. LEGITIMATE_ABSOLUTE_REFS = frozenset( { # importlib.resources.files() requires the full package name "local_deep_research.defaults.settings", # importlib.import_module() to avoid circular imports "local_deep_research.utilities", # package= kwarg for relative import resolution "local_deep_research.llm.providers", # package= kwarg for relative import resolution in module_whitelist.py "local_deep_research.web_search_engines", } ) # Directory patterns where absolute paths are allowed (matched as path components) ALLOWED_DIR_PATTERNS = { "tests", # Tests may verify rejection of absolute paths ".pre-commit-hooks", # Hook files themselves } # Specific file suffixes where absolute paths are allowed ALLOWED_FILE_SUFFIXES = { # Use os.sep-independent matching via PurePosixPath "security/module_whitelist.py", # Defines the security boundary itself } # Basename patterns for test files ALLOWED_BASENAME_PREFIXES = {"test_"} # test_*.py ALLOWED_BASENAME_SUFFIXES = {"_test.py"} # *_test.py class AbsoluteModulePathChecker(ast.NodeVisitor): """AST visitor to detect absolute module paths in string literals.""" def __init__(self, filename: str): self.filename = filename self.errors = [] def visit_Constant(self, node): """Check string constants for absolute module paths.""" if ( isinstance(node.value, str) and node.value.startswith(ABSOLUTE_BASE + ".") and node.value not in LEGITIMATE_ABSOLUTE_REFS ): self.errors.append( ( node.lineno, f'Absolute module path "{node.value}" found. ' f"Use a relative path (starting with '.') instead.", ) ) self.generic_visit(node) def _is_file_allowed(filename: str) -> bool: """Check if this file is allowed to contain absolute module paths.""" p = Path(filename) normalized = p.as_posix() parts = p.parts # Check directory patterns as path components for pattern in ALLOWED_DIR_PATTERNS: if pattern in parts: return True # Check specific file suffixes for suffix in ALLOWED_FILE_SUFFIXES: if normalized.endswith(suffix): return True # Check basename patterns basename = p.name for prefix in ALLOWED_BASENAME_PREFIXES: if basename.startswith(prefix): return True for suffix in ALLOWED_BASENAME_SUFFIXES: if basename.endswith(suffix): return True return False def check_python_file(filename: str) -> bool: """Check a Python file for absolute module paths in string literals.""" if _is_file_allowed(filename): return True try: with open(filename, "r", encoding="utf-8") as f: content = f.read() except Exception as e: print(f"Error reading {filename}: {e}") return False try: tree = ast.parse(content, filename=filename) checker = AbsoluteModulePathChecker(filename) checker.visit(tree) if checker.errors: print(f"\n{filename}:") for line_num, error in checker.errors: print(f" Line {line_num}: {error}") return False except SyntaxError: # Skip files with syntax errors (let other tools handle that) pass except Exception as e: print(f"Error parsing {filename}: {e}") return False return True def check_json_file(filename: str) -> bool: """Check a JSON config file for absolute module paths in module_path keys.""" if _is_file_allowed(filename): return True try: with open(filename, "r", encoding="utf-8") as f: data = json.load(f) except Exception as e: print(f"Error reading {filename}: {e}") return False errors = [] def _check_value(obj, path=""): if isinstance(obj, dict): for key, value in obj.items(): current_path = f"{path}.{key}" if path else key # Only check values under module_path keys if key in ("module_path", "full_search_module") and isinstance( value, str ): if ( value.startswith(ABSOLUTE_BASE + ".") and value not in LEGITIMATE_ABSOLUTE_REFS ): errors.append((current_path, value)) elif isinstance(value, (dict, list)): _check_value(value, current_path) elif isinstance(obj, list): for i, item in enumerate(obj): _check_value(item, f"{path}[{i}]") _check_value(data) if errors: print(f"\n{filename}:") for key_path, value in errors: print( f' Key "{key_path}": Absolute module path "{value}" found. ' f"Use a relative path (starting with '.') instead." ) return False return True def main(): """Check all provided files for absolute module paths.""" if len(sys.argv) < 2: print("Usage: check-absolute-module-paths.py ...") sys.exit(1) files_to_check = sys.argv[1:] has_errors = False for filename in files_to_check: if filename.endswith(".py"): if not check_python_file(filename): has_errors = True elif filename.endswith(".json"): if not check_json_file(filename): has_errors = True if has_errors: print("\n" + "=" * 70) print("Module Path Security: Absolute module paths detected!") print("=" * 70) print("\nModule paths must use relative imports (starting with '.')") print("instead of absolute paths starting with 'local_deep_research.'.") print("\nExamples:") print( ' BAD: "local_deep_research.web_search_engines' '.engines.search_engine_local"' ) print(' GOOD: ".engines.search_engine_local"') print("\nSee: src/local_deep_research/security/module_whitelist.py") sys.exit(1) else: sys.exit(0) if __name__ == "__main__": main()