#!/usr/bin/env python3 """ Poka-yoke: Hardcoded path linter for hermes-agent. Scans Python files for hardcoded home-directory paths that break multi-user/multi-profile deployments. Catches: - Path.home() / ".hermes" without HERMES_HOME env var fallback - Hardcoded /Users// paths - Hardcoded /home// paths - Raw ~/.hermes in code (not in comments/docstrings) Usage: python3 scripts/lint_hardcoded_paths.py # lint all .py files python3 scripts/lint_hardcoded_paths.py --fix # suggest fixes python3 scripts/lint_hardcoded_paths.py --staged # lint git staged files only Exit codes: 0 = no violations 1 = violations found 2 = error """ import argparse import os import re import subprocess import sys from pathlib import Path # ── Patterns ────────────────────────────────────────────────────── VIOLATIONS = [ { "id": "direct-home-hermes", "name": "Direct Path.home()/.hermes", "pattern": r'Path\.home\(\)\s*/\s*["\']\.hermes["\']', "exclude_with": r'os\.getenv\(|os\.environ\.get\(|_get_profiles_root|profiles_parent|current_default|native_home', "message": "Use `Path(os.getenv('HERMES_HOME', Path.home() / '.hermes'))` instead of direct `Path.home() / '.hermes'`", }, { "id": "hardcoded-user-path", "name": "Hardcoded /Users//", "pattern": r'["\']/Users/[a-zA-Z_][a-zA-Z0-9_]*/', "exclude_with": r'#|""".*"""\s*$', "message": "Use environment variables or relative paths instead of hardcoded /Users//", }, { "id": "hardcoded-home-path", "name": "Hardcoded /home//", "pattern": r'["\']/home/[a-zA-Z_][a-zA-Z0-9_]*/', "exclude_with": r'#|""".*"""\s*$', "message": "Use environment variables or relative paths instead of hardcoded /home//", }, { "id": "expanduser-hermes", "name": "os.path.expanduser ~/.hermes (non-fallback)", "pattern": r'os\.path\.expanduser\(["\']~/.hermes', "exclude_with": r'#', "message": "Use `os.environ.get('HERMES_HOME', os.path.expanduser('~/.hermes'))` instead", }, ] # ── Exceptions ───────────────────────────────────────────────────── # Files where hardcoded paths are acceptable (tests with mock data, # migration scripts, docs generation) EXCEPTIONS = [ "tests/", # Test fixtures can use mock paths "scripts/", # One-off scripts "optional-skills/", # Skills not in core "skills/", # External skills "plugins/", # Plugins "website/", # Docs site "mcp_serve.py", # Standalone MCP server "docs/", # Documentation ] # ── Scanner ──────────────────────────────────────────────────────── def is_exception(filepath: str) -> bool: """Check if file is in the exception list.""" for exc in EXCEPTIONS: if filepath.startswith(exc) or f"/{exc}" in filepath: return True return False def is_in_comment_or_docstring(line: str, lines: list, line_idx: int) -> bool: """Check if the match is in a comment or docstring.""" stripped = line.strip() # Line comment if stripped.startswith("#"): return True # Inline comment — check if match is after # if "#" in line: code_part = line[:line.index("#")] for v in VIOLATIONS: if re.search(v["pattern"], code_part): return False # Match is in code, not comment return True # No match in code part, must be in comment # Simple docstring check: look for triple quotes before this line in_docstring = False quote_count = 0 for i in range(max(0, line_idx - 20), line_idx + 1): for char in ['"""', "'''"]: quote_count += lines[i].count(char) if quote_count % 2 == 1: in_docstring = True # Also check current line for docstring delimiters if '"""' in line or "'''" in line: # If line is entirely within a docstring block, skip before_match = line[:line.find(re.search(VIOLATIONS[0]["pattern"], line).group())] if re.search(VIOLATIONS[0]["pattern"], line) else "" if '"""' in before_match or "'''" in before_match: in_docstring = True return in_docstring def scan_file(filepath: str) -> list: """Scan a single file for violations.""" try: with open(filepath) as f: content = f.read() lines = content.split("\n") except (OSError, UnicodeDecodeError): return [] violations_found = [] for i, line in enumerate(lines): for v in VIOLATIONS: match = re.search(v["pattern"], line) if not match: continue # Check if excluded by context (e.g., it's part of a fallback pattern) if v.get("exclude_with"): if re.search(v["exclude_with"], line): continue # Skip comments and docstrings stripped = line.strip() if stripped.startswith("#"): continue # Check if in inline comment if "#" in line: code_part = line[:line.index("#")] if not re.search(v["pattern"], code_part): continue violations_found.append({ "file": filepath, "line": i + 1, "rule": v["id"], "name": v["name"], "message": v["message"], "text": stripped[:120], }) return violations_found def get_staged_files() -> list: """Get list of staged Python files from git.""" try: result = subprocess.run( ["git", "diff", "--cached", "--name-only", "--diff-filter=ACM"], capture_output=True, text=True, timeout=10 ) return [f for f in result.stdout.strip().split("\n") if f.endswith(".py")] except (subprocess.TimeoutExpired, FileNotFoundError): return [] def scan_all(root: str = ".") -> list: """Scan all Python files in the repo.""" all_violations = [] for dirpath, dirnames, filenames in os.walk(root): dirnames[:] = [d for d in dirnames if d not in (".git", "venv", "__pycache__", "node_modules")] for f in filenames: if not f.endswith(".py"): continue filepath = os.path.join(dirpath, f) rel = os.path.relpath(filepath, root) if is_exception(rel): continue all_violations.extend(scan_file(filepath)) return all_violations # ── Output ───────────────────────────────────────────────────────── def print_violations(violations: list) -> None: """Print violations in a readable format.""" if not violations: print("PASS: No hardcoded path violations found") return print(f"FAIL: {len(violations)} hardcoded path violation(s) found\n") by_rule = {} for v in violations: by_rule.setdefault(v["rule"], []).append(v) for rule, items in sorted(by_rule.items()): print(f" [{rule}] {items[0]['name']}") print(f" {items[0]['message']}") for item in items: print(f" {item['file']}:{item['line']}: {item['text']}") print() def print_fix_suggestions(violations: list) -> None: """Print fix suggestions for violations.""" if not violations: return print("\n=== Fix Suggestions ===\n") for v in violations: print(f" {v['file']}:{v['line']}") print(f" Current: {v['text']}") if v["rule"] == "direct-home-hermes": print(f" Fix: Use `Path(os.getenv('HERMES_HOME', Path.home() / '.hermes'))`") elif v["rule"] in ("hardcoded-user-path", "hardcoded-home-path"): print(f" Fix: Use `os.environ.get('HOME')` or `Path.home()`") elif v["rule"] == "expanduser-hermes": print(f" Fix: Use `os.environ.get('HERMES_HOME', os.path.expanduser('~/.hermes'))`") print() # ── Main ─────────────────────────────────────────────────────────── def main(): parser = argparse.ArgumentParser(description="Lint hardcoded paths in hermes-agent") parser.add_argument("--staged", action="store_true", help="Only scan git staged files") parser.add_argument("--fix", action="store_true", help="Show fix suggestions") parser.add_argument("--json", action="store_true", help="Output as JSON") parser.add_argument("--root", default=".", help="Root directory to scan") args = parser.parse_args() if args.staged: files = get_staged_files() if not files: print("No staged Python files") sys.exit(0) violations = [] for f in files: if not is_exception(f): violations.extend(scan_file(f)) else: violations = scan_all(args.root) if args.json: import json print(json.dumps(violations, indent=2)) else: print_violations(violations) if args.fix: print_fix_suggestions(violations) sys.exit(1 if violations else 0) if __name__ == "__main__": main()