Skip to content

Commit 97726f4

Browse files
feat: runtime SessionDict validation at JSONL parse boundary (#49)
* feat: runtime SessionDict validation at JSONL parse boundary Replace cast(SessionDict, ...) in parse_session() with validate_session_dict() so untrusted .jsonl files are checked at ingress. Add SessionValidationError with path/detail fields, utils/validation.py, and tests for five malformed shapes plus session_minimal.jsonl regression. * fix: expose SessionValidationError.detail and fix test mypy types * fix: address PR #49 review (preserve dict keys, role test, parser type) Return validated input dict instead of reconstructing four keys. Add test_missing_role_in_message and annotate build_cli_parser(). * refactor(validation): DRY field checks, clearer root path, null test
1 parent 809be1b commit 97726f4

5 files changed

Lines changed: 158 additions & 8 deletions

File tree

app.py

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
"""Flask app that serves the web GUI for browsing sessions."""
22

3+
import argparse
34
import os
45
import sys
56

@@ -35,10 +36,8 @@ def index():
3536
return app
3637

3738

38-
def build_cli_parser():
39+
def build_cli_parser() -> argparse.ArgumentParser:
3940
"""CLI argument parser for ``python app.py`` (stdlib only; safe to import in tests)."""
40-
import argparse
41-
4241
parser = argparse.ArgumentParser(description="Claude Code Chat Browser")
4342
parser.add_argument("--port", type=int, default=5000)
4443
parser.add_argument("--host", default="127.0.0.1")

models/errors.py

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,16 @@
1-
"""HTTP error response shapes."""
1+
"""HTTP error response shapes and domain validation errors."""
22

33
from typing import TypedDict
44

55

66
class ErrorResponse(TypedDict):
77
error: str
8+
9+
10+
class SessionValidationError(ValueError):
11+
"""Raised when parsed JSONL output does not match SessionDict contract."""
12+
13+
def __init__(self, path: str, detail: str) -> None:
14+
self.path = path
15+
self.detail = detail
16+
super().__init__(f"Session validation failed at {path}: {detail}")

tests/test_jsonl_validation.py

Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,86 @@
1+
"""Runtime validation at the JSONL → SessionDict boundary."""
2+
3+
import os
4+
import sys
5+
from typing import Any
6+
7+
import pytest
8+
9+
sys.path.insert(0, os.path.join(os.path.dirname(__file__), ".."))
10+
11+
from models.errors import SessionValidationError # noqa: E402
12+
from utils.jsonl_parser import parse_session # noqa: E402
13+
from utils.validation import validate_session_dict # noqa: E402
14+
15+
FIXTURES = os.path.join(os.path.dirname(__file__), "fixtures")
16+
17+
18+
def _valid_payload(**overrides: Any) -> dict[str, Any]:
19+
base: dict[str, Any] = {
20+
"session_id": "abc123",
21+
"title": "Test Session",
22+
"messages": [{"role": "user", "text": "hello"}],
23+
"metadata": {"session_id": "abc123"},
24+
}
25+
base.update(overrides)
26+
return base
27+
28+
29+
class TestValidateSessionDict:
30+
def test_missing_session_id(self):
31+
payload = _valid_payload()
32+
del payload["session_id"]
33+
with pytest.raises(SessionValidationError) as exc_info:
34+
validate_session_dict(payload)
35+
assert exc_info.value.path == "session_id"
36+
37+
def test_wrong_type_session_id(self):
38+
with pytest.raises(SessionValidationError) as exc_info:
39+
validate_session_dict(_valid_payload(session_id=123))
40+
assert exc_info.value.path == "session_id"
41+
42+
def test_null_session_id(self):
43+
with pytest.raises(SessionValidationError) as exc_info:
44+
validate_session_dict(_valid_payload(session_id=None))
45+
assert exc_info.value.path == "session_id"
46+
assert exc_info.value.detail == "must not be null"
47+
48+
def test_null_role_in_message(self):
49+
with pytest.raises(SessionValidationError) as exc_info:
50+
validate_session_dict(
51+
_valid_payload(messages=[{"role": None, "text": "x"}])
52+
)
53+
assert exc_info.value.path == "messages[0].role"
54+
55+
def test_missing_role_in_message(self):
56+
with pytest.raises(SessionValidationError) as exc_info:
57+
validate_session_dict(
58+
_valid_payload(messages=[{"text": "no role key"}])
59+
)
60+
assert exc_info.value.path == "messages[0].role"
61+
62+
def test_metadata_not_dict(self):
63+
with pytest.raises(SessionValidationError) as exc_info:
64+
validate_session_dict(_valid_payload(metadata="not-a-dict"))
65+
assert exc_info.value.path == "metadata"
66+
67+
def test_message_not_dict(self):
68+
with pytest.raises(SessionValidationError) as exc_info:
69+
validate_session_dict(_valid_payload(messages=["not-a-dict"]))
70+
assert exc_info.value.path == "messages[0]"
71+
72+
def test_valid_payload_returns_session_dict(self):
73+
result = validate_session_dict(_valid_payload())
74+
assert result["session_id"] == "abc123"
75+
assert result["messages"][0]["role"] == "user"
76+
77+
78+
class TestParseSessionValidationRegression:
79+
def test_session_minimal_fixture_unchanged(self):
80+
path = os.path.join(FIXTURES, "session_minimal.jsonl")
81+
session = parse_session(path)
82+
assert session["session_id"] == "session_minimal"
83+
assert session["title"] == "Hello from integration fixture"
84+
assert len(session["messages"]) == 2
85+
assert session["messages"][0]["role"] == "user"
86+
assert session["messages"][1]["role"] == "assistant"

utils/jsonl_parser.py

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -4,9 +4,10 @@
44
import json
55
import os
66
from datetime import datetime
7-
from typing import Any, cast
7+
from typing import Any
88

99
from models.session import MessageDict, QuickSessionInfoDict, SessionDict
10+
from utils.validation import validate_session_dict
1011

1112

1213
def parse_session(filepath: str) -> SessionDict:
@@ -120,14 +121,13 @@ def parse_session(filepath: str) -> SessionDict:
120121

121122
title = _infer_title(messages)
122123

123-
return cast(
124-
SessionDict,
124+
return validate_session_dict(
125125
{
126126
"session_id": session_id,
127127
"title": title,
128128
"messages": messages,
129129
"metadata": metadata,
130-
},
130+
}
131131
)
132132

133133

utils/validation.py

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
"""Runtime validation for TypedDict shapes at untrusted-data boundaries."""
2+
3+
from typing import Any, cast
4+
5+
from models.errors import SessionValidationError
6+
from models.session import SessionDict
7+
8+
_ROOT_PATH = "(root)"
9+
10+
11+
def _require_field(
12+
container: dict[str, Any],
13+
key: str,
14+
expected_type: type[Any],
15+
type_label: str,
16+
*,
17+
path: str | None = None,
18+
) -> Any:
19+
field_path = path or key
20+
if key not in container:
21+
raise SessionValidationError(field_path, "missing required field")
22+
return _require_value(field_path, container[key], expected_type, type_label)
23+
24+
25+
def _require_value(
26+
path: str,
27+
val: Any,
28+
expected_type: type[Any],
29+
type_label: str,
30+
) -> Any:
31+
if val is None:
32+
raise SessionValidationError(path, "must not be null")
33+
if not isinstance(val, expected_type):
34+
raise SessionValidationError(
35+
path, f"expected {type_label}, got {type(val).__name__}"
36+
)
37+
return val
38+
39+
40+
def validate_session_dict(data: dict[str, Any]) -> SessionDict:
41+
"""Validate a plain dict matches SessionDict before returning it."""
42+
# Runtime guard for dynamic callers; mypy already types the parameter as dict.
43+
if not isinstance(data, dict):
44+
raise SessionValidationError(_ROOT_PATH, "expected dict")
45+
46+
_require_field(data, "session_id", str, "str")
47+
_require_field(data, "title", str, "str")
48+
messages = _require_field(data, "messages", list, "list")
49+
_require_field(data, "metadata", dict, "dict")
50+
51+
for index, message in enumerate(messages):
52+
path = f"messages[{index}]"
53+
msg_dict = _require_value(path, message, dict, "dict")
54+
_require_field(msg_dict, "role", str, "str", path=f"{path}.role")
55+
56+
return cast(SessionDict, data)

0 commit comments

Comments
 (0)