Sanitize print logs; refactor popular plugin logic

Ensure printed output and HTML logs are safe by importing and applying sanitize_string, opening log files with utf-8 and errors='replace', and sanitizing text before writing. Add tests to verify lone surrogate characters are replaced and that logging won't crash on invalid Unicode. In the plugin installer UI, introduce POPULAR_PLUGIN_MIN_STARS and centralize popularity checking in _isPopularPlugin, using it for filtering and counts.

frdel committed Mar 30, 2026 at 11:50 UTC 44e008745d829201482c0dbb16b9491d9341b0ec
6 files changed +272 -11
helpers/print_style.py
+7 -4
@@ -3,6 +3,7 @@ import sys
3 from datetime import datetime
4 from collections.abc import Mapping
5 from . import files
6 +from .strings import sanitize_string
7
8 _runtime_module = None
9
@@ -34,7 +35,7 @@ class PrintStyle:
35 os.makedirs(logs_dir, exist_ok=True)
36 log_filename = datetime.now().strftime("log_%Y%m%d_%H%M%S.html")
37 PrintStyle.log_file_path = os.path.join(logs_dir, log_filename)
37 - with open(PrintStyle.log_file_path, "w") as f:
38 + with open(PrintStyle.log_file_path, "w", encoding="utf-8", errors="replace") as f:
39 f.write("<html><body style='background-color:black;font-family: Arial, Helvetica, sans-serif;'><pre>\n")
40
41 def _get_rgb_color_code(self, color, is_background=False):
@@ -93,13 +94,13 @@ class PrintStyle:
94 self.padding_added = True
95
96 def _log_html(self, html):
96 - with open(PrintStyle.log_file_path, "a", encoding='utf-8') as f: # type: ignore # add encoding='utf-8'
97 - f.write(html)
97 + with open(PrintStyle.log_file_path, "a", encoding="utf-8", errors="replace") as f: # type: ignore[arg-type]
98 + f.write(sanitize_string(html))
99
100 @staticmethod
101 def _close_html_log():
102 if PrintStyle.log_file_path:
102 - with open(PrintStyle.log_file_path, "a") as f:
103 + with open(PrintStyle.log_file_path, "a", encoding="utf-8", errors="replace") as f:
104 f.write("</pre></body></html>")
105
106 @staticmethod
@@ -145,6 +146,8 @@ class PrintStyle:
146 # If masking fails, proceed without masking to avoid breaking functionality
147 pass
148
149 + text = sanitize_string(text)
150 +
151 return text, self._get_styled_text(text), self._get_html_styled_text(text)
152
153 def print(self, *args, sep=' ', end='\n', flush=True):
plugins/_plugin_installer/webui/install-detail.html
+85
@@ -25,6 +25,12 @@
25 <div class="pi-hero-main">
26 <h2 class="pi-hero-title" x-text="$store.pluginInstallStore.selectedPlugin.title || $store.pluginInstallStore.selectedPlugin.key"></h2>
27 <div class="pi-status-badges">
28 + <template x-if="$store.pluginInstallStore.selectedPlugin.suspended">
29 + <span class="pi-card-suspended-pill">
30 + <span class="material-symbols-outlined">priority_high</span>
31 + <span>Suspended</span>
32 + </span>
33 + </template>
34 <template x-if="$store.pluginInstallStore.selectedPlugin.installed">
35 <span class="pi-card-installed-pill">Installed</span>
36 </template>
@@ -100,6 +106,17 @@
106 </div>
107 </div>
108
109 + <template x-if="$store.pluginInstallStore.selectedPlugin.suspended">
110 + <div class="pi-suspension-banner">
111 + <span class="material-symbols-outlined">priority_high</span>
112 + <div class="pi-suspension-banner-copy">
113 + <div class="pi-suspension-banner-title">Plugin has been suspended for the following reasons:</div>
114 + <div class="pi-suspension-banner-explanation"
115 + x-text="$store.pluginInstallStore.selectedPlugin.suspended"></div>
116 + </div>
117 + </div>
118 + </template>
119 +
120 <div class="pi-description" x-text="$store.pluginInstallStore.selectedPlugin.description || 'No description available.'"></div>
121
122 <div class="pi-screenshots-section"
@@ -435,6 +452,27 @@
452 color: #1d4ed8;
453 }
454
455 + .pi-card-suspended-pill {
456 + display: inline-flex;
457 + align-items: center;
458 + gap: 0.3rem;
459 + padding: 0.24rem 0.5rem;
460 + border-radius: 0.5rem;
461 + background: rgba(245, 158, 11, 0.16);
462 + color: #f59e0b;
463 + font-size: 0.72rem;
464 + font-weight: 700;
465 + }
466 +
467 + .pi-card-suspended-pill .material-symbols-outlined {
468 + font-size: 0.95rem;
469 + }
470 +
471 + body.light-mode .pi-card-suspended-pill {
472 + background: rgba(245, 158, 11, 0.2);
473 + color: #b45309;
474 + }
475 +
476 .pi-status-badges .pi-card-installed-pill {
477 position: static;
478 top: auto;
@@ -447,6 +485,12 @@
485 right: auto;
486 }
487
488 + .pi-status-badges .pi-card-suspended-pill {
489 + position: static;
490 + top: auto;
491 + right: auto;
492 + }
493 +
494 .pi-tag {
495 display: inline-flex;
496 align-items: center;
@@ -466,6 +510,47 @@
510 margin-bottom: 1.5rem;
511 }
512
513 + .pi-suspension-banner {
514 + display: flex;
515 + align-items: flex-start;
516 + gap: 0.75rem;
517 + margin-bottom: 1rem;
518 + padding: 0.9rem 1rem;
519 + border: 1px solid rgba(245, 158, 11, 0.32);
520 + border-radius: 10px;
521 + background: rgba(245, 158, 11, 0.12);
522 + color: #f59e0b;
523 + }
524 +
525 + .pi-suspension-banner .material-symbols-outlined {
526 + font-size: 1.2rem;
527 + line-height: 1.2;
528 + flex-shrink: 0;
529 + }
530 +
531 + .pi-suspension-banner-copy {
532 + min-width: 0;
533 + }
534 +
535 + .pi-suspension-banner-title {
536 + font-size: 0.92rem;
537 + font-weight: 700;
538 + line-height: 1.45;
539 + }
540 +
541 + .pi-suspension-banner-explanation {
542 + margin-top: 0.2rem;
543 + font-size: 0.92rem;
544 + line-height: 1.55;
545 + white-space: pre-line;
546 + }
547 +
548 + body.light-mode .pi-suspension-banner {
549 + border-color: rgba(217, 119, 6, 0.28);
550 + background: rgba(245, 158, 11, 0.14);
551 + color: #b45309;
552 + }
553 +
554 .pi-actions-primary {
555 display: flex;
556 gap: 0.75rem;
plugins/_plugin_installer/webui/install-index.html
+29
@@ -124,6 +124,12 @@
124 </div>
125
126 <div class="pi-card-bubbles pi-card-bubbles-status">
127 + <template x-if="plugin.suspended">
128 + <span class="pi-card-bubble pi-card-bubble-suspended">
129 + <span class="material-symbols-outlined">priority_high</span>
130 + <span class="pi-card-bubble-text">Suspended</span>
131 + </span>
132 + </template>
133 <template x-if="plugin.installed">
134 <span class="pi-card-bubble pi-card-bubble-installed">
135 <span class="material-symbols-outlined">check_circle</span>
@@ -641,6 +647,19 @@
647 border-color: rgba(59, 130, 246, 0.3);
648 }
649
650 + .pi-card-bubble-suspended .material-symbols-outlined {
651 + color: #f59e0b;
652 + }
653 +
654 + .pi-card-bubble-suspended .pi-card-bubble-text {
655 + color: #f59e0b;
656 + }
657 +
658 + .pi-card-bubble-suspended:hover {
659 + background: rgba(245, 158, 11, 0.12);
660 + border-color: rgba(245, 158, 11, 0.35);
661 + }
662 +
663 body.light-mode .pi-card-bubble-installed .material-symbols-outlined,
664 body.light-mode .pi-card-bubble-installed .pi-card-bubble-text {
665 color: #166534;
@@ -661,6 +680,16 @@
680 border-color: rgba(59, 130, 246, 0.35);
681 }
682
683 + body.light-mode .pi-card-bubble-suspended .material-symbols-outlined,
684 + body.light-mode .pi-card-bubble-suspended .pi-card-bubble-text {
685 + color: #b45309;
686 + }
687 +
688 + body.light-mode .pi-card-bubble-suspended:hover {
689 + background: rgba(245, 158, 11, 0.16);
690 + border-color: rgba(217, 119, 6, 0.35);
691 + }
692 +
693 .pi-pagination {
694 display: flex;
695 align-items: center;
plugins/_plugin_installer/webui/pluginInstallStore.js
+33 -3
@@ -11,6 +11,7 @@ import { store as pluginSettingsStore } from "/components/plugins/plugin-setting
11
12 const PLUGIN_API = "plugins/_plugin_installer/plugin_install";
13 const PER_PAGE = 24;
14 +const POPULAR_PLUGIN_MIN_STARS = 3;
15
16 const SECURITY_WARNING = {
17 title: "Security Warning",
@@ -93,11 +94,23 @@ const model = {
94 .join(" ");
95 },
96
97 + _isPopularPlugin(plugin) {
98 + return (plugin?.stars || 0) >= POPULAR_PLUGIN_MIN_STARS;
99 + },
100 +
101 + _getSuspensionReason(plugin) {
102 + return typeof plugin?.suspended === "string" ? plugin.suspended.trim() : "";
103 + },
104 +
105 + isPluginSuspended(plugin) {
106 + return !!this._getSuspensionReason(plugin);
107 + },
108 +
109 _matchesBrowseFilter(plugin, filterKey) {
110 if (!filterKey || filterKey === "all") return true;
111 if (filterKey === "installed") return !!plugin?.installed;
112 if (filterKey === "update") return !!plugin?.has_update;
100 - if (filterKey === "popular") return (plugin?.stars || 0) >= 3;
113 + if (filterKey === "popular") return this._isPopularPlugin(plugin);
114 if (filterKey.startsWith("tag:")) {
115 return this._pluginPrimaryTag(plugin) === filterKey.slice(4);
116 }
@@ -126,6 +139,22 @@ const model = {
139 return true;
140 },
141
142 + _comparePluginsByStars(a, b) {
143 + const aSuspended = this.isPluginSuspended(a);
144 + const bSuspended = this.isPluginSuspended(b);
145 + if (aSuspended !== bSuspended) {
146 + return aSuspended ? 1 : -1;
147 + }
148 +
149 + const aStars = aSuspended ? 0 : Number(a?.stars) || 0;
150 + const bStars = bSuspended ? 0 : Number(b?.stars) || 0;
151 + if (aStars !== bStars) {
152 + return bStars - aStars;
153 + }
154 +
155 + return (a.title || a.key).localeCompare(b.title || b.key);
156 + },
157 +
158 // ── ZIP Install ──────────────────────────────
159
160 handleFileUpload(event) {
@@ -341,6 +370,7 @@ const model = {
370 commit: val?.commit || val?.latest_commit || "",
371 updated: val?.updated || val?.latest_commit_timestamp || "",
372 version: val?.version || "",
373 + suspended: this._getSuspensionReason(val),
374 installed,
375 };
376
@@ -365,7 +395,7 @@ const model = {
395 const updateCount = plugins.filter((plugin) => plugin.has_update).length;
396 filters.push({ key: "update", label: "Update", count: updateCount });
397
368 - const popularCount = plugins.filter((plugin) => (plugin.stars || 0) > 0).length;
398 + const popularCount = plugins.filter((plugin) => this._isPopularPlugin(plugin)).length;
399 if (popularCount) {
400 filters.push({ key: "popular", label: "Popular", count: popularCount });
401 }
@@ -406,7 +436,7 @@ const model = {
436 );
437 }
438 if (this.sortBy === "stars") {
409 - list.sort((a, b) => (b.stars || 0) - (a.stars || 0));
439 + list.sort((a, b) => this._comparePluginsByStars(a, b));
440 } else {
441 list.sort((a, b) =>
442 (a.title || a.key).localeCompare(b.title || b.key)
tests/test_print_style.py new
+52
@@ -0,0 +1,52 @@
1 +import sys
2 +from pathlib import Path
3 +
4 +import pytest
5 +
6 +PROJECT_ROOT = Path(__file__).resolve().parents[1]
7 +if str(PROJECT_ROOT) not in sys.path:
8 + sys.path.insert(0, str(PROJECT_ROOT))
9 +
10 +from helpers.print_style import PrintStyle
11 +
12 +
13 +class _PassthroughSecretsManager:
14 + def mask_values(self, text: str) -> str:
15 + return text
16 +
17 +
18 +@pytest.fixture(autouse=True)
19 +def _reset_print_style_state():
20 + PrintStyle.log_file_path = None
21 + PrintStyle.last_endline = True
22 + yield
23 + PrintStyle.log_file_path = None
24 + PrintStyle.last_endline = True
25 +
26 +
27 +def test_get_sanitizes_lone_surrogates(tmp_path, monkeypatch):
28 + monkeypatch.setattr("helpers.print_style.files.get_abs_path", lambda _: str(tmp_path))
29 +
30 + style = PrintStyle(log_only=True)
31 + style.secrets_mgr = _PassthroughSecretsManager()
32 +
33 + plain_text, styled_text, html_text = style.get("bad \ud83d")
34 +
35 + assert plain_text == "bad ?"
36 + assert "\ud83d" not in styled_text
37 + assert "\ud83d" not in html_text
38 +
39 +
40 +def test_print_writes_html_log_without_surrogate_crash(tmp_path, monkeypatch):
41 + monkeypatch.setattr("helpers.print_style.files.get_abs_path", lambda _: str(tmp_path))
42 +
43 + style = PrintStyle(log_only=True)
44 + style.secrets_mgr = _PassthroughSecretsManager()
45 +
46 + style.print("bad \ud83d")
47 +
48 + log_path = Path(PrintStyle.log_file_path)
49 + content = log_path.read_text(encoding="utf-8")
50 +
51 + assert "bad ?" in content
52 + assert "\ud83d" not in content
tools/skills_tool.py
+66 -4
@@ -6,6 +6,7 @@ from typing import List
6 from helpers.tool import Tool, Response
7 from helpers import projects, files, file_tree
8 from helpers import skills as skills_helper, runtime
9 +from helpers.print_style import PrintStyle
10
11
12 DATA_NAME_LOADED_SKILLS = "loaded_skills"
@@ -24,6 +25,67 @@ class SkillsTool(Tool):
25 Script execution is handled by code_execution_tool directly.
26 """
27
28 + def _current_method(self) -> str:
29 + return (
30 + (self.args.get("method") or self.method or "")
31 + .strip()
32 + .lower()
33 + )
34 +
35 + @staticmethod
36 + def _normalize_skill_name(skill_name: str) -> str:
37 + skill_name = skill_name.strip()
38 + if skill_name.startswith("**") and skill_name.endswith("**"):
39 + skill_name = skill_name[2:-2]
40 + return skill_name.strip()
41 +
42 + def get_log_object(self):
43 + import uuid
44 +
45 + if self._current_method() == "load":
46 + skill_name = self._normalize_skill_name(
47 + str(self.args.get("skill_name") or "")
48 + )
49 + heading = (
50 + f"icon://construction Loading skill {skill_name}"
51 + if skill_name
52 + else "icon://construction Loading skill"
53 + )
54 + return self.agent.context.log.log(
55 + type="tool",
56 + heading=heading,
57 + content="",
58 + kvps={"_tool_name": self.name},
59 + id=str(uuid.uuid4()),
60 + )
61 +
62 + return super().get_log_object()
63 +
64 + async def before_execution(self, **kwargs):
65 + if self._current_method() != "load":
66 + await super().before_execution(**kwargs)
67 + return
68 +
69 + skill_name = self._normalize_skill_name(
70 + str(kwargs.get("skill_name") or self.args.get("skill_name") or "")
71 + )
72 + label = f"{self.name}:{self._current_method()}"
73 + if skill_name:
74 + PrintStyle(
75 + font_color="#1B4F72",
76 + padding=True,
77 + background_color="white",
78 + bold=True,
79 + ).print(f"{self.agent.agent_name}: Loading skill '{skill_name}'")
80 + else:
81 + PrintStyle(
82 + font_color="#1B4F72",
83 + padding=True,
84 + background_color="white",
85 + bold=True,
86 + ).print(f"{self.agent.agent_name}: Using tool '{label}'")
87 + self.log = self.get_log_object()
88 +
89 async def execute(self, **kwargs) -> Response:
90 method = (
91 (kwargs.get("method") or self.args.get("method") or self.method or "")
@@ -38,7 +100,9 @@ class SkillsTool(Tool):
100 # query = str(kwargs.get("query") or "").strip()
101 # return Response(message=self._search(query), break_loop=False)
102 if method == "load":
41 - skill_name = str(kwargs.get("skill_name") or "").strip()
103 + skill_name = self._normalize_skill_name(
104 + str(kwargs.get("skill_name") or "")
105 + )
106 return Response(message=self._load(skill_name), break_loop=False)
107 # if method == "read_file":
108 # skill_name = str(kwargs.get("skill_name") or "").strip()
@@ -106,9 +170,7 @@ class SkillsTool(Tool):
170 # return "\n".join(lines)
171
172 def _load(self, skill_name: str) -> str:
109 - skill_name = skill_name.strip()
110 - if skill_name.startswith("**") and skill_name.endswith("**"):
111 - skill_name = skill_name[2:-2]
173 + skill_name = self._normalize_skill_name(skill_name)
174
175 if not skill_name:
176 return "Error: 'skill_name' is required for method=load."