Skip to content

Commit 30c31df

Browse files
committed
fix: security hardening and stability improvements for MCP tools
- Fix shell injection in view_repo_file: replace shell=True subprocess with pathlib iteration - Add path traversal guards in view_repo_file, write_doc_file, edit_doc_file (reject paths escaping repo/output dir) - Add threading.Lock to SessionStore for concurrent access safety - Cap max sessions to 10, evict oldest when full - Cap read_code_components to 50 IDs per call - Cap edit history to 20 entries per file - Store edit history as native dict instead of JSON string - Fix undo to run Mermaid validation after reverting content - Fix Mermaid validation to report "skipped" instead of false success - Fix Mermaid timeout to warn instead of silent pass - Fix pagination hint to point to list_components instead of analyze_repo - Clamp offset to non-negative in _build_component_index - Add smoke test covering all critical paths (25 assertions)
1 parent d3fdcca commit 30c31df

6 files changed

Lines changed: 374 additions & 66 deletions

File tree

codewiki/mcp/session.py

Lines changed: 41 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -8,10 +8,10 @@
88

99
from __future__ import annotations
1010

11+
import threading
1112
import time
1213
import uuid
1314
from dataclasses import dataclass, field
14-
from pathlib import Path
1515
from typing import Any, Dict, List, Optional
1616

1717
from codewiki.src.be.dependency_analyzer.models.core import Node
@@ -20,6 +20,9 @@
2020
# Sessions auto-expire after this many seconds of inactivity.
2121
_SESSION_TTL_SECONDS = 2 * 60 * 60 # 2 hours
2222

23+
# Maximum concurrent sessions to prevent unbounded memory growth.
24+
_MAX_SESSIONS = 10
25+
2326

2427
@dataclass
2528
class SessionState:
@@ -45,10 +48,11 @@ def is_expired(self) -> bool:
4548

4649

4750
class SessionStore:
48-
"""In-memory store for all active MCP sessions."""
51+
"""In-memory store for all active MCP sessions (thread-safe)."""
4952

5053
def __init__(self) -> None:
5154
self._sessions: Dict[str, SessionState] = {}
55+
self._lock = threading.Lock()
5256

