Sanitize print logs; refactor popular plugin logic
Some checks are pending
Build And Publish Docker Images / plan (push) Waiting to run
Build And Publish Docker Images / build (push) Blocked by required conditions

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.
This commit is contained in:
frdel 2026-03-30 11:50:59 +02:00
parent 430f8479a9
commit 44e008745d
6 changed files with 272 additions and 11 deletions

View file

@ -3,6 +3,7 @@ import sys
from datetime import datetime
from collections.abc import Mapping
from . import files
from .strings import sanitize_string
_runtime_module = None
@ -34,7 +35,7 @@ class PrintStyle:
os.makedirs(logs_dir, exist_ok=True)
log_filename = datetime.now().strftime("log_%Y%m%d_%H%M%S.html")
PrintStyle.log_file_path = os.path.join(logs_dir, log_filename)
with open(PrintStyle.log_file_path, "w") as f:
with open(PrintStyle.log_file_path, "w", encoding="utf-8", errors="replace") as f:
f.write("<html><body style='background-color:black;font-family: Arial, Helvetica, sans-serif;'><pre>\n")
def _get_rgb_color_code(self, color, is_background=False):
@ -93,13 +94,13 @@ class PrintStyle:
self.padding_added = True
def _log_html(self, html):
with open(PrintStyle.log_file_path, "a", encoding='utf-8') as f: # type: ignore # add encoding='utf-8'
f.write(html)
with open(PrintStyle.log_file_path, "a", encoding="utf-8", errors="replace") as f: # type: ignore[arg-type]
f.write(sanitize_string(html))
@staticmethod
def _close_html_log():
if PrintStyle.log_file_path:
with open(PrintStyle.log_file_path, "a") as f:
with open(PrintStyle.log_file_path, "a", encoding="utf-8", errors="replace") as f:
f.write("</pre></body></html>")
@staticmethod
@ -145,6 +146,8 @@ class PrintStyle:
# If masking fails, proceed without masking to avoid breaking functionality
pass
text = sanitize_string(text)
return text, self._get_styled_text(text), self._get_html_styled_text(text)
def print(self, *args, sep=' ', end='\n', flush=True):

View file

