#!/usr/bin/env python3 """ [ARCH] Architecture Linter v2 Part of the Gemini Sovereign Governance System. Enforces architectural boundaries, security, and documentation standards across the Timmy Foundation fleet. Refs: #437 — repo-aware, test-backed, CI-enforced. """ import argparse import os import re import sys from pathlib import Path # --- CONFIGURATION --- SOVEREIGN_KEYWORDS = ["mempalace", "sovereign_store", "tirith", "bezalel", "nexus"] # IP addresses (skip 127.0.0.1, 0.0.0.0, 10.x.x.x, 172.16-31.x.x, 192.168.x.x) IP_REGEX = r'\b(?!(?:127|10|192\.168|172\.(?:1[6-9]|2\d|3[01]))\.)' \ r'(?:\d{1,3}\.){3}\d{1,3}\b' # API key / secret patterns — catches openai-, sk-, anthropic-, AKIA, etc. API_KEY_PATTERNS = [ r'sk-[A-Za-z0-9]{20,}', # OpenAI-style r'sk-ant-[A-Za-z0-9\-]{20,}', # Anthropic r'AKIA[A-Z0-9]{16}', # AWS access key r'ghp_[A-Za-z0-9]{36}', # GitHub PAT r'glpat-[A-Za-z0-9\-]{20,}', # GitLab PAT r'(?:api[_-]?key|secret|token)\s*[:=]\s*["\'][A-Za-z0-9_\-]{16,}["\']', ] # Sovereignty rules (carried from v1) SOVEREIGN_RULES = [ (r'https?://api\.openai\.com', 'External cloud API: api.openai.com. Use local custom_provider.'), (r'https?://api\.anthropic\.com', 'External cloud API: api.anthropic.com. Use local custom_provider.'), (r'provider:\s*(?:openai|anthropic)\b', 'Direct cloud provider. Ensure fallback_model is configured.'), ] # File extensions to scan SCAN_EXTENSIONS = {'.py', '.ts', '.tsx', '.js', '.yaml', '.yml', '.json', '.env', '.sh', '.cfg', '.toml'} SKIP_DIRS = {'.git', 'node_modules', '__pycache__', '.venv', 'venv', '.tox', '.eggs'} class LinterResult: """Structured result container for programmatic access.""" def __init__(self, repo_path: str, repo_name: str): self.repo_path = repo_path self.repo_name = repo_name self.errors: list[str] = [] self.warnings: list[str] = [] @property def passed(self) -> bool: return len(self.errors) == 0 @property def violation_count(self) -> int: return len(self.errors) def summary(self) -> str: lines = [f"--- Architecture Linter v2: {self.repo_name} ---"] for w in self.warnings: lines.append(f" [W] {w}") for e in self.errors: lines.append(f" [E] {e}") status = "PASSED" if self.passed else f"FAILED ({self.violation_count} violations)" lines.append(f"\nResult: {status}") return '\n'.join(lines) class Linter: def __init__(self, repo_path: str): self.repo_path = Path(repo_path).resolve() if not self.repo_path.is_dir(): raise FileNotFoundError(f"Repository path does not exist: {self.repo_path}") self.repo_name = self.repo_path.name self.result = LinterResult(str(self.repo_path), self.repo_name) # --- helpers --- def _scan_files(self, extensions=None): """Yield (Path, content) for files matching *extensions*.""" exts = extensions or SCAN_EXTENSIONS for root, dirs, files in os.walk(self.repo_path): dirs[:] = [d for d in dirs if d not in SKIP_DIRS] for fname in files: if Path(fname).suffix in exts: if fname == '.env.example': continue fpath = Path(root) / fname try: content = fpath.read_text(errors='ignore') except Exception: continue yield fpath, content def _line_no(self, content: str, offset: int) -> int: return content.count('\n', 0, offset) + 1 # --- checks --- def check_sidecar_boundary(self): """No sovereign code in hermes-agent (sidecar boundary).""" if self.repo_name != 'hermes-agent': return for fpath, content in self._scan_files(): for kw in SOVEREIGN_KEYWORDS: if kw in content.lower(): rel = str(fpath.relative_to(self.repo_path)) self.result.errors.append( f"Sovereign keyword '{kw}' in hermes-agent violates sidecar boundary. [{rel}]" ) def check_hardcoded_ips(self): """No hardcoded public IPs — use DNS or env vars.""" for fpath, content in self._scan_files(): for m in re.finditer(IP_REGEX, content): ip = m.group() # skip private ranges already handled by lookahead, and 0.0.0.0 if ip.startswith('0.'): continue line = self._line_no(content, m.start()) rel = str(fpath.relative_to(self.repo_path)) self.result.errors.append( f"Hardcoded IP '{ip}'. Use DNS or env vars. [{rel}:{line}]" ) def check_api_keys(self): """No cloud API keys / secrets committed.""" for fpath, content in self._scan_files(): for pattern in API_KEY_PATTERNS: for m in re.finditer(pattern, content, re.IGNORECASE): line = self._line_no(content, m.start()) rel = str(fpath.relative_to(self.repo_path)) self.result.errors.append( f"Potential secret / API key detected. [{rel}:{line}]" ) def check_sovereignty_rules(self): """V1 sovereignty rules: no direct cloud API endpoints or providers.""" for fpath, content in self._scan_files({'.py', '.ts', '.tsx', '.js', '.yaml', '.yml'}): for pattern, msg in SOVEREIGN_RULES: for m in re.finditer(pattern, content): line = self._line_no(content, m.start()) rel = str(fpath.relative_to(self.repo_path)) self.result.errors.append(f"{msg} [{rel}:{line}]") def check_soul_canonical(self): """SOUL.md must exist exactly in timmy-config root.""" soul_path = self.repo_path / 'SOUL.md' if self.repo_name == 'timmy-config': if not soul_path.exists(): self.result.errors.append( 'SOUL.md missing from canonical location (timmy-config root).' ) else: if soul_path.exists(): self.result.errors.append( 'SOUL.md found in non-canonical repo. Must live only in timmy-config.' ) def check_readme(self): """Every repo must have a substantive README.""" readme = self.repo_path / 'README.md' if not readme.exists(): self.result.errors.append('README.md is missing.') else: content = readme.read_text(errors='ignore') if len(content.strip()) < 50: self.result.warnings.append( 'README.md is very short (<50 chars). Provide current truth about the repo.' ) # --- runner --- def run(self) -> LinterResult: """Execute all checks and return the result.""" self.check_sidecar_boundary() self.check_hardcoded_ips() self.check_api_keys() self.check_sovereignty_rules() self.check_soul_canonical() self.check_readme() return self.result def main(): parser = argparse.ArgumentParser( description='Gemini Architecture Linter v2 — repo-aware sovereignty gate.' ) parser.add_argument( 'repo_path', nargs='?', default='.', help='Path to the repository to lint (default: cwd).', ) parser.add_argument( '--repo', dest='repo_flag', default=None, help='Explicit repo path (alias for positional arg).', ) parser.add_argument( '--json', dest='json_output', action='store_true', help='Emit machine-readable JSON instead of human text.', ) args = parser.parse_args() path = args.repo_flag if args.repo_flag else args.repo_path try: linter = Linter(path) except FileNotFoundError as exc: print(f"ERROR: {exc}", file=sys.stderr) sys.exit(2) result = linter.run() if args.json_output: import json as _json out = { 'repo': result.repo_name, 'passed': result.passed, 'violation_count': result.violation_count, 'errors': result.errors, 'warnings': result.warnings, } print(_json.dumps(out, indent=2)) else: print(result.summary()) sys.exit(0 if result.passed else 1) if __name__ == '__main__': main()