5357
def create(
5458
self,
@@ -58,35 +62,48 @@ def create(
5862
leaf_nodes: List[str],
5963
) -> SessionState:
6064
"""Create a new session and return it."""
61-
session_id = uuid.uuid4().hex[:12]
62-
state = SessionState(
63-
session_id=session_id,
64-
repo_path=repo_path,
65-
output_dir=output_dir,
66-
components=components,
67-
leaf_nodes=leaf_nodes,
68-
)
69-
self._sessions[session_id] = state
70-
self._purge_expired()
71-
return state
65+
with self._lock:
66+
self._purge_expired_locked()
67+
# Evict oldest if at capacity
68+
if len(self._sessions) >= _MAX_SESSIONS:
69+
oldest_id = min(
70+
self._sessions,
71+
key=lambda sid: self._sessions[sid].last_accessed,
72+
)
73+
del self._sessions[oldest_id]
74+
session_id = uuid.uuid4().hex[:12]
75+
# Ensure no collision
76+
while session_id in self._sessions:
77+
session_id = uuid.uuid4().hex[:12]
78+
state = SessionState(
79+
session_id=session_id,
80+
repo_path=repo_path,
81+
output_dir=output_dir,
82+
components=components,
83+
leaf_nodes=leaf_nodes,
84+
)
85+
self._sessions[session_id] = state
86+
return state
7287

7388
def get(self, session_id: str) -> Optional[SessionState]:
7489
"""Return the session or ``None`` if not found / expired."""
75-
state = self._sessions.get(session_id)
76-
if state is None:
77-
return None
78-
if state.is_expired:
79-
del self._sessions[session_id]
80-
return None
81-
state.touch()
82-
return state
90+
with self._lock:
91+
state = self._sessions.get(session_id)
92+
if state is None:
93+
return None
94+
if state.is_expired:
95+
del self._sessions[session_id]
96+
return None
97+
state.touch()
98+
return state
8399

84100
def remove(self, session_id: str) -> bool:
85101
"""Remove a session. Returns True if it existed."""
86-
return self._sessions.pop(session_id, None) is not None
102+
with self._lock:
103+
return self._sessions.pop(session_id, None) is not None
87104

88-
def _purge_expired(self) -> None:
89-
"""Remove all expired sessions."""
105+
def _purge_expired_locked(self) -> None:
106+
"""Remove all expired sessions. Caller must hold _lock."""
90107
expired = [sid for sid, s in self._sessions.items() if s.is_expired]
91108
for sid in expired:
92109
del self._sessions[sid]

codewiki/mcp/tools/analysis.py

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,8 @@ def _build_component_index(
3333
"""
3434
all_ids = list(components.keys())
3535
total = len(all_ids)
36-
limit = min(max(limit, 1), 200) # clamp to [1, 200]
36+
offset = max(0, int(offset)) # prevent negative-index wrapping
37+
limit = min(max(int(limit), 1), 200) # clamp to [1, 200]
3738
page_ids = all_ids[offset : offset + limit]
3839
index: list[dict] = []
3940
for comp_id in page_ids:
@@ -336,7 +337,7 @@ def handle_analyze_repo(
336337
if pagination["has_more"]:
337338
result["hint"] += (
338339
f" Component index has {pagination['total']} items; "
339-
f"call analyze_repo again with offset={offset + limit} to see the next page."
340+
f"call list_components(session_id='{session.session_id}', offset={offset + limit}) to see the next page."
340341
)
341342
if changes and not changes.get("no_changes"):
342343
result["hint"] = (

codewiki/mcp/tools/code_reader.py

Lines changed: 51 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -8,10 +8,8 @@
88

99
import json
1010
import logging
11-
import os
12-
import subprocess
1311
from pathlib import Path
14-
from typing import Any, Dict, List, Optional
12+
from typing import Any, Dict, List
1513

1614
from codewiki.mcp.session import SessionState, SessionStore
1715

@@ -20,13 +18,25 @@
2018
# Truncation guard for very large responses
2119
_MAX_RESPONSE_LEN = 32000
2220

21+
# Max components per read_code_components call
22+
_MAX_COMPONENTS_PER_CALL = 50
23+
2324

2425
def _maybe_truncate(text: str, limit: int = _MAX_RESPONSE_LEN) -> str:
2526
if len(text) <= limit:
2627
return text
2728
return text[:limit] + "\n\n<response clipped — use view_repo_file with view_range to read more>"
2829

2930

31+
def _is_within(path: Path, base: Path) -> bool:
32+
"""Return True if *path* resolves to somewhere inside *base*."""
33+
try:
34+
path.resolve().relative_to(base.resolve())
35+
return True
36+
except ValueError:
37+
return False
38+
39+
3040
def handle_read_code_components(
3141
arguments: Dict[str, Any],
3242
store: SessionStore,
@@ -38,8 +48,11 @@ def handle_read_code_components(
3848
return json.dumps({"error": f"Session {session_id} not found or expired."})
3949

4050
component_ids: List[str] = arguments["component_ids"]
41-
components = session.components
51+
# Cap the number of components to avoid oversized responses
52+
if len(component_ids) > _MAX_COMPONENTS_PER_CALL:
53+
component_ids = component_ids[:_MAX_COMPONENTS_PER_CALL]
4254

55+
components = session.components
4356
results = []
4457
for cid in component_ids:
4558
node = components.get(cid)
@@ -52,6 +65,8 @@ def handle_read_code_components(
5265
results.append(f"## {cid} ({getattr(node, 'component_type', '')})\n```{fence}\n{code}\n```\n")
5366

5467
output = "\n".join(results)
68+
if len(arguments["component_ids"]) > _MAX_COMPONENTS_PER_CALL:
69+
output = f"<only first {_MAX_COMPONENTS_PER_CALL} components shown; call again with the remaining IDs>\n\n" + output
5570
return _maybe_truncate(output)
5671

5772

@@ -66,20 +81,43 @@ def handle_view_repo_file(
6681
return json.dumps({"error": f"Session {session_id} not found or expired."})
6782

6883
rel_path = arguments["path"]
69-
abs_path = Path(session.repo_path) / rel_path
84+
repo_base = Path(session.repo_path).resolve()
85+
abs_path = (repo_base / rel_path).resolve()
86+
87+
# Path traversal guard
88+
if not _is_within(abs_path, repo_base):
89+
return json.dumps({"error": "Path escapes repository directory."})
7090

7191
if not abs_path.exists():
7292
return json.dumps({"error": f"Path not found: {rel_path}"})
7393

74-
# Directory listing
94+
# Directory listing — use pathlib instead of shelling out
7595
if abs_path.is_dir():
76-
out = subprocess.run(
77-
rf"find {abs_path} -maxdepth 2 -not -path '*/\.*'",
78-
shell=True,
79-
capture_output=True,
80-
)
81-
listing = out.stdout.decode("utf-8", errors="replace")
82-
listing = listing.replace(str(abs_path), rel_path)
96+
entries: list[str] = []
97+
for child in sorted(abs_path.iterdir()):
98+
if child.name.startswith("."):
99+
continue
100+
rel_child = child.relative_to(repo_base)
101+
suffix = "/" if child.is_dir() else ""
102+
entries.append(f"{rel_child}{suffix}")
103+
# Also list one level deeper if there aren't too many entries
104+
if len(entries) <= 50:
105+
expanded: list[str] = []
106+
for child in sorted(abs_path.iterdir()):
107+
if child.name.startswith("."):
108+
continue
109+
rel_child = child.relative_to(repo_base)
110+
suffix = "/" if child.is_dir() else ""
111+
expanded.append(f"{rel_child}{suffix}")
112+
if child.is_dir():
113+
for sub in sorted(child.iterdir()):
114+
if sub.name.startswith("."):
115+
continue
116+
rel_sub = sub.relative_to(repo_base)
117+
sub_suffix = "/" if sub.is_dir() else ""
118+
expanded.append(f" {rel_sub}{sub_suffix}")
119+
entries = expanded
120+
listing = "\n".join(entries)
83121
return f"Directory listing for {rel_path}:\n{listing}"
84122

85123
# File view

codewiki/mcp/tools/doc_writer.py

Lines changed: 65 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -6,17 +6,43 @@
66

77
from __future__ import annotations
88

9-
import asyncio
109
import json
1110
import logging
12-
import os
1311
from pathlib import Path
14-
from typing import Any, Dict, Optional
12+
from typing import Any, Dict
1513

1614
from codewiki.mcp.session import SessionState, SessionStore
1715

1816
logger = logging.getLogger(__name__)
1917

18+
# Max edit history entries per file (prevent unbounded memory growth)
19+
_MAX_HISTORY_PER_FILE = 20
20+
21+
22+
def _is_within(path: Path, base: Path) -> bool:
23+
"""Return True if *path* resolves to somewhere inside *base*."""
24+
try:
25+
path.resolve().relative_to(base.resolve())
26+
return True
27+
except ValueError:
28+
return False
29+
30+
31+
def _safe_doc_path(session: SessionState, filename: str) -> Path | None:
32+
"""Resolve *filename* within session.output_dir, guarding against traversal."""
33+
if not filename.endswith(".md"):
34+
filename += ".md"
35+
output_base = Path(session.output_dir).resolve()
36+
doc_path = (output_base / filename).resolve()
37+
if not _is_within(doc_path, output_base):
38+
return None
39+
return doc_path
40+
41+
42+
def _ensure_parent_dirs(path: Path) -> None:
43+
"""Create parent directories if they don't exist."""
44+
path.parent.mkdir(parents=True, exist_ok=True)
45+
2046

2147
async def _validate_mermaid(file_path: str, relative_path: str) -> str:
2248
"""Run Mermaid validation and return the result string."""
@@ -27,9 +53,20 @@ async def _validate_mermaid(file_path: str, relative_path: str) -> str:
2753
return f"Mermaid validation skipped: {e}"
2854

2955

30-
def _ensure_parent_dirs(path: Path) -> None:
31-
"""Create parent directories if they don't exist."""
32-
path.parent.mkdir(parents=True, exist_ok=True)
56+
def _save_history(session: SessionState, doc_path: Path, content: str) -> None:
57+
"""Append *content* to edit history for *doc_path*, capped at _MAX_HISTORY_PER_FILE."""
58+
history = session.registry.get("file_history")
59+
if history is None:
60+
history = {}
61+
elif isinstance(history, str):
62+
history = json.loads(history)
63+
key = str(doc_path)
64+
entry = history.setdefault(key, [])
65+
entry.append(content)
66+
# Trim to last N entries
67+
if len(entry) > _MAX_HISTORY_PER_FILE:
68+
del entry[: len(entry) - _MAX_HISTORY_PER_FILE]
69+
session.registry["file_history"] = history # keep as native dict
3370

3471

3572
async def handle_write_doc_file(
@@ -43,11 +80,12 @@ async def handle_write_doc_file(
4380
return json.dumps({"error": f"Session {session_id} not found or expired."})
4481

4582
filename = arguments["filename"]
46-
if not filename.endswith(".md"):
47-
filename += ".md"
83+
doc_path = _safe_doc_path(session, filename)
84+
if doc_path is None:
85+
return json.dumps({"error": "Filename escapes output directory."})
86+
4887
content = arguments["content"]
4988

50-
doc_path = Path(session.output_dir) / filename
5189
_ensure_parent_dirs(doc_path)
5290

5391
if doc_path.exists():
@@ -81,36 +119,39 @@ async def handle_edit_doc_file(
81119
return json.dumps({"error": f"Session {session_id} not found or expired."})
82120

83121
filename = arguments["filename"]
84-
if not filename.endswith(".md"):
85-
filename += ".md"
122+
doc_path = _safe_doc_path(session, filename)
123+
if doc_path is None:
124+
return json.dumps({"error": "Filename escapes output directory."})
86125

87-
doc_path = Path(session.output_dir) / filename
88126
command = arguments["command"]
89127

90128
if command == "undo":
91129
# Undo via registry history
92-
history_key = str(doc_path)
93-
history = session.registry.get("file_history", "{}")
94-
file_history = json.loads(history) if isinstance(history, str) else history
95-
path_history = file_history.get(history_key, [])
130+
history = session.registry.get("file_history", {})
131+
if isinstance(history, str):
132+
history = json.loads(history)
133+
path_history = history.get(str(doc_path), [])
96134
if not path_history:
97135
return json.dumps({"error": f"No edit history found for {filename}."})
98136
old_content = path_history.pop()
99-
file_history[history_key] = path_history
100-
session.registry["file_history"] = json.dumps(file_history)
137+
history[str(doc_path)] = path_history
138+
session.registry["file_history"] = history
101139
doc_path.write_text(old_content, encoding="utf-8")
102-
return json.dumps({"status": "undone", "filename": filename})
140+
141+
# Validate Mermaid after undo
142+
mermaid_result = await _validate_mermaid(str(doc_path), filename)
143+
return json.dumps({
144+
"status": "undone",
145+
"filename": filename,
146+
"mermaid_validation": mermaid_result,
147+
}, ensure_ascii=False)
103148

104149
if not doc_path.exists():
105150
return json.dumps({"error": f"File not found: {filename}. Use write_doc_file to create it."})
106151

107152
# Save current content to history before editing
108153
current_content = doc_path.read_text(encoding="utf-8")
109-
history_key = str(doc_path)
110-
history = session.registry.get("file_history", "{}")
111-
file_history = json.loads(history) if isinstance(history, str) else history
112-
file_history.setdefault(history_key, []).append(current_content)
113-
session.registry["file_history"] = json.dumps(file_history)
154+
_save_history(session, doc_path, current_content)
114155

115156
if command == "str_replace":
116157
old_str = arguments.get("old_str")

codewiki/src/be/utils.py

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -239,16 +239,15 @@ async def validate_single_diagram(diagram_content: str, diagram_num: int, line_s
239239
"""
240240
core_error = await _try_pythonmonkey_parse(diagram_content)
241241
if core_error is None:
242-
# Both PythonMonkey (3.12+) and mermaid-py (env disabled) are unavailable
243242
if _MERMAID_PY_BROKEN:
244-
return "" # Skip validation gracefully
243+
return f" Diagram {diagram_num}: validation skipped (set MERMAID_VALIDATE=1 to enable)"
245244
try:
246245
core_error = await asyncio.wait_for(
247246
asyncio.to_thread(_parse_via_mermaid_py, diagram_content),
248247
timeout=15.0,
249248
)
250249
except asyncio.TimeoutError:
251-
return "" # Graceful skip on timeout
250+
return f" Diagram {diagram_num}: validation timed out (15s) — diagram may be invalid"
252251
except Exception as e:
253252
return f" Diagram {diagram_num}: Exception during validation - {str(e)}"
254253

0 commit comments

Comments
 (0)