Skip to content

Commit 03c495a

Browse files
committed
Harden ANSI escape sanitization
Add OSC and CSI stripping with tests
1 parent 8351f24 commit 03c495a

File tree

2 files changed

+50
-0
lines changed

2 files changed

+50
-0
lines changed

src/claude_code_transcripts/__init__.py

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,23 @@ def get_template(name):
4848
300 # Characters - text blocks longer than this are shown in index
4949
)
5050

51+
# Regex to strip ANSI escape sequences from terminal output
52+
ANSI_ESCAPE_PATTERN = re.compile(
53+
r"""
54+
\x1b(?:\].*?(?:\x07|\x1b\\) # OSC sequences
55+
|\[[0-?]*[ -/]*[@-~] # CSI sequences
56+
|[@-Z\\-_]) # 7-bit C1 control codes
57+
""",
58+
re.VERBOSE | re.DOTALL,
59+
)
60+
61+
62+
def strip_ansi(text):
63+
"""Strip ANSI escape sequences from terminal output."""
64+
if not text:
65+
return text
66+
return ANSI_ESCAPE_PATTERN.sub("", text)
67+
5168

5269
def extract_text_from_content(content):
5370
"""Extract plain text from message content.
@@ -711,6 +728,7 @@ def render_content_block(block):
711728

712729
# Check for git commits and render with styled cards
713730
if isinstance(content, str):
731+
content = strip_ansi(content)
714732
commits_found = list(COMMIT_PATTERN.finditer(content))
715733
if commits_found:
716734
# Build commit cards + remaining content
@@ -2071,4 +2089,5 @@ def on_progress(project_name, session_name, current, total):
20712089

20722090

20732091
def main():
2092+
# print("RUNNING LOCAL VERSION!!")
20742093
cli()

tests/test_generate_html.py

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@
1818
render_edit_tool,
1919
render_bash_tool,
2020
render_content_block,
21+
strip_ansi,
2122
analyze_conversation,
2223
format_tool_stats,
2324
is_tool_result_message,
@@ -284,6 +285,21 @@ def test_tool_result_error(self, snapshot_html):
284285
result = render_content_block(block)
285286
assert result == snapshot_html
286287

288+
def test_tool_result_with_ansi_codes(self):
289+
"""Test that ANSI escape codes are stripped from tool results."""
290+
block = {
291+
"type": "tool_result",
292+
"content": "\x1b[38;2;166;172;186mTests passed:\x1b[0m \x1b[32m✓\x1b[0m All 5 tests passed\n\x1b[1;31mError:\x1b[0m None",
293+
"is_error": False,
294+
}
295+
result = render_content_block(block)
296+
assert "\x1b[" not in result
297+
assert "[38;2;" not in result
298+
assert "[32m" not in result
299+
assert "[0m" not in result
300+
assert "Tests passed:" in result
301+
assert "All 5 tests passed" in result
302+
287303
def test_tool_result_with_commit(self, snapshot_html):
288304
"""Test tool result with git commit output."""
289305
# Need to set the global _github_repo for commit link rendering
@@ -303,6 +319,21 @@ def test_tool_result_with_commit(self, snapshot_html):
303319
claude_code_transcripts._github_repo = old_repo
304320

305321

322+
class TestStripAnsi:
323+
"""Tests for ANSI escape stripping."""
324+
325+
def test_strips_csi_sequences(self):
326+
text = "start\x1b[?25hend\x1b[2Jdone"
327+
assert strip_ansi(text) == "startenddone"
328+
329+
def test_strips_osc_sequences(self):
330+
text = "title\x1b]0;My Title\x07end"
331+
assert strip_ansi(text) == "titleend"
332+
333+
def test_strips_osc_st_terminator(self):
334+
text = "name\x1b]0;Title\x1b\\end"
335+
assert strip_ansi(text) == "nameend"
336+
306337
class TestAnalyzeConversation:
307338
"""Tests for conversation analysis."""
308339

0 commit comments

Comments
 (0)