diff --git a/frontend/src/components/Sidebar.jsx b/frontend/src/components/Sidebar.jsx
index c1eec932..eae9fdf9 100644
--- a/frontend/src/components/Sidebar.jsx
+++ b/frontend/src/components/Sidebar.jsx
@@ -9,6 +9,7 @@ import WorkspaceSwitcher from './WorkspaceSwitcher';
import NotificationBell from './NotificationBell';
import { SIDEBAR_CATEGORIES, CATEGORY_LABELS, SIDEBAR_PRESETS, getHiddenItemIds, getVisibleItems, applyWorkspaceNavPermissions } from './sidebarItems';
import { useContributions } from '../plugins/contributions';
+import { sanitizeSvgInner } from '../utils/sanitizeSvg';
const Sidebar = ({ mobileOpen = false, isMobile = false, onMobileClose = () => {} }) => {
const { user, logout, updateUser } = useAuth();
@@ -222,7 +223,7 @@ const Sidebar = ({ mobileOpen = false, isMobile = false, onMobileClose = () => {
>
{item.label}
@@ -246,7 +247,7 @@ const Sidebar = ({ mobileOpen = false, isMobile = false, onMobileClose = () => {
>
{sub.label}
diff --git a/frontend/src/components/ai/AIAssistant.jsx b/frontend/src/components/ai/AIAssistant.jsx
index cab7935f..8080b89d 100644
--- a/frontend/src/components/ai/AIAssistant.jsx
+++ b/frontend/src/components/ai/AIAssistant.jsx
@@ -11,8 +11,12 @@ const AIAssistant = () => {
const { isAuthenticated } = useAuth();
const { isOpen, toggle, isStreaming, unread, pageContext } = useServerkitAI();
- // Alt+A toggles the assistant (ignored while typing in a field).
+ // Alt+A toggles the assistant (ignored while typing in a field). Users who
+ // find the global shortcut intrusive can disable it by setting
+ // localStorage 'sk_ai_shortcut' to 'off'; the bubble's title/aria-label
+ // keeps the binding discoverable for everyone else.
useEffect(() => {
+ if (localStorage.getItem('sk_ai_shortcut') === 'off') return undefined;
const onKeyDown = (e) => {
if (!e.altKey || (e.key !== 'a' && e.key !== 'A')) return;
const t = document.activeElement;
diff --git a/frontend/src/components/ai/ChatBubble.jsx b/frontend/src/components/ai/ChatBubble.jsx
index c3be6afb..b6ec8b0a 100644
--- a/frontend/src/components/ai/ChatBubble.jsx
+++ b/frontend/src/components/ai/ChatBubble.jsx
@@ -13,6 +13,8 @@ const ChatBubble = ({ open, unread, streaming, raised, onToggle }) => (
].filter(Boolean).join(' ')}
onClick={onToggle}
aria-label={open ? 'Close assistant (Alt+A)' : 'Open assistant (Alt+A)'}
+ title={open ? 'Close assistant (Alt+A)' : 'Open assistant (Alt+A)'}
+ aria-keyshortcuts="Alt+A"
aria-expanded={open}
aria-controls="sk-ai-drawer"
>
diff --git a/frontend/src/utils/sanitizeSvg.js b/frontend/src/utils/sanitizeSvg.js
new file mode 100644
index 00000000..cc33838a
--- /dev/null
+++ b/frontend/src/utils/sanitizeSvg.js
@@ -0,0 +1,70 @@
+// Allowlist sanitizer for the inline SVG icon strings rendered in the sidebar.
+//
+// Core sidebar icons are trusted constants, but plugin-contributed nav items
+// flow through the same render path and could supply arbitrary SVG (script,
+// event handlers, , external refs). The icons are a small, fixed
+// shape grammar, so we parse the string with the real XML parser and rebuild it
+// keeping ONLY known-safe elements and attributes — anything else (incl. every
+// on*/event handler) is dropped. Results are cached since icon strings are
+// stable across renders.
+
+const ALLOWED_TAGS = new Set([
+ 'path', 'rect', 'circle', 'ellipse', 'line', 'polyline', 'polygon',
+ 'g', 'defs', 'lineargradient', 'radialgradient', 'stop', 'clippath',
+ 'title', 'desc', 'tspan', 'text', 'svg',
+]);
+
+const ALLOWED_ATTRS = new Set([
+ 'd', 'x', 'y', 'width', 'height', 'cx', 'cy', 'r', 'rx', 'ry',
+ 'x1', 'y1', 'x2', 'y2', 'points', 'transform', 'viewbox',
+ 'preserveaspectratio', 'fill', 'stroke', 'stroke-width', 'stroke-linecap',
+ 'stroke-linejoin', 'stroke-dasharray', 'stroke-dashoffset', 'opacity',
+ 'fill-opacity', 'stroke-opacity', 'fill-rule', 'clip-rule', 'clip-path',
+ 'offset', 'stop-color', 'stop-opacity', 'gradientunits',
+ 'gradienttransform', 'id', 'class', 'text-anchor', 'font-size', 'dx', 'dy',
+]);
+
+const cache = new Map();
+
+export function sanitizeSvgInner(svg) {
+ if (!svg || typeof svg !== 'string') return '';
+ if (cache.has(svg)) return cache.get(svg);
+ if (typeof DOMParser === 'undefined') return '';
+
+ let result = '';
+ try {
+ const doc = new DOMParser().parseFromString(
+ ``,
+ 'image/svg+xml'
+ );
+ const root = doc.documentElement;
+ if (root && !root.getElementsByTagName('parsererror').length) {
+ scrub(root);
+ result = root.innerHTML;
+ }
+ } catch {
+ result = '';
+ }
+
+ cache.set(svg, result);
+ return result;
+}
+
+function scrub(node) {
+ for (const child of Array.from(node.children)) {
+ const tag = child.tagName.toLowerCase();
+ if (!ALLOWED_TAGS.has(tag)) {
+ child.remove();
+ continue;
+ }
+ for (const attr of Array.from(child.attributes)) {
+ const name = attr.name.toLowerCase();
+ if (name.startsWith('on') || !ALLOWED_ATTRS.has(name)) {
+ child.removeAttribute(attr.name);
+ }
+ }
+ scrub(child);
+ }
+}
+
+export default sanitizeSvgInner;