Skip to content

Commit b524b31

Browse files
authored
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: #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: #809
1 parent e61dc2a commit b524b31

6 files changed

Lines changed: 56 additions & 11 deletions

File tree

mempalace/config.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -251,4 +251,8 @@ def save_people_map(self, people_map):
251251
self._config_dir.mkdir(parents=True, exist_ok=True)
252252
with open(self._people_map_file, "w") as f:
253253
json.dump(people_map, f, indent=2)
254+
try:
255+
self._people_map_file.chmod(0o600)
256+
except (OSError, NotImplementedError):
257+
pass
254258
return self._people_map_file

mempalace/entity_registry.py

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -316,7 +316,15 @@ def load(cls, config_dir: Optional[Path] = None) -> "EntityRegistry":
316316

317317
def save(self):
318318
self._path.parent.mkdir(parents=True, exist_ok=True)
319+
try:
320+
self._path.parent.chmod(0o700)
321+
except (OSError, NotImplementedError):
322+
pass
319323
self._path.write_text(json.dumps(self._data, indent=2), encoding="utf-8")
324+
try:
325+
self._path.chmod(0o600)
326+
except (OSError, NotImplementedError):
327+
pass
320328

321329
@staticmethod
322330
def _empty() -> dict:

mempalace/exporter.py

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -49,9 +49,15 @@ def export_palace(palace_path: str, output_dir: str, format: str = "markdown") -
4949
return {"wings": 0, "rooms": 0, "drawers": 0}
5050

5151
os.makedirs(output_dir, exist_ok=True)
52+
try:
53+
os.chmod(output_dir, 0o700)
54+
except (OSError, NotImplementedError):
55+
pass
5256

5357
# Track which room files have been opened (so we can append vs overwrite)
5458
opened_rooms: set[tuple[str, str]] = set()
59+
# Track which wing directories have been created and chmoded
60+
created_wing_dirs: set[str] = set()
5561
# Track stats per wing: {wing: {room: count}}
5662
wing_stats: dict[str, dict[str, int]] = defaultdict(lambda: defaultdict(int))
5763
total_drawers = 0
@@ -82,7 +88,13 @@ def export_palace(palace_path: str, output_dir: str, format: str = "markdown") -
8288
for wing, rooms in batch_grouped.items():
8389
safe_wing = _safe_path_component(wing)
8490
wing_dir = os.path.join(output_dir, safe_wing)
85-
os.makedirs(wing_dir, exist_ok=True)
91+
if wing_dir not in created_wing_dirs:
92+
os.makedirs(wing_dir, exist_ok=True)
93+
try:
94+
os.chmod(wing_dir, 0o700)
95+
except (OSError, NotImplementedError):
96+
pass
97+
created_wing_dirs.add(wing_dir)
8698

8799
for room, drawers in rooms.items():
88100
safe_room = _safe_path_component(room)

mempalace/hooks_cli.py

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -105,14 +105,30 @@ def _count_human_messages(transcript_path: str) -> int:
105105
return count
106106

107107

108+
_state_dir_initialized = False
109+
110+
108111
def _log(message: str):
109112
"""Append to hook state log file."""
113+
global _state_dir_initialized
110114
try:
111-
STATE_DIR.mkdir(parents=True, exist_ok=True)
115+
if not _state_dir_initialized:
116+
STATE_DIR.mkdir(parents=True, exist_ok=True)
117+
try:
118+
STATE_DIR.chmod(0o700)
119+
except (OSError, NotImplementedError):
120+
pass
121+
_state_dir_initialized = True
112122
log_path = STATE_DIR / "hook.log"
123+
is_new = not log_path.exists()
113124
timestamp = datetime.now().strftime("%H:%M:%S")
114125
with open(log_path, "a") as f:
115126
f.write(f"[{timestamp}] {message}\n")
127+
if is_new:
128+
try:
129+
log_path.chmod(0o600)
130+
except (OSError, NotImplementedError):
131+
pass
116132
except OSError:
117133
pass
118134

mempalace/knowledge_graph.py

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -50,7 +50,12 @@
5050
class KnowledgeGraph:
5151
def __init__(self, db_path: str = None):
5252
self.db_path = db_path or DEFAULT_KG_PATH
53-
Path(self.db_path).parent.mkdir(parents=True, exist_ok=True)
53+
db_parent = Path(self.db_path).parent
54+
db_parent.mkdir(parents=True, exist_ok=True)
55+
try:
56+
db_parent.chmod(0o700)
57+
except (OSError, NotImplementedError):
58+
pass
5459
self._connection = None
5560
self._lock = threading.Lock()
5661
self._init_db()

mempalace/mcp_server.py

Lines changed: 8 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -121,14 +121,14 @@ def _parse_args():
121121
except (OSError, NotImplementedError):
122122
pass
123123
_WAL_FILE = _WAL_DIR / "write_log.jsonl"
124-
# Pre-create WAL file with restricted permissions to avoid race condition
125-
if not _WAL_FILE.exists():
126-
_WAL_FILE.touch(mode=0o600)
127-
else:
128-
try:
129-
_WAL_FILE.chmod(0o600)
130-
except (OSError, NotImplementedError):
131-
pass
124+
# Atomically create WAL file with restricted permissions (no TOCTOU race).
125+
# os.open with O_CREAT|O_WRONLY and mode 0o600 creates the file if absent
126+
# or opens it if present, both in a single syscall.
127+
try:
128+
_fd = os.open(str(_WAL_FILE), os.O_CREAT | os.O_WRONLY, 0o600)
129+
os.close(_fd)
130+
except (OSError, NotImplementedError):
131+
pass
132132

133133
# Keys whose values should be redacted in WAL entries to avoid logging sensitive content
134134
_WAL_REDACT_KEYS = frozenset(

0 commit comments

Comments
 (0)