diff --git a/agentprof/report/runner.py b/agentprof/report/runner.py
index 0f7caf6..6b5c703 100644
--- a/agentprof/report/runner.py
+++ b/agentprof/report/runner.py
@@ -23,6 +23,9 @@
DEFAULT_REPORT_DIR = APP_DIR / "reports"
REPORT_ID_MAX_LENGTH = 128
REPORT_ID_RE = re.compile(r"[A-Za-z0-9][A-Za-z0-9._-]*")
+MARKDOWN_ESCAPE_RE = re.compile(r"([\\`*\[\]()!|])")
+MARKDOWN_AUTO_LINK_RE = re.compile(r"(?i)\b((?:https?|ftp)://)")
+MARKDOWN_WWW_LINK_RE = re.compile(r"(?i)\bwww\.")
MULTI_AGENT_WASTE_KIND = "multi_agent_waste"
@@ -217,10 +220,10 @@ def _cost_to_json(record: CostLedgerRecord) -> dict[str, Any]:
def _markdown_report(payload: dict[str, Any]) -> str:
summary = payload["summary"]
lines = [
- f"# AgentProf Report: {payload['project']}",
+ f"# AgentProf Report: {_markdown_text(payload['project'])}",
"",
- f"Report ID: `{payload['report_id']}`",
- f"Generated at: `{payload['generated_at']}`",
+ f"Report ID: {_markdown_inline_code(payload['report_id'])}",
+ f"Generated at: {_markdown_inline_code(payload['generated_at'])}",
"",
"## Summary",
"",
@@ -241,7 +244,7 @@ def _markdown_report(payload: dict[str, Any]) -> str:
[
"## Visuals",
"",
- f"",
+ f"})",
"",
]
)
@@ -266,10 +269,10 @@ def _markdown_report(payload: dict[str, Any]) -> str:
for record in payload["cost_ledger"]:
lines.append(
"| "
- f"{record['cost_type']} | "
+ f"{_markdown_table_cell(record['cost_type'])} | "
f"{_format_usd(record['amount_usd'])} | "
- f"{record['attribution_method']} | "
- f"{record['issue_id'] or ''} |"
+ f"{_markdown_table_cell(record['attribution_method'])} | "
+ f"{_markdown_table_cell(record['issue_id'] or '')} |"
)
lines.append("")
@@ -278,23 +281,23 @@ def _markdown_report(payload: dict[str, Any]) -> str:
def _markdown_issue(issue: dict[str, Any]) -> list[str]:
lines = [
- f"### {issue['title']}",
+ f"### {_markdown_text(issue['title'])}",
"",
- f"- Issue ID: `{issue['issue_id']}`",
- f"- Kind: `{issue['kind']}`",
- f"- Severity: `{issue['severity']}`",
- f"- Confidence: `{issue['confidence']}`",
+ f"- Issue ID: {_markdown_inline_code(issue['issue_id'])}",
+ f"- Kind: {_markdown_inline_code(issue['kind'])}",
+ f"- Severity: {_markdown_inline_code(issue['severity'])}",
+ f"- Confidence: {_markdown_inline_code(issue['confidence'])}",
f"- Affected traces: {issue['affected_traces']}",
f"- Affected spans: {issue['affected_spans']}",
f"- Wasted cost: {_format_usd(issue['wasted_cost_usd'])}",
f"- Potential savings: {_format_usd(issue['potential_savings_usd'])}",
"",
- f"Recommendation: {issue['recommendation']}",
+ f"Recommendation: {_markdown_text(issue['recommendation'])}",
"",
]
if issue["recommended_tests"]:
lines.extend(["Recommended tests:", ""])
- lines.extend(f"- {test}" for test in issue["recommended_tests"])
+ lines.extend(f"- {_markdown_text(test)}" for test in issue["recommended_tests"])
lines.append("")
if issue["evidence"]:
lines.extend(["Evidence:", ""])
@@ -302,7 +305,10 @@ def _markdown_issue(issue: dict[str, Any]) -> list[str]:
location = ":".join(
part for part in (item["trace_id"], item["span_id"]) if part
)
- lines.append(f"- `{location}` {item['message']}")
+ lines.append(
+ f"- {_markdown_inline_code(location or 'unknown')} "
+ f"{_markdown_text(item['message'])}"
+ )
lines.append("")
return lines
@@ -444,6 +450,53 @@ def _agent_label(visual: dict[str, Any]) -> str:
return str(count)
+def _markdown_text(value: Any) -> str:
+ return _escape_markdown(_single_line(value))
+
+
+def _markdown_table_cell(value: Any) -> str:
+ return _markdown_text(value)
+
+
+def _markdown_inline_code(value: Any) -> str:
+ text = _single_line(value)
+ max_backticks = max(
+ (len(match.group(0)) for match in re.finditer(r"`+", text)),
+ default=0,
+ )
+ marker = "`" * (max_backticks + 1)
+ padding = " " if text.startswith("`") or text.endswith("`") else ""
+ return f"{marker}{padding}{text}{padding}{marker}"
+
+
+def _markdown_link_target(value: Any) -> str:
+ text = _single_line(value)
+ return (
+ text.replace("\\", "%5C")
+ .replace(" ", "%20")
+ .replace("(", "%28")
+ .replace(")", "%29")
+ )
+
+
+def _escape_markdown(value: str) -> str:
+ text = escape(value, quote=False)
+ text = MARKDOWN_ESCAPE_RE.sub(r"\\\1", text)
+ text = MARKDOWN_AUTO_LINK_RE.sub(
+ lambda match: match.group(1).replace(":", ":"),
+ text,
+ )
+ text = MARKDOWN_WWW_LINK_RE.sub(
+ lambda match: match.group(0).replace(".", "."),
+ text,
+ )
+ return text
+
+
+def _single_line(value: Any) -> str:
+ return "" if value is None else " ".join(str(value).split())
+
+
def _default_report_id(generated_at: datetime) -> str:
return f"agentprof-{generated_at.astimezone(UTC).strftime('%Y%m%dT%H%M%S%fZ')}"
diff --git a/tests/test_report.py b/tests/test_report.py
index 6a08b73..6a76b98 100644
--- a/tests/test_report.py
+++ b/tests/test_report.py
@@ -230,6 +230,37 @@ def test_generate_report_handles_empty_analysis_results(tmp_path: Path) -> None:
assert store.stats()["reports"] == 1
+def test_generate_report_escapes_markdown_injection(tmp_path: Path) -> None:
+ store = DuckDBStore(tmp_path / "agentprof.duckdb")
+ _seed_markdown_injection_issue(store)
+
+ result = generate_report(
+ store,
+ project="Tracer [link](http://project.example)",
+ output_dir=tmp_path / "reports",
+ report_id="escaped-report",
+ generated_at=_dt("2026-04-26T12:00:00+00:00"),
+ )
+ markdown = result.report_md_path.read_text(encoding="utf-8")
+
+ assert "" not in markdown
+ assert "",
+ severity="medium",
+ confidence="high",
+ first_seen=_dt("2026-04-26T10:00:00+00:00"),
+ last_seen=_dt("2026-04-26T10:00:02+00:00"),
+ affected_traces=1,
+ affected_spans=1,
+ total_cost_usd=Decimal("0.030"),
+ wasted_cost_usd=Decimal("0.020"),
+ potential_savings_usd=Decimal("0.020"),
+ recommendation="\n[evil]: http://evil.example",
+ recommended_tests=["[click me](https://test.example) www.test.example `danger`"],
+ )
+ evidence = IssueEvidenceRecord(
+ issue_id=issue.issue_id,
+ trace_id="trace`id",
+ span_id="span`id",
+ evidence_type="retry_attempt",
+ message="`code` \n[ref]: http://evil.example
",
+ attributes={},
+ )
+ cost = CostLedgerRecord(
+ trace_id="trace`id",
+ span_id="span`id",
+ issue_id=issue.issue_id,
+ cost_type="wasted|retry\n[ref]: http://evil.example",
+ amount_usd=Decimal("0.020"),
+ attribution_method="retry_loop](http://evil.example)",
+ confidence="source",
+ )
+ store.replace_analysis_results(
+ issue_kind="retry_loop",
+ attribution_method="retry_loop](http://evil.example)",
+ issues=[issue],
+ evidence=[evidence],
+ cost_records=[cost],
+ )
+
+
def _dt(value: str) -> datetime:
return datetime.fromisoformat(value).astimezone(UTC)