Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 23 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -5,3 +5,26 @@ __pycache__/
*.pyc
.pytest_cache/
mempal.yaml

# Security-sensitive files
.env
.env.*
*.key
auth_token
entities.json

# Database files
*.sqlite3

# Virtual environments
venv/
.venv/
env/

# IDE
.idea/
.vscode/

# Test coverage
htmlcov/
.coverage
34 changes: 34 additions & 0 deletions mempalace/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,8 @@
import os
from pathlib import Path

from .security import secure_dir, secure_file

DEFAULT_PALACE_PATH = os.path.expanduser("~/.mempalace/palace")
DEFAULT_COLLECTION_NAME = "mempalace_drawers"

Expand Down Expand Up @@ -123,9 +125,39 @@ def hall_keywords(self):
"""Mapping of hall names to keyword lists."""
return self._file_config.get("hall_keywords", DEFAULT_HALL_KEYWORDS)

# ── Security settings ────────────────────────────────────────────────

def _security(self, key, default=None):
"""Read a value from the nested security config block."""
env_key = f"MEMPALACE_{key.upper()}"
env_val = os.environ.get(env_key)
if env_val is not None:
if isinstance(default, bool):
return env_val.lower() in ("1", "true", "yes")
if isinstance(default, int):
return int(env_val)
return env_val
return self._file_config.get("security", {}).get(key, default)

@property
def auth_enabled(self):
"""Whether MCP token authentication is required."""
return self._security("auth_enabled", False)

@property
def encryption_enabled(self):
"""Whether data-at-rest encryption is active."""
return self._security("encryption_enabled", False)

@property
def max_content_size(self):
"""Maximum allowed content size in bytes for MCP write operations."""
return self._security("max_content_size", 1_048_576) # 1 MB

def init(self):
"""Create config directory and write default config.json if it doesn't exist."""
self._config_dir.mkdir(parents=True, exist_ok=True)
secure_dir(self._config_dir)
if not self._config_file.exists():
default_config = {
"palace_path": DEFAULT_PALACE_PATH,
Expand All @@ -135,6 +167,7 @@ def init(self):
}
with open(self._config_file, "w") as f:
json.dump(default_config, f, indent=2)
secure_file(self._config_file)
return self._config_file

def save_people_map(self, people_map):
Expand All @@ -146,4 +179,5 @@ def save_people_map(self, people_map):
self._config_dir.mkdir(parents=True, exist_ok=True)
with open(self._people_map_file, "w") as f:
json.dump(people_map, f, indent=2)
secure_file(self._people_map_file)
return self._people_map_file
4 changes: 2 additions & 2 deletions mempalace/convo_miner.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@

import os
import sys
import hashlib
from .security import content_hash
from pathlib import Path
from datetime import datetime
from collections import defaultdict
Expand Down Expand Up @@ -356,7 +356,7 @@ def mine_convos(
chunk_room = chunk.get("memory_type", room) if extract_mode == "general" else room
if extract_mode == "general":
room_counts[chunk_room] += 1
drawer_id = f"drawer_{wing}_{chunk_room}_{hashlib.md5((source_file + str(chunk['chunk_index'])).encode(), usedforsecurity=False).hexdigest()[:16]}"
drawer_id = f"drawer_{wing}_{chunk_room}_{content_hash(source_file + str(chunk['chunk_index']))}"
try:
collection.add(
documents=[chunk["content"]],
Expand Down
37 changes: 33 additions & 4 deletions mempalace/knowledge_graph.py
Original file line number Diff line number Diff line change
Expand Up @@ -35,20 +35,22 @@
kg.invalidate("Max", "has_issue", "sports_injury", ended="2026-02-15")
"""

import hashlib
import json
import os
import sqlite3
from datetime import date, datetime
from pathlib import Path

from .security import content_hash, decrypt as sec_decrypt, encrypt as sec_encrypt, secure_file


DEFAULT_KG_PATH = os.path.expanduser("~/.mempalace/knowledge_graph.sqlite3")


class KnowledgeGraph:
def __init__(self, db_path: str = None):
def __init__(self, db_path: str = None, fernet=None):
self.db_path = db_path or DEFAULT_KG_PATH
self._fernet = fernet
Path(self.db_path).parent.mkdir(parents=True, exist_ok=True)
self._init_db()

Expand Down Expand Up @@ -85,6 +87,7 @@ def _init_db(self):
""")
conn.commit()
conn.close()
secure_file(self.db_path)

def _conn(self):
conn = sqlite3.connect(self.db_path, timeout=10)
Expand All @@ -96,10 +99,36 @@ def _entity_id(self, name: str) -> str:

# ── Write operations ──────────────────────────────────────────────────

def _encrypt_props(self, props_json: str) -> str:
"""Encrypt properties JSON if encryption is enabled."""
if self._fernet:
return sec_encrypt(self._fernet, props_json)
return props_json

def _decrypt_props(self, props_str: str) -> str:
"""Decrypt properties string if it looks like Fernet ciphertext.

Fernet tokens always start with 'gAAAAA'. If the string starts with '{'
it's unencrypted JSON from before encryption was enabled.
"""
if not props_str or props_str.startswith("{"):
return props_str # Already plaintext JSON
if self._fernet:
try:
return sec_decrypt(self._fernet, props_str)
except Exception:
import logging

logging.getLogger("mempalace_security").error(
"Failed to decrypt entity properties — wrong key or corrupted data"
)
return "{}" # Return empty properties rather than ciphertext
return props_str # No fernet available, return as-is

def add_entity(self, name: str, entity_type: str = "unknown", properties: dict = None):
"""Add or update an entity node."""
eid = self._entity_id(name)
props = json.dumps(properties or {})
props = self._encrypt_props(json.dumps(properties or {}))
conn = self._conn()
conn.execute(
"INSERT OR REPLACE INTO entities (id, name, type, properties) VALUES (?, ?, ?, ?)",
Expand Down Expand Up @@ -147,7 +176,7 @@ def add_triple(
conn.close()
return existing[0] # Already exists and still valid

triple_id = f"t_{sub_id}_{pred}_{obj_id}_{hashlib.md5(f'{valid_from}{datetime.now().isoformat()}'.encode()).hexdigest()[:8]}"
triple_id = f"t_{sub_id}_{pred}_{obj_id}_{content_hash(f'{valid_from}{datetime.now().isoformat()}', length=8)}"

conn.execute(
"""INSERT INTO triples (id, subject, predicate, object, valid_from, valid_to, confidence, source_closet, source_file)
Expand Down
Loading