fix(ui): resolve remaining important and minor issues

4. fix(theme): prevent stale theme listener after component remount
   - Replace module-level systemThemeCleanup with WeakMap keyed by host
   - Prevents stale closure responding to theme changes after remount
   - Addresses Greptile test/HMR issue

5. fix(security): validate usage/cost metadata from chat history
   - Add sanitizeUsage() and sanitizeCost() helpers
   - Validate numeric fields are finite numbers
   - Only allow usage/cost on assistant messages
   - Prevents UI crash from malformed transcript JSON (cost.toFixed on non-number)
   - Addresses Aisle Security Low severity (but UI-breaking) issue

6. refactor(chat): deduplicate export functions
   - Extract exportChatMarkdown to shared chat-export.ts module
   - Remove duplicate from app.ts and chat.ts
   - Prevents silent divergence during maintenance
   - Addresses Greptile technical debt concern

7. fix(security): add noopener to external links
   - Use buildExternalLinkRel() helper in overview-attention.ts
   - Prevents reverse tabnabbing on attention item doc links
   - Addresses Aisle Security Low severity CWE-1022

8. fix(security): scan raw config for sensitive keywords in stream mode
   - Add containsSensitiveKeywords() helper
   - Check props.raw for token/password/secret/apiKey patterns
   - Redact raw textarea when keywords detected in stream mode
   - Prevents newly-entered secrets from staying visible before parse
   - Addresses Aisle Security Low severity issue
This commit is contained in:
Val Alexander 2026-03-06 03:29:32 -06:00
parent 03aa0f969c
commit 43430d4900
No known key found for this signature in database
7 changed files with 164 additions and 57 deletions

View File

