@@ -32,6 +32,7 @@ class names from a defensive blocklist.
3232
3333from flask import Flask
3434
35+ from api .export_api import export_bp
3536from api .projects import projects_bp
3637from api .search import _IndexSearchOutcome
3738from api .sessions import sessions_bp
@@ -94,6 +95,13 @@ def _write_session(tmp_path, project: str, session_id: str, content: str):
9495 return p
9596
9697
98+ def _patch_exclusion_raises (monkeypatch ):
99+ def _boom (* _args , ** _kwargs ):
100+ raise ValueError ("internal_exclusion_secret" )
101+
102+ monkeypatch .setattr ("api._session_handlers.is_session_excluded" , _boom )
103+
104+
97105# ---------------------------------------------------------------------------
98106# /api/sessions/<project>/<id>
99107# ---------------------------------------------------------------------------
@@ -122,6 +130,18 @@ def _boom(*args, **kwargs):
122130 assert "internal_secret_field_id" not in json .dumps (body )
123131 _assert_no_class_name_leak (json .dumps (body ))
124132
133+ def test_500_when_exclusion_raises_does_not_leak (self , tmp_path , client , monkeypatch ):
134+ _write_session (tmp_path , "proj" , "abc" , '{"type":"user","message":{}}' )
135+ _patch_exclusion_raises (monkeypatch )
136+
137+ resp = client .get ("/api/sessions/proj/abc" )
138+ assert resp .status_code == 500
139+ body = resp .get_json ()
140+ assert body .get ("error" ) == "Failed to parse session"
141+ assert body .get ("code" ) == "PARSE_ERROR"
142+ assert "internal_exclusion_secret" not in json .dumps (body )
143+ _assert_no_class_name_leak (json .dumps (body ))
144+
125145 def test_404_on_missing_file_keeps_session_id_safe (self , tmp_path , client ):
126146 # Session ID is part of the URL so it appears in the 404 message —
127147 # that's fine; what we're guarding is exception-class leakage, which
@@ -163,6 +183,47 @@ def _boom(*args, **kwargs):
163183 assert "/private/path" not in json .dumps (body )
164184 _assert_no_class_name_leak (json .dumps (body ))
165185
186+ def test_500_when_exclusion_raises_does_not_leak (self , tmp_path , client , monkeypatch ):
187+ _write_session (tmp_path , "proj" , "abc" , '{"type":"user","message":{}}' )
188+ _patch_exclusion_raises (monkeypatch )
189+
190+ resp = client .get ("/api/sessions/proj/abc/stats" )
191+ assert resp .status_code == 500
192+ body = resp .get_json ()
193+ assert body .get ("error" ) == "Failed to parse session"
194+ assert body .get ("code" ) == "PARSE_ERROR"
195+ assert "internal_exclusion_secret" not in json .dumps (body )
196+ _assert_no_class_name_leak (json .dumps (body ))
197+
198+
199+ # ---------------------------------------------------------------------------
200+ # /api/export/session
201+ # ---------------------------------------------------------------------------
202+
203+
204+ class TestExportSessionErrorBody :
205+ @pytest .fixture
206+ def export_client (self , tmp_path ):
207+ app = Flask (__name__ )
208+ app .config ["TESTING" ] = True
209+ app .config ["CLAUDE_PROJECTS_DIR" ] = str (tmp_path )
210+ app .register_blueprint (export_bp )
211+ return app .test_client ()
212+
213+ def test_500_when_exclusion_raises_does_not_leak (
214+ self , tmp_path , export_client , monkeypatch
215+ ):
216+ _write_session (tmp_path , "proj" , "abc" , '{"type":"user","message":{}}' )
217+ _patch_exclusion_raises (monkeypatch )
218+
219+ resp = export_client .get ("/api/export/session/proj/abc" )
220+ assert resp .status_code == 500
221+ body = resp .get_json ()
222+ assert body .get ("error" ) == "Failed to parse session"
223+ assert body .get ("code" ) == "PARSE_ERROR"
224+ assert "internal_exclusion_secret" not in json .dumps (body )
225+ _assert_no_class_name_leak (json .dumps (body ))
226+
166227
167228# ---------------------------------------------------------------------------
168229# /api/projects (per-session card)
0 commit comments