Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
140 changes: 140 additions & 0 deletions scripts/audit-user-facing-errors.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,140 @@
#!/usr/bin/env python3
"""Audit likely user-facing Rust error surfaces.

Athen exposes errors through several external surfaces: the desktop UI,
Telegram replies, notifications, and emitted frontend events. Raw `{e}` /
`to_string()` formatting is sometimes fine for internal logs, but user-facing
paths should prefer `AthenError::user_safe_message()` where the value may come
from providers, tools, shells, MCP servers, or third-party APIs.

This script is intentionally advisory. It prints candidates for review instead
of failing CI, because not every raw error formatting site is user-facing and
not every error type is `AthenError`.
"""

from __future__ import annotations

import re
import sys
from dataclasses import dataclass
from pathlib import Path

ROOT = Path(__file__).resolve().parents[1]
CRATES = ROOT / "crates"

RUST_EXTENSIONS = {".rs"}

SURFACE_HINTS = (
"send_telegram_reply",
"emit(",
"emit_all",
"Notification",
"toast",
"frontend",
"user-facing",
"user facing",
"format_user_error",
"simplify_error",
"to_frontend",
"reply",
)

RAW_ERROR_PATTERNS: tuple[tuple[str, re.Pattern[str]], ...] = (
("format interpolation of error", re.compile(r'format!\([^\n;]*\{e(?::[^}]*)?\}')),
("direct error string conversion", re.compile(r'\b[a-zA-Z_][a-zA-Z0-9_]*\.to_string\(\)')),
("error passed through anyhow/debug string", re.compile(r'format!\([^\n;]*(error|err|failed)', re.IGNORECASE)),
)

LOG_ONLY_HINTS = (
"tracing::",
"log::",
"debug!",
"warn!",
"error!",
"info!",
)


@dataclass(frozen=True)
class Finding:
path: Path
line: int
kind: str
text: str
context: str


def iter_rust_files() -> list[Path]:
return sorted(
path
for path in CRATES.rglob("*.rs")
if path.is_file() and path.suffix.lower() in RUST_EXTENSIONS
)


def has_surface_hint(window: str) -> bool:
lowered = window.lower()
return any(hint.lower() in lowered for hint in SURFACE_HINTS)


def is_log_only(line: str) -> bool:
return any(hint in line for hint in LOG_ONLY_HINTS)


def scan_file(path: Path) -> list[Finding]:
lines = path.read_text(encoding="utf-8").splitlines()
findings: list[Finding] = []

for idx, line in enumerate(lines):
if is_log_only(line):
continue

start = max(0, idx - 4)
end = min(len(lines), idx + 5)
window = "\n".join(lines[start:end])

if not has_surface_hint(window):
continue

for kind, pattern in RAW_ERROR_PATTERNS:
if pattern.search(line):
findings.append(
Finding(
path=path,
line=idx + 1,
kind=kind,
text=line.strip(),
context=" | ".join(s.strip() for s in lines[start:end] if s.strip()),
)
)

return findings


def main() -> int:
if not CRATES.exists():
print(f"crates directory not found: {CRATES}", file=sys.stderr)
return 2

findings = [finding for path in iter_rust_files() for finding in scan_file(path)]

if not findings:
print("No likely user-facing raw error surfaces found.")
return 0

print("Likely user-facing raw error surfaces found for review:\n")
for finding in findings:
rel = finding.path.relative_to(ROOT)
print(f"- {rel}:{finding.line}: {finding.kind}")
print(f" {finding.text}")
print(" Consider using `AthenError::user_safe_message()` or redacting before display.")

print(
"\nThis audit is advisory and exits successfully. Review these call sites "
"before making the check blocking."
)
return 0


if __name__ == "__main__":
raise SystemExit(main())
140 changes: 140 additions & 0 deletions scripts/check-csp-regressions.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,140 @@
#!/usr/bin/env python3
"""Fail CI when frontend code reintroduces CSP-hostile patterns.

Athen's Tauri WebView uses a CSP with `script-src 'self'`. Inline event
handlers and `javascript:` URLs are incompatible with that policy and can
silently break UI controls. They also make it harder to reason about injected
HTML rendered inside the app shell.

This checker intentionally focuses on high-signal patterns instead of trying
to be a full JavaScript/HTML linter.
"""

from __future__ import annotations

import re
import sys
from dataclasses import dataclass
from pathlib import Path

ROOT = Path(__file__).resolve().parents[1]
FRONTEND = ROOT / "frontend"

TEXT_EXTENSIONS = {".html", ".js", ".css"}

CHECKS: tuple[tuple[str, re.Pattern[str], str], ...] = (
(
"inline event handler",
re.compile(r"\son[a-zA-Z]+\s*=", re.IGNORECASE),
"Move event wiring to addEventListener or delegated listeners.",
),
(
"javascript: URL",
re.compile(r"javascript\s*:", re.IGNORECASE),
"Use a real event listener instead of javascript: URLs.",
),
(
"eval call",
re.compile(r"\beval\s*\(", re.IGNORECASE),
"Avoid eval; use explicit parsing or dispatch tables.",
),
(
"Function constructor",
re.compile(r"\bnew\s+Function\s*\(", re.IGNORECASE),
"Avoid dynamic code generation in the WebView.",
),
(
"string timer callback",
re.compile(r"\bset(?:Timeout|Interval)\s*\(\s*['\"]", re.IGNORECASE),
"Pass a function to timers, not a string to evaluate.",
),
)

INLINE_SCRIPT_RE = re.compile(
r"<script\b(?![^>]*\bsrc\s*=)[^>]*>(?P<body>.*?)</script\s*>",
re.IGNORECASE | re.DOTALL,
)


@dataclass(frozen=True)
class Finding:
path: Path
line: int
kind: str
snippet: str
guidance: str


def iter_frontend_files() -> list[Path]:
return sorted(
path
for path in FRONTEND.rglob("*")
if path.is_file() and path.suffix.lower() in TEXT_EXTENSIONS
)


def line_for_offset(text: str, offset: int) -> int:
return text.count("\n", 0, offset) + 1


def scan_file(path: Path) -> list[Finding]:
text = path.read_text(encoding="utf-8")
findings: list[Finding] = []

for kind, pattern, guidance in CHECKS:
for match in pattern.finditer(text):
findings.append(
Finding(
path=path,
line=line_for_offset(text, match.start()),
kind=kind,
snippet=text[match.start() : match.end()].strip(),
guidance=guidance,
)
)

if path.suffix.lower() == ".html":
for match in INLINE_SCRIPT_RE.finditer(text):
body = match.group("body").strip()
if not body:
continue
findings.append(
Finding(
path=path,
line=line_for_offset(text, match.start()),
kind="inline script block",
snippet="<script>…</script>",
guidance="Move inline scripts into frontend/app.js or another self-hosted JS file.",
)
)

return findings


def main() -> int:
if not FRONTEND.exists():
print(f"frontend directory not found: {FRONTEND}", file=sys.stderr)
return 2

findings = [finding for path in iter_frontend_files() for finding in scan_file(path)]

if not findings:
print("No CSP-hostile frontend patterns found.")
return 0

print("CSP regression check failed. Found CSP-hostile frontend patterns:\n")
for finding in findings:
rel = finding.path.relative_to(ROOT)
print(f"- {rel}:{finding.line}: {finding.kind}: {finding.snippet!r}")
print(f" {finding.guidance}")

print(
"\nAthen's Tauri CSP uses script-src 'self', so inline handlers, "
"javascript: URLs, and dynamic code execution should stay out of "
"the WebView surface."
)
return 1


if __name__ == "__main__":
raise SystemExit(main())
Loading