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."