Sunee / memory_firewall.py
sagarmythos
Add all Mythos-Killer v8.0 files
828589c
Raw
History Blame Contribute Delete
17.9 kB
# memory_firewall.py (Mythos-Killer v8.0 -- Cognitive Maintenance Engine)
# This file protects AI memory files (MEMORY.md, checkpoint.json, context files).
# If any attacker attempts to poison a monitored file, this module detects it,
# raises an alarm via Telegram and EventBus, and immediately restores the
# original file from a verified backup.
#
# Direct mitigation for the "MEMORY.md poisoning" vulnerability (discovered by Cisco).
import asyncio
import os
import json
import hashlib
import shutil
import threading
from pathlib import Path
from typing import Dict, Any, Optional
from datetime import datetime
try:
from event_bus import get_event_bus, Events
EVENT_BUS_AVAILABLE = True
except ImportError:
EVENT_BUS_AVAILABLE = False
Events = None
try:
from telegram_bot import TelegramBot
TELEGRAM_AVAILABLE = True
except ImportError:
TELEGRAM_AVAILABLE = False
TelegramBot = None
class MemoryFirewall:
"""
v8.0 Memory Firewall -- integrity guardian for all AI memory and context files.
Uses SHA-256 hashing to ensure no file is modified without authorization.
On detecting a violation:
1. Blocks the AI from reading the compromised file.
2. Restores the original file from backup.
3. Sends an alarm via Telegram and EventBus.
"""
LEDGER_FILE = "memory_ledger.json"
BACKUP_DIR = "memory_backups"
MONITORED_EXTENSIONS = {".md", ".json", ".txt", ".yaml", ".yml"}
MONITORED_FILENAMES = {
"MEMORY.md",
"checkpoint.json",
"architecture.txt",
"claude.md",
}
DEFAULT_EXCLUDE_DIRS = {
"__pycache__",
".git",
"node_modules",
"venv",
".venv",
"artifacts",
"memory_backups",
}
def __init__(
self,
workspace_path: str = "/workspace",
event_bus=None,
telegram_bot=None,
auto_scan: bool = True,
):
"""
Initializes the MemoryFirewall.
Args:
workspace_path: Root path of the workspace containing memory files.
event_bus: EventBus instance for alarm events.
telegram_bot: TelegramBot instance for Telegram alarms.
auto_scan: Whether to perform a full scan on startup.
"""
self.workspace = Path(workspace_path)
self.backup_dir = self.workspace / self.BACKUP_DIR
self.ledger_path = self.workspace / self.LEDGER_FILE
self.event_bus = event_bus
self.telegram_bot = telegram_bot
if self.event_bus is None and EVENT_BUS_AVAILABLE:
try:
self.event_bus = get_event_bus()
except Exception:
pass
os.makedirs(self.backup_dir, exist_ok=True)
self.ledger: Dict[str, str] = {}
# FIX (Lock Order): _ledger_lock is declared BEFORE _load_ledger() is called
# so that any future use of the lock inside _load_ledger is safe.
self._ledger_lock = threading.RLock()
self._load_ledger()
self.stats = {
"total_integrity_checks": 0,
"total_violations": 0,
"total_restores": 0,
"last_violation_at": None,
"monitored_files": 0,
}
if auto_scan:
self.initial_scan()
# --- Timeline helper ---
def _add_timeline(self, state: Dict, agent: str, status: str, message: str):
if "timeline" not in state:
state["timeline"] = []
state["timeline"].append({
"timestamp": datetime.now().strftime("%H:%M:%S"),
"agent": agent,
"status": status,
"message": message,
})
# --- Ledger management ---
def _load_ledger(self):
"""Loads the hash ledger from disk."""
try:
if self.ledger_path.exists():
with open(self.ledger_path, 'r', encoding='utf-8') as f:
self.ledger = json.load(f)
print(f"[MemoryFirewall] Ledger loaded: {len(self.ledger)} files tracked")
else:
print("[MemoryFirewall] No existing ledger found -- starting fresh")
except Exception as e:
print(f"[MemoryFirewall] Failed to load ledger: {e}")
self.ledger = {}
def _save_ledger(self):
"""Saves the hash ledger to disk."""
try:
with open(self.ledger_path, 'w', encoding='utf-8') as f:
json.dump(self.ledger, f, indent=2, ensure_ascii=False)
except Exception as e:
print(f"[MemoryFirewall] Failed to save ledger: {e}")
# --- Hash computation ---
def _compute_hash(self, file_path: Path) -> Optional[str]:
"""
Computes the SHA-256 hash of a file.
Returns the hex digest string, or None if the file does not exist.
"""
if not file_path.exists():
return None
try:
sha256 = hashlib.sha256()
with open(file_path, 'rb') as f:
for chunk in iter(lambda: f.read(4096), b""):
sha256.update(chunk)
return sha256.hexdigest()
except Exception as e:
print(f"[MemoryFirewall] Hash compute failed for {file_path}: {e}")
return None
def _get_relative_path(self, file_path: Path) -> str:
"""Returns the path relative to the workspace root."""
try:
return str(file_path.relative_to(self.workspace))
except ValueError:
return str(file_path)
# --- File scanning and registration ---
def _should_monitor(self, file_path: Path) -> bool:
"""Returns True if this file should be monitored."""
for parent in file_path.parents:
if parent.name in self.DEFAULT_EXCLUDE_DIRS:
return False
if file_path.name in self.MONITORED_FILENAMES:
return True
if file_path.suffix in self.MONITORED_EXTENSIONS:
return True
return False
def initial_scan(self) -> int:
"""
Scans all monitorable files in the workspace and registers them
in the hash ledger.
Returns: Number of files registered.
"""
print("[MemoryFirewall] Starting initial scan...")
count = 0
for root, dirs, files in os.walk(self.workspace):
dirs[:] = [d for d in dirs if d not in self.DEFAULT_EXCLUDE_DIRS]
for filename in files:
file_path = Path(root) / filename
if self._should_monitor(file_path):
self.register_file(file_path)
count += 1
self.stats["monitored_files"] = len(self.ledger)
self._save_ledger()
print(f"[MemoryFirewall] Initial scan complete: {count} files registered")
return count
def register_file(self, file_path: Path) -> Optional[str]:
"""
Registers a new file in the ledger and creates its backup.
Returns the file's current hash, or None on failure.
"""
if not file_path.exists():
return None
rel_path = self._get_relative_path(file_path)
current_hash = self._compute_hash(file_path)
if current_hash is None:
return None
with self._ledger_lock:
if rel_path not in self.ledger or self.ledger[rel_path] != current_hash:
self._create_backup(file_path)
self.ledger[rel_path] = current_hash
self._save_ledger()
return current_hash
# --- Backup and restore ---
def _create_backup(self, file_path: Path) -> Optional[Path]:
"""
Creates a backup of a file.
FIX (Backup Naming Collision): The backup filename is built using an
MD5 hash of the relative path instead of a simple slash-to-underscore
replacement. This guarantees unique backup names even when two different
paths would previously collapse to the same string after substitution.
Returns: Path to the backup file, or None on failure.
"""
if not file_path.exists():
return None
rel_path = self._get_relative_path(file_path)
path_hash = hashlib.md5(rel_path.encode()).hexdigest()
backup_name = f"{path_hash}_{file_path.name}.backup"
backup_path = self.backup_dir / backup_name
try:
os.makedirs(backup_path.parent, exist_ok=True)
shutil.copy2(file_path, backup_path)
return backup_path
except Exception as e:
print(f"[MemoryFirewall] Backup failed for {file_path}: {e}")
return None
def _restore_from_backup(self, file_path: Path) -> bool:
"""
Restores a file from its backup.
The backup filename is reconstructed using the same MD5-based naming
scheme used in _create_backup to ensure the correct file is found.
Returns: True on success, False otherwise.
"""
rel_path = self._get_relative_path(file_path)
path_hash = hashlib.md5(rel_path.encode()).hexdigest()
backup_name = f"{path_hash}_{file_path.name}.backup"
backup_path = self.backup_dir / backup_name
if not backup_path.exists():
print(f"[MemoryFirewall] No backup found for {rel_path}")
return False
try:
timestamp = datetime.now().strftime('%Y%m%d_%H%M%S')
corrupted_name = f"{path_hash}_{file_path.name}.corrupted_{timestamp}"
corrupted_backup = self.backup_dir / corrupted_name
if file_path.exists():
shutil.copy2(file_path, corrupted_backup)
shutil.copy2(backup_path, file_path)
restored_hash = self._compute_hash(file_path)
if restored_hash:
with self._ledger_lock:
self.ledger[rel_path] = restored_hash
self._save_ledger()
self.stats["total_restores"] += 1
print(f"[MemoryFirewall] Restored {rel_path} from backup")
return True
except Exception as e:
print(f"[MemoryFirewall] Restore failed for {file_path}: {e}")
return False
# --- Integrity checks ---
def verify_file(self, file_path: Path) -> bool:
"""
Checks the integrity of a single file.
Returns:
True -- file is intact (hash matches).
False -- file has been tampered with.
"""
self.stats["total_integrity_checks"] += 1
rel_path = self._get_relative_path(file_path)
if rel_path not in self.ledger:
self.register_file(file_path)
return True
expected_hash = self.ledger[rel_path]
current_hash = self._compute_hash(file_path)
if current_hash is None:
print(f"[MemoryFirewall] File missing: {rel_path}")
return False
if current_hash != expected_hash:
self._handle_violation(file_path, rel_path, expected_hash, current_hash)
return False
return True
def verify_all(self) -> Dict[str, bool]:
"""
Checks the integrity of every file registered in the ledger.
Returns a {relative_path: is_valid} dictionary.
"""
results = {}
with self._ledger_lock:
files_to_check = list(self.ledger.keys())
for rel_path in files_to_check:
file_path = self.workspace / rel_path
results[rel_path] = self.verify_file(file_path)
return results
def verify_before_read(self, file_path: Path) -> bool:
"""
Checks a file's integrity before the AI reads it.
Blocks access and attempts auto-restore if tampering is detected.
Returns:
True -- file is safe to read (or was successfully restored).
False -- file is compromised and could not be restored.
"""
rel_path = self._get_relative_path(file_path)
if rel_path not in self.ledger:
self.register_file(file_path)
return True
expected_hash = self.ledger[rel_path]
current_hash = self._compute_hash(file_path)
if current_hash is None:
print(f"[MemoryFirewall] File missing: {rel_path}")
return False
if current_hash != expected_hash:
self.stats["total_integrity_checks"] += 1
# _handle_violation internally calls _restore_from_backup, so we
# only need to check whether the restored file now matches ledger.
self._handle_violation(file_path, rel_path, expected_hash, current_hash)
restored_hash = self._compute_hash(file_path)
expected_after = self.ledger.get(rel_path)
if restored_hash and restored_hash == expected_after:
print(
f"[MemoryFirewall] File {file_path.name} was tampered with "
f"-- restored from backup"
)
return True
else:
print(
f"[MemoryFirewall] File {file_path.name} is compromised "
f"and cannot be restored!"
)
return False
self.stats["total_integrity_checks"] += 1
return True
# --- Violation handling ---
def _handle_violation(
self,
file_path: Path,
rel_path: str,
expected_hash: str,
current_hash: str,
):
"""
Called when a file fails an integrity check. This method:
1. Increments violation statistics and logs the event.
2. Emits a NOTIFY_SOS event on the EventBus.
3. Sends a fire-and-forget Telegram alarm (never blocks the main loop).
4. Always calls _restore_from_backup so callers (verify_file,
verify_all, verify_before_read) all benefit from auto-restore
without needing to duplicate restore logic.
FIX (Async RuntimeError): The old asyncio.get_running_loop() /
asyncio.run() pattern caused RuntimeError when called from inside a
running event loop. It is replaced with asyncio.create_task(), which
schedules the coroutine on the already-running loop without blocking it.
The entire Telegram call is wrapped in try/except so a failure there
never crashes the main loop.
FIX (Auto-Restore): _restore_from_backup is now always called at the
end of this method, giving verify_file and verify_all auto-restore
behavior in addition to verify_before_read.
"""
self.stats["total_violations"] += 1
self.stats["last_violation_at"] = datetime.now().isoformat()
violation_msg = (
f"MEMORY FIREWALL ALERT!\n\n"
f"File: {rel_path}\n"
f"Full path: {file_path}\n"
f"Expected hash: {expected_hash[:16]}...\n"
f"Current hash: {current_hash[:16]}...\n"
f"Time: {datetime.now().isoformat()}\n\n"
f"Action: Restoring file from backup immediately."
)
print(f"[MemoryFirewall] {violation_msg}")
# 1. Emit EventBus alarm
if self.event_bus and EVENT_BUS_AVAILABLE:
self.event_bus.emit_sync(
Events.NOTIFY_SOS if Events else "notify.sos",
{
"type": "memory_violation",
"file": rel_path,
"expected_hash": expected_hash,
"current_hash": current_hash,
"timestamp": datetime.now().isoformat(),
},
)
# 2. Fire-and-forget Telegram alarm (never blocks or crashes the main loop).
# FIX: asyncio.create_task() schedules on the running loop without
# blocking; the old asyncio.run() raised RuntimeError inside async contexts.
if self.telegram_bot and TELEGRAM_AVAILABLE and self.telegram_bot.is_running:
try:
asyncio.create_task(
self.telegram_bot.send_message(
text=violation_msg,
parse_mode="Markdown",
)
)
except Exception as e:
print(f"[MemoryFirewall] Telegram alert failed: {e}")
# 3. Always attempt auto-restore so verify_file and verify_all
# also repair the file, not just raise an alarm.
self._restore_from_backup(file_path)
# --- Main run method (LangGraph-compatible) ---
def run(self, state: Dict[str, Any]) -> Dict[str, Any]:
"""
LangGraph-compatible run method. Call this before the AI reads any
memory or context file.
FIX (Dead Parameter): The unused `instruction: Optional[str] = None`
parameter has been removed from the signature.
"""
target_file = state.get("file_path", "")
if not target_file:
return state
file_path = Path(target_file)
if not file_path.is_absolute():
file_path = self.workspace / target_file
is_safe = self.verify_before_read(file_path)
if is_safe:
self._add_timeline(state, "MemoryFirewall", "OK",
f"File safe: {file_path.name}")
state["memory_integrity"] = "safe"
else:
self._add_timeline(state, "MemoryFirewall", "ALERT",
f"File tampered, restored: {file_path.name}")
state["memory_integrity"] = "compromised_restored"
state["current_step"] = "memory_verified"
return state
def get_stats(self) -> Dict[str, Any]:
"""Returns current firewall statistics."""
return {
**self.stats,
"ledger_size": len(self.ledger),
"workspace": str(self.workspace),
}