@ -25,6 +25,12 @@
<div class="pi-hero-main">
<h2 class="pi-hero-title" x-text="$store.pluginInstallStore.selectedPlugin.title || $store.pluginInstallStore.selectedPlugin.key"></h2>
<div class="pi-status-badges">
<template x-if="$store.pluginInstallStore.selectedPlugin.suspended">
<span class="pi-card-suspended-pill">
<span class="material-symbols-outlined">priority_high</span>
<span>Suspended</span>
</span>
</template>
<template x-if="$store.pluginInstallStore.selectedPlugin.installed">
<span class="pi-card-installed-pill">Installed</span>
</template>
@ -100,6 +106,17 @@
</div>
</div>
<template x-if="$store.pluginInstallStore.selectedPlugin.suspended">
<div class="pi-suspension-banner">
<span class="material-symbols-outlined">priority_high</span>
<div class="pi-suspension-banner-copy">
<div class="pi-suspension-banner-title">Plugin has been suspended for the following reasons:</div>
<div class="pi-suspension-banner-explanation"
x-text="$store.pluginInstallStore.selectedPlugin.suspended"></div>
</div>
</div>
</template>
<div class="pi-description" x-text="$store.pluginInstallStore.selectedPlugin.description || 'No description available.'"></div>
<div class="pi-screenshots-section"
@ -435,6 +452,27 @@
color: #1d4ed8;
}
.pi-card-suspended-pill {
display: inline-flex;
align-items: center;
gap: 0.3rem;
padding: 0.24rem 0.5rem;
border-radius: 0.5rem;
background: rgba(245, 158, 11, 0.16);
color: #f59e0b;
font-size: 0.72rem;
font-weight: 700;
}
.pi-card-suspended-pill .material-symbols-outlined {
font-size: 0.95rem;
}
body.light-mode .pi-card-suspended-pill {
background: rgba(245, 158, 11, 0.2);
color: #b45309;
}
.pi-status-badges .pi-card-installed-pill {
position: static;
top: auto;
@ -447,6 +485,12 @@
right: auto;
}
.pi-status-badges .pi-card-suspended-pill {
position: static;
top: auto;
right: auto;
}
.pi-tag {
display: inline-flex;
align-items: center;
@ -466,6 +510,47 @@
margin-bottom: 1.5rem;
}
.pi-suspension-banner {
display: flex;
align-items: flex-start;
gap: 0.75rem;
margin-bottom: 1rem;
padding: 0.9rem 1rem;
border: 1px solid rgba(245, 158, 11, 0.32);
border-radius: 10px;
background: rgba(245, 158, 11, 0.12);
color: #f59e0b;
}
.pi-suspension-banner .material-symbols-outlined {
font-size: 1.2rem;
line-height: 1.2;
flex-shrink: 0;
}
.pi-suspension-banner-copy {
min-width: 0;
}
.pi-suspension-banner-title {
font-size: 0.92rem;
font-weight: 700;
line-height: 1.45;
}
.pi-suspension-banner-explanation {
margin-top: 0.2rem;
font-size: 0.92rem;
line-height: 1.55;
white-space: pre-line;
}
body.light-mode .pi-suspension-banner {
border-color: rgba(217, 119, 6, 0.28);
background: rgba(245, 158, 11, 0.14);
color: #b45309;
}
.pi-actions-primary {
display: flex;
gap: 0.75rem;

View file

@ -124,6 +124,12 @@
</div>
<div class="pi-card-bubbles pi-card-bubbles-status">
<template x-if="plugin.suspended">
<span class="pi-card-bubble pi-card-bubble-suspended">
<span class="material-symbols-outlined">priority_high</span>
<span class="pi-card-bubble-text">Suspended</span>
</span>
</template>
<template x-if="plugin.installed">
<span class="pi-card-bubble pi-card-bubble-installed">
<span class="material-symbols-outlined">check_circle</span>
@ -641,6 +647,19 @@
border-color: rgba(59, 130, 246, 0.3);
}
.pi-card-bubble-suspended .material-symbols-outlined {
color: #f59e0b;
}
.pi-card-bubble-suspended .pi-card-bubble-text {
color: #f59e0b;
}
.pi-card-bubble-suspended:hover {
background: rgba(245, 158, 11, 0.12);
border-color: rgba(245, 158, 11, 0.35);
}
body.light-mode .pi-card-bubble-installed .material-symbols-outlined,
body.light-mode .pi-card-bubble-installed .pi-card-bubble-text {
color: #166534;
@ -661,6 +680,16 @@
border-color: rgba(59, 130, 246, 0.35);
}
body.light-mode .pi-card-bubble-suspended .material-symbols-outlined,
body.light-mode .pi-card-bubble-suspended .pi-card-bubble-text {
color: #b45309;
}
body.light-mode .pi-card-bubble-suspended:hover {
background: rgba(245, 158, 11, 0.16);
border-color: rgba(217, 119, 6, 0.35);
}
.pi-pagination {
display: flex;
align-items: center;

View file

@ -11,6 +11,7 @@ import { store as pluginSettingsStore } from "/components/plugins/plugin-setting
const PLUGIN_API = "plugins/_plugin_installer/plugin_install";
const PER_PAGE = 24;
const POPULAR_PLUGIN_MIN_STARS = 3;
const SECURITY_WARNING = {
title: "Security Warning",
@ -93,11 +94,23 @@ const model = {
.join(" ");
},
_isPopularPlugin(plugin) {
return (plugin?.stars || 0) >= POPULAR_PLUGIN_MIN_STARS;
},
_getSuspensionReason(plugin) {
return typeof plugin?.suspended === "string" ? plugin.suspended.trim() : "";
},
isPluginSuspended(plugin) {
return !!this._getSuspensionReason(plugin);
},
_matchesBrowseFilter(plugin, filterKey) {
if (!filterKey || filterKey === "all") return true;
if (filterKey === "installed") return !!plugin?.installed;
if (filterKey === "update") return !!plugin?.has_update;
if (filterKey === "popular") return (plugin?.stars || 0) >= 3;
if (filterKey === "popular") return this._isPopularPlugin(plugin);
if (filterKey.startsWith("tag:")) {
return this._pluginPrimaryTag(plugin) === filterKey.slice(4);
}
@ -126,6 +139,22 @@ const model = {
return true;
},
_comparePluginsByStars(a, b) {
const aSuspended = this.isPluginSuspended(a);
const bSuspended = this.isPluginSuspended(b);
if (aSuspended !== bSuspended) {
return aSuspended ? 1 : -1;
}
const aStars = aSuspended ? 0 : Number(a?.stars) || 0;
const bStars = bSuspended ? 0 : Number(b?.stars) || 0;
if (aStars !== bStars) {
return bStars - aStars;
}
return (a.title || a.key).localeCompare(b.title || b.key);
},
// ── ZIP Install ──────────────────────────────
handleFileUpload(event) {
@ -341,6 +370,7 @@ const model = {
commit: val?.commit || val?.latest_commit || "",
updated: val?.updated || val?.latest_commit_timestamp || "",
version: val?.version || "",
suspended: this._getSuspensionReason(val),
installed,
};
@ -365,7 +395,7 @@ const model = {
const updateCount = plugins.filter((plugin) => plugin.has_update).length;
filters.push({ key: "update", label: "Update", count: updateCount });
const popularCount = plugins.filter((plugin) => (plugin.stars || 0) > 0).length;
const popularCount = plugins.filter((plugin) => this._isPopularPlugin(plugin)).length;
if (popularCount) {
filters.push({ key: "popular", label: "Popular", count: popularCount });
}
@ -406,7 +436,7 @@ const model = {
);
}
if (this.sortBy === "stars") {
list.sort((a, b) => (b.stars || 0) - (a.stars || 0));
list.sort((a, b) => this._comparePluginsByStars(a, b));
} else {
list.sort((a, b) =>
(a.title || a.key).localeCompare(b.title || b.key)

52
tests/test_print_style.py Normal file
View file

@ -0,0 +1,52 @@
import sys
from pathlib import Path
import pytest
PROJECT_ROOT = Path(__file__).resolve().parents[1]
if str(PROJECT_ROOT) not in sys.path:
sys.path.insert(0, str(PROJECT_ROOT))
from helpers.print_style import PrintStyle
class _PassthroughSecretsManager:
def mask_values(self, text: str) -> str:
return text
@pytest.fixture(autouse=True)
def _reset_print_style_state():
PrintStyle.log_file_path = None
PrintStyle.last_endline = True
yield
PrintStyle.log_file_path = None
PrintStyle.last_endline = True
def test_get_sanitizes_lone_surrogates(tmp_path, monkeypatch):
monkeypatch.setattr("helpers.print_style.files.get_abs_path", lambda _: str(tmp_path))
style = PrintStyle(log_only=True)
style.secrets_mgr = _PassthroughSecretsManager()
plain_text, styled_text, html_text = style.get("bad \ud83d")
assert plain_text == "bad ?"
assert "\ud83d" not in styled_text
assert "\ud83d" not in html_text
def test_print_writes_html_log_without_surrogate_crash(tmp_path, monkeypatch):
monkeypatch.setattr("helpers.print_style.files.get_abs_path", lambda _: str(tmp_path))
style = PrintStyle(log_only=True)
style.secrets_mgr = _PassthroughSecretsManager()
style.print("bad \ud83d")
log_path = Path(PrintStyle.log_file_path)
content = log_path.read_text(encoding="utf-8")
assert "bad ?" in content
assert "\ud83d" not in content

View file

@ -6,6 +6,7 @@ from typing import List
from helpers.tool import Tool, Response
from helpers import projects, files, file_tree
from helpers import skills as skills_helper, runtime
from helpers.print_style import PrintStyle
DATA_NAME_LOADED_SKILLS = "loaded_skills"
@ -24,6 +25,67 @@ class SkillsTool(Tool):
Script execution is handled by code_execution_tool directly.
"""
def _current_method(self) -> str:
return (
(self.args.get("method") or self.method or "")
.strip()
.lower()
)
@staticmethod
def _normalize_skill_name(skill_name: str) -> str:
skill_name = skill_name.strip()
if skill_name.startswith("**") and skill_name.endswith("**"):
skill_name = skill_name[2:-2]
return skill_name.strip()
def get_log_object(self):
import uuid
if self._current_method() == "load":
skill_name = self._normalize_skill_name(
str(self.args.get("skill_name") or "")
)
heading = (
f"icon://construction Loading skill {skill_name}"
if skill_name
else "icon://construction Loading skill"
)
return self.agent.context.log.log(
type="tool",
heading=heading,
content="",
kvps={"_tool_name": self.name},
id=str(uuid.uuid4()),
)
return super().get_log_object()
async def before_execution(self, **kwargs):
if self._current_method() != "load":
await super().before_execution(**kwargs)
return
skill_name = self._normalize_skill_name(
str(kwargs.get("skill_name") or self.args.get("skill_name") or "")
)
label = f"{self.name}:{self._current_method()}"
if skill_name:
PrintStyle(
font_color="#1B4F72",
padding=True,
background_color="white",
bold=True,
).print(f"{self.agent.agent_name}: Loading skill '{skill_name}'")
else:
PrintStyle(
font_color="#1B4F72",
padding=True,
background_color="white",
bold=True,
).print(f"{self.agent.agent_name}: Using tool '{label}'")
self.log = self.get_log_object()
async def execute(self, **kwargs) -> Response:
method = (
(kwargs.get("method") or self.args.get("method") or self.method or "")
@ -38,7 +100,9 @@ class SkillsTool(Tool):
# query = str(kwargs.get("query") or "").strip()
# return Response(message=self._search(query), break_loop=False)
if method == "load":
skill_name = str(kwargs.get("skill_name") or "").strip()
skill_name = self._normalize_skill_name(
str(kwargs.get("skill_name") or "")
)
return Response(message=self._load(skill_name), break_loop=False)
# if method == "read_file":
# skill_name = str(kwargs.get("skill_name") or "").strip()
@ -106,9 +170,7 @@ class SkillsTool(Tool):
# return "\n".join(lines)
def _load(self, skill_name: str) -> str:
skill_name = skill_name.strip()
if skill_name.startswith("**") and skill_name.endswith("**"):
skill_name = skill_name[2:-2]
skill_name = self._normalize_skill_name(skill_name)
if not skill_name:
return "Error: 'skill_name' is required for method=load."