fix: restrict file permissions on sensitive palace data (#814)

* fix: restrict file permissions on sensitive palace data

On Linux with default umask (022), several files and directories
containing personal data were created world-readable. This patch
applies chmod 0o700 to directories and 0o600 to files immediately
after creation, wrapped in try/except for Windows compatibility.

Files hardened:
- hooks_cli.py: hook_state/ directory and hook.log
- entity_registry.py: entity_registry.json (names, relationships)
- knowledge_graph.py: knowledge_graph.sqlite3 parent directory
- exporter.py: export output directory and wing subdirectories
- config.py: people_map.json (name mappings)
- mcp_server.py: WAL file creation uses atomic os.open (TOCTOU fix)

Refs: MemPalace/mempalace#809

* fix: avoid redundant chmod calls on hot paths

- hooks_cli.py: chmod STATE_DIR and hook.log only on first creation,
  not on every _log() call (hooks fire on every Stop event)
- exporter.py: track created wing dirs to skip redundant makedirs +
  chmod on the same directory across batches
- mcp_server.py: remove redundant _WAL_FILE.chmod after os.open
  already set mode=0o600 atomically

Refs: MemPalace/mempalace#809
This commit is contained in:
Marcio E. Heiderscheidt
2026-04-15 04:27:03 -03:00
committed by GitHub
parent e61dc2adf8
commit b524b31839
6 changed files with 56 additions and 11 deletions
+4
View File
@@ -251,4 +251,8 @@ class MempalaceConfig:
self._config_dir.mkdir(parents=True, exist_ok=True) self._config_dir.mkdir(parents=True, exist_ok=True)
with open(self._people_map_file, "w") as f: with open(self._people_map_file, "w") as f:
json.dump(people_map, f, indent=2) json.dump(people_map, f, indent=2)
try:
self._people_map_file.chmod(0o600)
except (OSError, NotImplementedError):
pass
return self._people_map_file return self._people_map_file
+8
View File
@@ -316,7 +316,15 @@ class EntityRegistry:
def save(self): def save(self):
self._path.parent.mkdir(parents=True, exist_ok=True) self._path.parent.mkdir(parents=True, exist_ok=True)
try:
self._path.parent.chmod(0o700)
except (OSError, NotImplementedError):
pass
self._path.write_text(json.dumps(self._data, indent=2), encoding="utf-8") self._path.write_text(json.dumps(self._data, indent=2), encoding="utf-8")
try:
self._path.chmod(0o600)
except (OSError, NotImplementedError):
pass
@staticmethod @staticmethod
def _empty() -> dict: def _empty() -> dict:
+13 -1
View File
@@ -49,9 +49,15 @@ def export_palace(palace_path: str, output_dir: str, format: str = "markdown") -
return {"wings": 0, "rooms": 0, "drawers": 0} return {"wings": 0, "rooms": 0, "drawers": 0}
os.makedirs(output_dir, exist_ok=True) os.makedirs(output_dir, exist_ok=True)
try:
os.chmod(output_dir, 0o700)
except (OSError, NotImplementedError):
pass
# Track which room files have been opened (so we can append vs overwrite) # Track which room files have been opened (so we can append vs overwrite)
opened_rooms: set[tuple[str, str]] = set() opened_rooms: set[tuple[str, str]] = set()
# Track which wing directories have been created and chmoded
created_wing_dirs: set[str] = set()
# Track stats per wing: {wing: {room: count}} # Track stats per wing: {wing: {room: count}}
wing_stats: dict[str, dict[str, int]] = defaultdict(lambda: defaultdict(int)) wing_stats: dict[str, dict[str, int]] = defaultdict(lambda: defaultdict(int))
total_drawers = 0 total_drawers = 0
@@ -82,7 +88,13 @@ def export_palace(palace_path: str, output_dir: str, format: str = "markdown") -
for wing, rooms in batch_grouped.items(): for wing, rooms in batch_grouped.items():
safe_wing = _safe_path_component(wing) safe_wing = _safe_path_component(wing)
wing_dir = os.path.join(output_dir, safe_wing) wing_dir = os.path.join(output_dir, safe_wing)
os.makedirs(wing_dir, exist_ok=True) if wing_dir not in created_wing_dirs:
os.makedirs(wing_dir, exist_ok=True)
try:
os.chmod(wing_dir, 0o700)
except (OSError, NotImplementedError):
pass
created_wing_dirs.add(wing_dir)
for room, drawers in rooms.items(): for room, drawers in rooms.items():
safe_room = _safe_path_component(room) safe_room = _safe_path_component(room)
+17 -1
View File
@@ -105,14 +105,30 @@ def _count_human_messages(transcript_path: str) -> int:
return count return count
_state_dir_initialized = False
def _log(message: str): def _log(message: str):
"""Append to hook state log file.""" """Append to hook state log file."""
global _state_dir_initialized
try: try:
STATE_DIR.mkdir(parents=True, exist_ok=True) if not _state_dir_initialized:
STATE_DIR.mkdir(parents=True, exist_ok=True)
try:
STATE_DIR.chmod(0o700)
except (OSError, NotImplementedError):
pass
_state_dir_initialized = True
log_path = STATE_DIR / "hook.log" log_path = STATE_DIR / "hook.log"
is_new = not log_path.exists()
timestamp = datetime.now().strftime("%H:%M:%S") timestamp = datetime.now().strftime("%H:%M:%S")
with open(log_path, "a") as f: with open(log_path, "a") as f:
f.write(f"[{timestamp}] {message}\n") f.write(f"[{timestamp}] {message}\n")
if is_new:
try:
log_path.chmod(0o600)
except (OSError, NotImplementedError):
pass
except OSError: except OSError:
pass pass
+6 -1
View File
@@ -50,7 +50,12 @@ DEFAULT_KG_PATH = os.path.expanduser("~/.mempalace/knowledge_graph.sqlite3")
class KnowledgeGraph: class KnowledgeGraph:
def __init__(self, db_path: str = None): def __init__(self, db_path: str = None):
self.db_path = db_path or DEFAULT_KG_PATH self.db_path = db_path or DEFAULT_KG_PATH
Path(self.db_path).parent.mkdir(parents=True, exist_ok=True) db_parent = Path(self.db_path).parent
db_parent.mkdir(parents=True, exist_ok=True)
try:
db_parent.chmod(0o700)
except (OSError, NotImplementedError):
pass
self._connection = None self._connection = None
self._lock = threading.Lock() self._lock = threading.Lock()
self._init_db() self._init_db()
+8 -8
View File
@@ -121,14 +121,14 @@ try:
except (OSError, NotImplementedError): except (OSError, NotImplementedError):
pass pass
_WAL_FILE = _WAL_DIR / "write_log.jsonl" _WAL_FILE = _WAL_DIR / "write_log.jsonl"
# Pre-create WAL file with restricted permissions to avoid race condition # Atomically create WAL file with restricted permissions (no TOCTOU race).
if not _WAL_FILE.exists(): # os.open with O_CREAT|O_WRONLY and mode 0o600 creates the file if absent
_WAL_FILE.touch(mode=0o600) # or opens it if present, both in a single syscall.
else: try:
try: _fd = os.open(str(_WAL_FILE), os.O_CREAT | os.O_WRONLY, 0o600)
_WAL_FILE.chmod(0o600) os.close(_fd)
except (OSError, NotImplementedError): except (OSError, NotImplementedError):
pass pass
# Keys whose values should be redacted in WAL entries to avoid logging sensitive content # Keys whose values should be redacted in WAL entries to avoid logging sensitive content
_WAL_REDACT_KEYS = frozenset( _WAL_REDACT_KEYS = frozenset(