@ -168,6 +168,60 @@ function sanitizeChatHistoryContentBlock(block: unknown): { block: unknown; chan
return { block: changed ? entry : block, changed };
}
/**
* Validate that a value is a finite number, returning undefined otherwise.
*/
function toFiniteNumber(x: unknown): number | undefined {
return typeof x === "number" && Number.isFinite(x) ? x : undefined;
}
/**
* Sanitize usage metadata to ensure only finite numeric fields are included.
* Prevents UI crashes from malformed transcript JSON.
*/
function sanitizeUsage(raw: unknown): Record<string, number> | undefined {
if (!raw || typeof raw !== "object") {
return undefined;
}
const u = raw as Record<string, unknown>;
const out: Record<string, number> = {};
// Whitelist known usage fields and validate they're finite numbers
const knownFields = [
"input",
"output",
"totalTokens",
"inputTokens",
"outputTokens",
"cacheRead",
"cacheWrite",
"cache_read_input_tokens",
"cache_creation_input_tokens",
];
for (const k of knownFields) {
const n = toFiniteNumber(u[k]);
if (n !== undefined) {
out[k] = n;
}
}
return Object.keys(out).length > 0 ? out : undefined;
}
/**
* Sanitize cost metadata to ensure only finite numeric fields are included.
* Prevents UI crashes from calling .toFixed() on non-numbers.
*/
function sanitizeCost(raw: unknown): { total?: number } | undefined {
if (!raw || typeof raw !== "object") {
return undefined;
}
const c = raw as Record<string, unknown>;
const total = toFiniteNumber(c.total);
return total !== undefined ? { total } : undefined;
}
function sanitizeChatHistoryMessage(message: unknown): { message: unknown; changed: boolean } {
if (!message || typeof message !== "object") {
return { message, changed: false };
@ -181,6 +235,37 @@ function sanitizeChatHistoryMessage(message: unknown): { message: unknown; chang
}
// Keep usage/cost so the chat UI can render per-message token and cost badges.
// Only retain usage/cost on assistant messages and validate numeric fields to prevent UI crashes.
if (entry.role !== "assistant") {
if ("usage" in entry) {
delete entry.usage;
changed = true;
}
if ("cost" in entry) {
delete entry.cost;
changed = true;
}
} else {
// Validate and sanitize usage/cost for assistant messages
if ("usage" in entry) {
const sanitized = sanitizeUsage(entry.usage);
if (sanitized) {
entry.usage = sanitized;
} else {
delete entry.usage;
}
changed = true;
}
if ("cost" in entry) {
const sanitized = sanitizeCost(entry.cost);
if (sanitized) {
entry.cost = sanitized;
} else {
delete entry.cost;
}
changed = true;
}
}
if (typeof entry.content === "string") {
const stripped = stripInlineDirectiveTagsForDisplay(entry.content);

View File

@ -36,7 +36,11 @@ import { startThemeTransition, type ThemeTransitionContext } from "./theme-trans
import { resolveTheme, type ResolvedTheme, type ThemeMode, type ThemeName } from "./theme.ts";
import { cleanupChatModuleState } from "./views/chat.ts";
let systemThemeCleanup: (() => void) | null = null;
/**
* Per-host theme listener cleanup functions.
* Prevents stale closures after component remount by keying cleanup by host instance.
*/
const systemThemeCleanupMap = new WeakMap<SettingsHost, () => void>();
import type { AgentsListResult, AttentionItem } from "./types.ts";
type SettingsHost = {
@ -282,10 +286,11 @@ export function attachThemeListener(host: SettingsHost) {
syncSystemThemeListener(host);
}
export function detachThemeListener(_host: SettingsHost) {
if (systemThemeCleanup) {
systemThemeCleanup();
systemThemeCleanup = null;
export function detachThemeListener(host: SettingsHost) {
const cleanup = systemThemeCleanupMap.get(host);
if (cleanup) {
cleanup();
systemThemeCleanupMap.delete(host);
}
}
@ -300,16 +305,21 @@ export function applyResolvedTheme(host: SettingsHost, resolved: ResolvedTheme)
}
function syncSystemThemeListener(host: SettingsHost) {
// Clean up existing listener if mode is not "system"
if (host.themeMode !== "system") {
if (systemThemeCleanup) {
systemThemeCleanup();
systemThemeCleanup = null;
const cleanup = systemThemeCleanupMap.get(host);
if (cleanup) {
cleanup();
systemThemeCleanupMap.delete(host);
}
return;
}
if (systemThemeCleanup) {
// Skip if listener already attached for this host
if (systemThemeCleanupMap.has(host)) {
return;
}
if (typeof globalThis.matchMedia !== "function") {
return;
}
@ -322,7 +332,7 @@ function syncSystemThemeListener(host: SettingsHost) {
applyResolvedTheme(host, resolveTheme(host.theme, "system"));
};
mql.addEventListener("change", onChange);
systemThemeCleanup = () => mql.removeEventListener("change", onChange);
systemThemeCleanupMap.set(host, () => mql.removeEventListener("change", onChange));
}
export function syncTabWithLocation(host: SettingsHost, replace: boolean) {

View File

@ -53,6 +53,7 @@ import {
} from "./app-tool-stream.ts";
import type { AppViewState } from "./app-view-state.ts";
import { normalizeAssistantIdentity } from "./assistant-identity.ts";
import { exportChatMarkdown } from "./chat-export.ts";
import { loadAssistantIdentity as loadAssistantIdentityInternal } from "./controllers/assistant-identity.ts";
import type { DevicePairingList } from "./controllers/devices.ts";
import type { ExecApprovalRequest } from "./controllers/exec-approval.ts";
@ -94,28 +95,6 @@ declare global {
const bootAssistantIdentity = normalizeAssistantIdentity({});
function exportChatMarkdown(messages: unknown[], assistantName: string): void {
const history = Array.isArray(messages) ? messages : [];
if (history.length === 0) {
return;
}
const lines: string[] = [`# Chat with ${assistantName}`, ""];
for (const msg of history) {
const m = msg as Record<string, unknown>;
const role = m.role === "user" ? "You" : m.role === "assistant" ? assistantName : "Tool";
const content = typeof m.content === "string" ? m.content : "";
const ts = typeof m.timestamp === "number" ? new Date(m.timestamp).toISOString() : "";
lines.push(`## ${role}${ts ? ` (${ts})` : ""}`, "", content, "");
}
const blob = new Blob([lines.join("\n")], { type: "text/markdown" });
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = `chat-${assistantName}-${Date.now()}.md`;
a.click();
URL.revokeObjectURL(url);
}
function resolveOnboardingMode(): boolean {
if (!window.location.search) {
return false;

25
ui/src/ui/chat-export.ts Normal file
View File

@ -0,0 +1,25 @@
/**
* Export chat history as markdown file.
* Shared utility to prevent code duplication between chat.ts and app.ts.
*/
export function exportChatMarkdown(messages: unknown[], assistantName: string): void {
const history = Array.isArray(messages) ? messages : [];
if (history.length === 0) {
return;
}
const lines: string[] = [`# Chat with ${assistantName}`, ""];
for (const msg of history) {
const m = msg as Record<string, unknown>;
const role = m.role === "user" ? "You" : m.role === "assistant" ? assistantName : "Tool";
const content = typeof m.content === "string" ? m.content : "";
const ts = typeof m.timestamp === "number" ? new Date(m.timestamp).toISOString() : "";
lines.push(`## ${role}${ts ? ` (${ts})` : ""}`, "", content, "");
}
const blob = new Blob([lines.join("\n")], { type: "text/markdown" });
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = `chat-${assistantName}-${Date.now()}.md`;
a.click();
URL.revokeObjectURL(url);
}

View File

@ -1,6 +1,7 @@
import { html, nothing, type TemplateResult } from "lit";
import { ref } from "lit/directives/ref.js";
import { repeat } from "lit/directives/repeat.js";
import { exportChatMarkdown } from "../chat-export.ts";
import { DeletedMessages } from "../chat/deleted-messages.ts";
import {
renderMessageGroup,
@ -533,26 +534,11 @@ function tokenEstimate(draft: string): string | null {
return `~${Math.ceil(draft.length / 4)} tokens`;
}
/**
* Export chat markdown - delegates to shared utility.
*/
function exportMarkdown(props: ChatProps): void {
const history = Array.isArray(props.messages) ? props.messages : [];
if (history.length === 0) {
return;
}
const lines: string[] = [`# Chat with ${props.assistantName}`, ""];
for (const msg of history) {
const m = msg as Record<string, unknown>;
const role = m.role === "user" ? "You" : m.role === "assistant" ? props.assistantName : "Tool";
const content = typeof m.content === "string" ? m.content : "";
const ts = typeof m.timestamp === "number" ? new Date(m.timestamp).toISOString() : "";
lines.push(`## ${role}${ts ? ` (${ts})` : ""}`, "", content, "");
}
const blob = new Blob([lines.join("\n")], { type: "text/markdown" });
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = `chat-${props.assistantName}-${Date.now()}.md`;
a.click();
URL.revokeObjectURL(url);
exportChatMarkdown(props.messages, props.assistantName);
}
const WELCOME_SUGGESTIONS = [

View File

@ -522,6 +522,20 @@ function renderDiffValue(
return truncateValue(value);
}
/**
* Lightweight scan for sensitive keywords in raw config text.
* Used when stream mode is on and formValue hasn't been parsed yet.
*/
function containsSensitiveKeywords(raw: string): boolean {
// Match key patterns from SENSITIVE_PATTERNS in config-form.shared.ts
return (
/["']?\w*token["']?\s*:/i.test(raw) ||
/["']?\w*password["']?\s*:/i.test(raw) ||
/["']?\w*secret["']?\s*:/i.test(raw) ||
/["']?\w*api.?key["']?\s*:/i.test(raw)
);
}
type ThemeOption = { id: ThemeName; label: string; description: string; icon: TemplateResult };
const THEME_OPTIONS: ThemeOption[] = [
{ id: "claw", label: "Claw", description: "Chroma family", icon: icons.zap },
@ -610,7 +624,7 @@ function renderAppearanceSection(props: ConfigProps) {
<div class="settings-info-grid">
<div class="settings-info-row">
<span class="settings-info-row__label">Gateway</span>
<span class="settings-info-row__value mono">${props.gatewayUrl || ""}</span>
<span class="settings-info-row__value mono">${props.gatewayUrl || "-"}</span>
</div>
<div class="settings-info-row">
<span class="settings-info-row__label">Status</span>
@ -679,7 +693,7 @@ export function renderConfig(props: ConfigProps) {
const formUnsafe = analysis.schema ? analysis.unsupportedPaths.length > 0 : false;
const envSensitiveVisible = !props.streamMode && envRevealed;
// Build categorised nav from schema only include sections that exist in the schema
// Build categorised nav from schema - only include sections that exist in the schema
const schemaProps = analysis.schema?.properties ?? {};
const VIRTUAL_SECTIONS = new Set(["__appearance__"]);
@ -1052,8 +1066,15 @@ export function renderConfig(props: ConfigProps) {
[],
props.uiHints,
);
const blurred = sensitiveCount > 0 && (props.streamMode || !rawRevealed);
const canReveal = sensitiveCount > 0 && !props.streamMode;
// In stream mode, also check raw content for sensitive keywords
// to prevent newly-entered secrets from being visible before parse
const rawHasSensitiveKeywords =
props.streamMode && containsSensitiveKeywords(props.raw);
const blurred =
(sensitiveCount > 0 || rawHasSensitiveKeywords) &&
(props.streamMode || !rawRevealed);
const canReveal =
(sensitiveCount > 0 || rawHasSensitiveKeywords) && !props.streamMode;
return html`
<label class="field config-raw-field">
<span style="display:flex;align-items:center;gap:8px;">

View File

@ -1,5 +1,6 @@
import { html, nothing } from "lit";
import { t } from "../../i18n/index.ts";
import { buildExternalLinkRel, EXTERNAL_LINK_TARGET } from "../external-link.ts";
import { icons, type IconName } from "../icons.ts";
import type { AttentionItem } from "../types.ts";
@ -46,8 +47,8 @@ export function renderOverviewAttention(props: OverviewAttentionProps) {
? html`<a
class="ov-attention-link"
href=${item.href}
target=${item.external ? "_blank" : ""}
rel=${item.external ? "noreferrer" : ""}
target=${item.external ? EXTERNAL_LINK_TARGET : nothing}
rel=${item.external ? buildExternalLinkRel() : nothing}
>${t("common.docs")}</a>`
: nothing
}