diff --git a/.claude/SKILL-WHITELIST.json b/.claude/SKILL-WHITELIST.json new file mode 100644 index 0000000..f85c814 --- /dev/null +++ b/.claude/SKILL-WHITELIST.json @@ -0,0 +1,50 @@ +{ + "version": "1.0.0", + "description": "M-09: Trusted skills and sub-agents allowed for auto-injection. Items not in this list will be blocked with a security warning.", + "updated": "2026-03-01", + "trusted_skills": [ + "agent-creation", + "ask-company", + "brainstorming", + "chronicler", + "code-review", + "convert-to-company-docs", + "dispatching-parallel-agents", + "docs-megabrain", + "executing-plans", + "executor", + "fase-2-5-tagging", + "feature-dev", + "frontend-design", + "gdrive-transcription-downloader", + "gemini-fallback", + "gha", + "github-workflow", + "hookify", + "hybrid-source-reading", + "jarvis", + "jarvis-briefing", + "knowledge-extraction", + "ler-planilha", + "pipeline-jarvis", + "plugin-dev", + "process-company-inbox", + "pr-review-toolkit", + "python-megabrain", + "resume", + "save", + "skill-creator-internal", + "skill-writer", + "smart-download-tagger", + "source-sync", + "sync-docs", + "using-superpools", + "using-superpowers", + "verification-before-completion", + "verify", + "verify-6-levels", + "writing-plans" + ], + "trusted_subagents": [], + "blocked": [] +} diff --git a/.claude/hooks/session_start.py b/.claude/hooks/session_start.py index 412f14e..c833123 100755 --- a/.claude/hooks/session_start.py +++ b/.claude/hooks/session_start.py @@ -26,6 +26,7 @@ import sys import os import re +import hashlib from datetime import datetime, timedelta from pathlib import Path from typing import Optional, Dict, List, Any @@ -115,6 +116,128 @@ } } +#================================ +# M-08: PERSONALITY FILE INTEGRITY +#================================ + +# Files injected into context that must be verified +PERSONALITY_FILES = [ + '.claude/jarvis/JARVIS-DNA-PERSONALITY.md', + 'system/02-JARVIS-SOUL.md', + '.claude/jarvis/JARVIS-BOOT-SEQUENCE.md', + '.claude/jarvis/JARVIS-MEMORY.md', +] + +INTEGRITY_MANIFEST_PATH = '.claude/jarvis/INTEGRITY-MANIFEST.json' + + +def compute_file_hash(filepath: Path) -> Optional[str]: + """Compute SHA256 hash of a file for integrity verification.""" + try: + sha256 = hashlib.sha256() + with open(filepath, 'rb') as f: + for chunk in iter(lambda: f.read(8192), b''): + sha256.update(chunk) + return sha256.hexdigest() + except Exception: + return None + + +def load_integrity_manifest() -> Dict: + """Load integrity manifest from disk.""" + project_dir = get_project_dir() + manifest_path = Path(project_dir) / INTEGRITY_MANIFEST_PATH + if not manifest_path.exists(): + return {} + try: + with open(manifest_path, 'r', encoding='utf-8') as f: + return json.load(f) + except Exception: + return {} + + +def save_integrity_manifest(manifest: Dict): + """Save integrity manifest to disk.""" + project_dir = get_project_dir() + manifest_path = Path(project_dir) / INTEGRITY_MANIFEST_PATH + manifest_path.parent.mkdir(parents=True, exist_ok=True) + try: + with open(manifest_path, 'w', encoding='utf-8') as f: + json.dump(manifest, f, indent=2, ensure_ascii=False) + except Exception: + pass + + +def verify_personality_integrity() -> Dict: + """ + M-08: Verify integrity of personality files against manifest. + Warn-only: never blocks session start. + + On first run: creates manifest with current hashes. + On subsequent runs: compares hashes, warns if changed. + Does NOT auto-update manifest when changes detected (requires manual reset). + """ + project_dir = get_project_dir() + manifest = load_integrity_manifest() + files_section = manifest.get('files', {}) + + result = { + 'verified': [], + 'changed': [], + 'new_files': [], + 'missing': [], + 'manifest_exists': bool(files_section), + } + + current_hashes = {} + + for rel_path in PERSONALITY_FILES: + filepath = Path(project_dir) / rel_path + if not filepath.exists(): + result['missing'].append(rel_path) + continue + + current_hash = compute_file_hash(filepath) + if current_hash is None: + continue + + current_hashes[rel_path] = current_hash + + if rel_path in files_section: + expected_hash = files_section[rel_path].get('sha256') + if expected_hash and expected_hash != current_hash: + result['changed'].append(rel_path) + else: + result['verified'].append(rel_path) + else: + result['new_files'].append(rel_path) + + now = datetime.now().isoformat() + + if not files_section: + # First run: create manifest with current hashes (baseline) + new_manifest = { + 'version': '1.0.0', + 'description': 'Integrity manifest for personality files (M-08)', + 'generated': now, + 'last_verified': now, + 'files': {} + } + for rel_path, file_hash in current_hashes.items(): + new_manifest['files'][rel_path] = { + 'sha256': file_hash, + 'verified_at': now + } + save_integrity_manifest(new_manifest) + elif not result['changed']: + # No changes: update last_verified timestamp only + manifest['last_verified'] = now + save_integrity_manifest(manifest) + # If changes detected: DON'T update manifest - preserve old hashes for comparison + + return result + + #================================ # UTILITÁRIOS #================================ @@ -836,6 +959,9 @@ def main(): # === VERIFICAR INTEGRIDADE === integrity = check_system_integrity() + # === M-08: VERIFY PERSONALITY FILE INTEGRITY === + personality_integrity = verify_personality_integrity() + # === CARREGAR TODOS OS ARQUIVOS === state = load_state() memory = load_memory_owner() @@ -879,6 +1005,19 @@ def main(): if warnings: output_parts.append(warnings) + # M-08: Personality integrity warnings + if personality_integrity.get('changed'): + output_parts.append("┌──────────────────────────────────────────────────────────────────────────────┐") + output_parts.append("│ ⚠️ PERSONALITY INTEGRITY WARNING │") + output_parts.append("├──────────────────────────────────────────────────────────────────────────────┤") + for changed_file in personality_integrity['changed']: + fname = changed_file.split('/')[-1][:50] + output_parts.append(f"│ Modified since last verified: {fname:<40}│") + output_parts.append("│ Run /verify-integrity to accept changes or investigate. │") + output_parts.append("└──────────────────────────────────────────────────────────────────────────────┘") + elif not personality_integrity.get('manifest_exists'): + output_parts.append("[INTEGRITY] First run: personality file manifest created.") + # Sistemas carregados loaded = integrity.get('loaded', []) output_parts.append(f"\n[SISTEMAS] {len(loaded)}/8 arquivos carregados: {', '.join(loaded[:5])}...") diff --git a/.claude/hooks/skill_router.py b/.claude/hooks/skill_router.py index d04aa7d..f1131a7 100644 --- a/.claude/hooks/skill_router.py +++ b/.claude/hooks/skill_router.py @@ -23,6 +23,7 @@ import os import re import json +from datetime import datetime from pathlib import Path from typing import Dict, List, Optional, Tuple @@ -30,6 +31,13 @@ SKILLS_PATH = PROJECT_ROOT / ".claude" / "skills" SUBAGENTS_PATH = PROJECT_ROOT / ".claude" / "jarvis" / "sub-agents" INDEX_PATH = PROJECT_ROOT / ".claude" / "mission-control" / "SKILL-INDEX.json" +WHITELIST_PATH = PROJECT_ROOT / ".claude" / "SKILL-WHITELIST.json" + +# M-09: Allowed base directories for skill/sub-agent resolution +ALLOWED_SKILL_PREFIXES = ( + os.path.normpath('.claude/skills/'), + os.path.normpath('.claude/jarvis/sub-agents/'), +) def scan_skills() -> List[Tuple[Path, str]]: @@ -277,6 +285,73 @@ def get_item_context(item_path: str, item_type: str) -> str: return get_subagent_context(item_path) +#================================ +# M-09: SECURITY - WHITELIST & PATH VALIDATION +#================================ + +def is_path_safe(item_path: str) -> bool: + """ + M-09: Validate that skill/sub-agent path doesn't escape expected directories. + Prevents path traversal attacks via crafted SKILL.md paths. + """ + normalized = os.path.normpath(item_path) + return any(normalized.startswith(prefix) for prefix in ALLOWED_SKILL_PREFIXES) + + +def load_whitelist() -> Dict: + """Load skill/sub-agent whitelist. Empty dict = no whitelist (trust all).""" + if not WHITELIST_PATH.exists(): + return {} + try: + with open(WHITELIST_PATH, 'r', encoding='utf-8') as f: + return json.load(f) + except Exception: + return {} + + +def is_skill_trusted(item_name: str, item_type: str, whitelist: Dict) -> bool: + """ + M-09: Check if skill/sub-agent is in the trusted whitelist. + If no whitelist file exists, all items in valid paths are trusted (graceful degradation). + """ + if not whitelist: + return True # No whitelist file = trust all (backward compatible) + + blocked_list = whitelist.get('blocked', []) + if item_name in blocked_list: + return False + + if item_type == "skill": + trusted_list = whitelist.get('trusted_skills', []) + else: + trusted_list = whitelist.get('trusted_subagents', []) + + if '*' in trusted_list: + return True # Wildcard = trust all of this type + + return item_name in trusted_list + + +def log_security_event(event_type: str, item_name: str, item_path: str): + """M-09: Log security events for skill routing.""" + log_dir = PROJECT_ROOT / 'logs' + log_dir.mkdir(parents=True, exist_ok=True) + log_file = log_dir / 'skill-security.jsonl' + + entry = { + 'timestamp': datetime.now().isoformat(), + 'event': event_type, + 'item_name': item_name, + 'item_path': item_path + } + + try: + with open(log_file, 'a', encoding='utf-8') as f: + f.write(json.dumps(entry, ensure_ascii=False) + '\n') + except Exception: + pass + + def main(): """ Hook entry point for Claude Code UserPromptSubmit event. @@ -303,6 +378,23 @@ def main(): item_type = top.get('type', 'skill') item_name = top.get('name', 'unknown') + # M-09: Path safety check — block path traversal + if not is_path_safe(top['path']): + log_security_event('path_traversal_blocked', item_name, top['path']) + print(json.dumps({'continue': True})) + return + + # M-09: Whitelist check — block unwhitelisted items + whitelist = load_whitelist() + if not is_skill_trusted(item_name, item_type, whitelist): + log_security_event('unwhitelisted_blocked', item_name, top['path']) + type_label = "skill" if item_type == "skill" else "sub-agent" + print(json.dumps({ + 'continue': True, + 'feedback': f"[SECURITY] {type_label} '{item_name}' not in trusted whitelist. Skipping auto-injection." + })) + return + context = get_item_context(top['path'], item_type) if context: