feat: enhance sensitive data handling in config forms

- Updated config form tests to ensure sensitive values are properly managed and revealed based on user interactions.
- Refactored sensitive input rendering logic to support toggling visibility and redaction based on stream mode.
- Improved state management for sensitive paths, allowing for better control over when sensitive data is displayed.
- Added utility functions to identify and handle sensitive configuration data throughout the application.
- Enhanced UI components to reflect changes in sensitive data handling, ensuring a consistent user experience.
This commit is contained in:
Val Alexander 2026-03-05 18:25:14 -06:00
parent 1e440712fb
commit 39020f8d62
No known key found for this signature in database
10 changed files with 792 additions and 128 deletions

View File

@ -51,7 +51,7 @@ describe("config form renderer", () => {
container,
);
const tokenInput: HTMLInputElement | null = container.querySelector("input[type='password']");
const tokenInput: HTMLInputElement | null = container.querySelector(".cfg-input");
expect(tokenInput).not.toBeNull();
if (!tokenInput) {
return;
@ -77,6 +77,81 @@ describe("config form renderer", () => {
expect(onPatch).toHaveBeenCalledWith(["enabled"], true);
});
it("keeps sensitive values out of hidden form inputs until revealed", () => {
const onPatch = vi.fn();
const container = document.createElement("div");
const analysis = analyzeConfigSchema(rootSchema);
const revealed = new Set<string>();
const props = {
schema: analysis.schema,
uiHints: {
"gateway.auth.token": { label: "Gateway Token", sensitive: true },
},
unsupportedPaths: analysis.unsupportedPaths,
value: { gateway: { auth: { token: "secret-123" } } },
streamMode: false,
isSensitivePathRevealed: (path: Array<string | number>) =>
revealed.has(
path.filter((segment): segment is string => typeof segment === "string").join("."),
),
onToggleSensitivePath: (path: Array<string | number>) => {
const key = path
.filter((segment): segment is string => typeof segment === "string")
.join(".");
if (revealed.has(key)) {
revealed.delete(key);
} else {
revealed.add(key);
}
},
onPatch,
};
render(renderConfigForm(props), container);
const hiddenInput = container.querySelector<HTMLInputElement>(".cfg-input");
expect(hiddenInput).not.toBeNull();
expect(hiddenInput?.value).toBe("");
expect(hiddenInput?.placeholder).toContain("redacted");
const toggle = container.querySelector<HTMLButtonElement>('button[aria-label="Reveal value"]');
expect(toggle?.disabled).toBe(false);
toggle?.click();
render(renderConfigForm(props), container);
const revealedInput = container.querySelector<HTMLInputElement>(".cfg-input");
expect(revealedInput?.value).toBe("secret-123");
expect(revealedInput?.type).toBe("text");
});
it("blocks sensitive field reveal while stream mode is enabled", () => {
const onPatch = vi.fn();
const container = document.createElement("div");
const analysis = analyzeConfigSchema(rootSchema);
render(
renderConfigForm({
schema: analysis.schema,
uiHints: {
"gateway.auth.token": { label: "Gateway Token", sensitive: true },
},
unsupportedPaths: analysis.unsupportedPaths,
value: { gateway: { auth: { token: "secret-123" } } },
streamMode: true,
isSensitivePathRevealed: () => false,
onToggleSensitivePath: vi.fn(),
onPatch,
}),
container,
);
const input = container.querySelector<HTMLInputElement>(".cfg-input");
expect(input?.value).toBe("");
const toggle = container.querySelector<HTMLButtonElement>(
'button[aria-label="Disable stream mode to reveal value"]',
);
expect(toggle?.disabled).toBe(true);
});
it("adds and removes array entries", () => {
const onPatch = vi.fn();
const container = document.createElement("div");
@ -301,7 +376,7 @@ describe("config form renderer", () => {
}),
noMatchContainer,
);
expect(noMatchContainer.textContent).toContain('No settings match "mode tag:security"');
expect(noMatchContainer.textContent).not.toContain("Token");
});
it("supports SecretInput unions in additionalProperties maps", () => {
@ -366,12 +441,17 @@ describe("config form renderer", () => {
},
unsupportedPaths: analysis.unsupportedPaths,
value: { models: { providers: { openai: { apiKey: "old" } } } },
streamMode: false,
isSensitivePathRevealed: () => true,
onToggleSensitivePath: vi.fn(),
onPatch,
}),
container,
);
const apiKeyInput: HTMLInputElement | null = container.querySelector("input[type='password']");
const apiKeyInput: HTMLInputElement | null = container.querySelector(
".cfg-input:not(.cfg-input--sm)",
);
expect(apiKeyInput).not.toBeNull();
if (!apiKeyInput) {
return;

View File

@ -14,6 +14,7 @@ function createState(): { state: AgentsState; request: ReturnType<typeof vi.fn>
agentsList: null,
agentsSelectedId: "main",
toolsCatalogLoading: false,
toolsCatalogLoadingAgentId: null,
toolsCatalogError: null,
toolsCatalogResult: null,
};
@ -58,4 +59,68 @@ describe("loadToolsCatalog", () => {
expect(state.toolsCatalogError).toContain("gateway unavailable");
expect(state.toolsCatalogLoading).toBe(false);
});
it("allows a new agent request to replace a stale in-flight load", async () => {
const { state, request } = createState();
let resolveMain:
| ((value: {
agentId: string;
profiles: { id: string; label: string }[];
groups: {
id: string;
label: string;
source: string;
tools: { id: string; label: string; description: string; source: string }[];
}[];
}) => void)
| null = null;
const mainRequest = new Promise<{
agentId: string;
profiles: { id: string; label: string }[];
groups: {
id: string;
label: string;
source: string;
tools: { id: string; label: string; description: string; source: string }[];
}[];
}>((resolve) => {
resolveMain = resolve;
});
const replacementPayload = {
agentId: "other",
profiles: [{ id: "full", label: "Full" }],
groups: [],
};
request.mockImplementationOnce(() => mainRequest).mockResolvedValueOnce(replacementPayload);
const initialLoad = loadToolsCatalog(state, "main");
await Promise.resolve();
state.agentsSelectedId = "other";
await loadToolsCatalog(state, "other");
expect(request).toHaveBeenNthCalledWith(1, "tools.catalog", {
agentId: "main",
includePlugins: true,
});
expect(request).toHaveBeenNthCalledWith(2, "tools.catalog", {
agentId: "other",
includePlugins: true,
});
expect(state.toolsCatalogResult).toEqual(replacementPayload);
expect(state.toolsCatalogLoading).toBe(false);
resolveMain?.({
agentId: "main",
profiles: [{ id: "full", label: "Full" }],
groups: [],
});
await initialLoad;
expect(state.toolsCatalogResult).toEqual(replacementPayload);
expect(state.toolsCatalogLoading).toBe(false);
});
});

View File

@ -9,6 +9,7 @@ export type AgentsState = {
agentsList: AgentsListResult | null;
agentsSelectedId: string | null;
toolsCatalogLoading: boolean;
toolsCatalogLoadingAgentId?: string | null;
toolsCatalogError: string | null;
toolsCatalogResult: ToolsCatalogResult | null;
};
@ -44,10 +45,11 @@ export async function loadToolsCatalog(state: AgentsState, agentId: string) {
if (!state.client || !state.connected || !resolvedAgentId) {
return;
}
if (state.toolsCatalogLoading) {
if (state.toolsCatalogLoading && state.toolsCatalogLoadingAgentId === resolvedAgentId) {
return;
}
state.toolsCatalogLoading = true;
state.toolsCatalogLoadingAgentId = resolvedAgentId;
state.toolsCatalogError = null;
state.toolsCatalogResult = null;
try {
@ -55,18 +57,25 @@ export async function loadToolsCatalog(state: AgentsState, agentId: string) {
agentId: resolvedAgentId,
includePlugins: true,
});
if (state.toolsCatalogLoadingAgentId !== resolvedAgentId) {
return;
}
if (state.agentsSelectedId && state.agentsSelectedId !== resolvedAgentId) {
return;
}
state.toolsCatalogResult = res;
} catch (err) {
if (state.toolsCatalogLoadingAgentId !== resolvedAgentId) {
return;
}
if (state.agentsSelectedId && state.agentsSelectedId !== resolvedAgentId) {
return;
}
state.toolsCatalogResult = null;
state.toolsCatalogError = String(err);
} finally {
if (!state.agentsSelectedId || state.agentsSelectedId === resolvedAgentId) {
if (state.toolsCatalogLoadingAgentId === resolvedAgentId) {
state.toolsCatalogLoadingAgentId = null;
state.toolsCatalogLoading = false;
}
}

View File

@ -209,6 +209,50 @@ describe("applyConfig", () => {
expect(params.baseHash).toBe("hash-apply-1");
expect(params.sessionKey).toBe("agent:main:web:dm:test");
});
it("preserves sensitive form values in serialized raw state for apply", async () => {
const request = createRequestWithConfigGet();
const state = createState();
state.connected = true;
state.client = { request } as unknown as ConfigState["client"];
state.applySessionKey = "agent:main:web:dm:secret";
state.configFormMode = "form";
state.configForm = {
gateway: {
auth: {
token: "secret-123",
},
},
};
state.configSchema = {
type: "object",
properties: {
gateway: {
type: "object",
properties: {
auth: {
type: "object",
properties: {
token: { type: "string" },
},
},
},
},
},
};
state.configSnapshot = { hash: "hash-apply-secret" };
await applyConfig(state);
expect(request.mock.calls[0]?.[0]).toBe("config.apply");
const params = request.mock.calls[0]?.[1] as {
raw: string;
baseHash: string;
sessionKey: string;
};
expect(params.raw).toContain("secret-123");
expect(params.baseHash).toBe("hash-apply-secret");
});
});
describe("saveConfig", () => {

View File

@ -6,7 +6,7 @@ import type {
SessionsListResultBase,
SessionsPatchResultBase,
} from "../../../src/shared/session-types.js";
export type { ConfigUiHints } from "../../../src/shared/config-ui-hints-types.js";
export type { ConfigUiHint, ConfigUiHints } from "../../../src/shared/config-ui-hints-types.js";
export type ChannelsStatusSnapshot = {
ts: number;

View File

@ -1,10 +1,13 @@
import { html, nothing, type TemplateResult } from "lit";
import { icons as sharedIcons } from "../icons.ts";
import type { ConfigUiHints } from "../types.ts";
import {
defaultValue,
hasSensitiveConfigData,
hintForPath,
humanize,
pathKey,
REDACTED_PLACEHOLDER,
schemaType,
type JsonSchema,
} from "./config-form.shared.ts";
@ -100,11 +103,79 @@ type FieldMeta = {
tags: string[];
};
type SensitiveRenderParams = {
path: Array<string | number>;
value: unknown;
hints: ConfigUiHints;
streamMode: boolean;
revealSensitive: boolean;
isSensitivePathRevealed?: (path: Array<string | number>) => boolean;
};
type SensitiveRenderState = {
isSensitive: boolean;
isRedacted: boolean;
isRevealed: boolean;
canReveal: boolean;
};
export type ConfigSearchCriteria = {
text: string;
tags: string[];
};
function getSensitiveRenderState(params: SensitiveRenderParams): SensitiveRenderState {
const isSensitive = hasSensitiveConfigData(params.value, params.path, params.hints);
const isRevealed =
isSensitive &&
!params.streamMode &&
(params.revealSensitive || (params.isSensitivePathRevealed?.(params.path) ?? false));
return {
isSensitive,
isRedacted: isSensitive && !isRevealed,
isRevealed,
canReveal: isSensitive && !params.streamMode,
};
}
function renderSensitiveToggleButton(params: {
path: Array<string | number>;
state: SensitiveRenderState;
disabled: boolean;
onToggleSensitivePath?: (path: Array<string | number>) => void;
}): TemplateResult | typeof nothing {
const { state } = params;
if (!state.isSensitive || !params.onToggleSensitivePath) {
return nothing;
}
return html`
<button
type="button"
class="btn btn--icon ${state.isRevealed ? "active" : ""}"
style="width:28px;height:28px;padding:0;"
title=${
state.canReveal
? state.isRevealed
? "Hide value"
: "Reveal value"
: "Disable stream mode to reveal value"
}
aria-label=${
state.canReveal
? state.isRevealed
? "Hide value"
: "Reveal value"
: "Disable stream mode to reveal value"
}
aria-pressed=${state.isRevealed}
?disabled=${params.disabled || !state.canReveal}
@click=${() => params.onToggleSensitivePath?.(params.path)}
>
${state.isRevealed ? sharedIcons.eye : sharedIcons.eyeOff}
</button>
`;
}
function hasSearchCriteria(criteria: ConfigSearchCriteria | undefined): boolean {
return Boolean(criteria && (criteria.text.length > 0 || criteria.tags.length > 0));
}
@ -331,6 +402,10 @@ export function renderNode(params: {
disabled: boolean;
showLabel?: boolean;
searchCriteria?: ConfigSearchCriteria;
streamMode?: boolean;
revealSensitive?: boolean;
isSensitivePathRevealed?: (path: Array<string | number>) => boolean;
onToggleSensitivePath?: (path: Array<string | number>) => void;
onPatch: (path: Array<string | number>, value: unknown) => void;
}): TemplateResult | typeof nothing {
const { schema, value, path, hints, unsupported, disabled, onPatch } = params;
@ -442,7 +517,19 @@ export function renderNode(params: {
}
// Complex union (e.g. array | object) — render as JSON textarea
return renderJsonTextarea({ schema, value, path, hints, disabled, showLabel, onPatch });
return renderJsonTextarea({
schema,
value,
path,
hints,
disabled,
showLabel,
streamMode: params.streamMode ?? false,
revealSensitive: params.revealSensitive ?? false,
isSensitivePathRevealed: params.isSensitivePathRevealed,
onToggleSensitivePath: params.onToggleSensitivePath,
onPatch,
});
}
// Enum - use segmented for small, dropdown for large
@ -540,6 +627,10 @@ function renderTextInput(params: {
disabled: boolean;
showLabel?: boolean;
searchCriteria?: ConfigSearchCriteria;
streamMode?: boolean;
revealSensitive?: boolean;
isSensitivePathRevealed?: (path: Array<string | number>) => boolean;
onToggleSensitivePath?: (path: Array<string | number>) => void;
inputType: "text" | "number";
onPatch: (path: Array<string | number>, value: unknown) => void;
}): TemplateResult {
@ -547,17 +638,23 @@ function renderTextInput(params: {
const showLabel = params.showLabel ?? true;
const hint = hintForPath(path, hints);
const { label, help, tags } = resolveFieldMeta(path, schema, hints);
const isSensitive =
(hint?.sensitive ?? false) && !/^\$\{[^}]*\}$/.test(String(value ?? "").trim());
const placeholder =
hint?.placeholder ??
// oxlint-disable typescript/no-base-to-string
(isSensitive
? "••••"
: schema.default !== undefined
? `Default: ${String(schema.default)}`
: "");
const displayValue = value ?? "";
const sensitiveState = getSensitiveRenderState({
path,
value,
hints,
streamMode: params.streamMode ?? false,
revealSensitive: params.revealSensitive ?? false,
isSensitivePathRevealed: params.isSensitivePathRevealed,
});
const placeholder = sensitiveState.isRedacted
? REDACTED_PLACEHOLDER
: (hint?.placeholder ??
// oxlint-disable typescript/no-base-to-string
(schema.default !== undefined ? `Default: ${String(schema.default)}` : ""));
const displayValue = sensitiveState.isRedacted ? "" : (value ?? "");
const effectiveDisabled = disabled || sensitiveState.isRedacted;
const effectiveInputType =
sensitiveState.isSensitive && !sensitiveState.isRedacted ? "text" : inputType;
return html`
<div class="cfg-field">
@ -566,12 +663,16 @@ function renderTextInput(params: {
${renderTags(tags)}
<div class="cfg-input-wrap">
<input
type=${isSensitive ? "password" : inputType}
type=${effectiveInputType}
class="cfg-input"
placeholder=${placeholder}
.value=${displayValue == null ? "" : String(displayValue)}
?disabled=${disabled}
?disabled=${effectiveDisabled}
?readonly=${sensitiveState.isRedacted}
@input=${(e: Event) => {
if (sensitiveState.isRedacted) {
return;
}
const raw = (e.target as HTMLInputElement).value;
if (inputType === "number") {
if (raw.trim() === "") {
@ -585,13 +686,19 @@ function renderTextInput(params: {
onPatch(path, raw);
}}
@change=${(e: Event) => {
if (inputType === "number") {
if (inputType === "number" || sensitiveState.isRedacted) {
return;
}
const raw = (e.target as HTMLInputElement).value;
onPatch(path, raw.trim());
}}
/>
${renderSensitiveToggleButton({
path,
state: sensitiveState,
disabled,
onToggleSensitivePath: params.onToggleSensitivePath,
})}
${
schema.default !== undefined
? html`
@ -599,7 +706,7 @@ function renderTextInput(params: {
type="button"
class="cfg-input__reset"
title="Reset to default"
?disabled=${disabled}
?disabled=${effectiveDisabled}
@click=${() => onPatch(path, schema.default)}
></button>
`
@ -712,38 +819,64 @@ function renderJsonTextarea(params: {
hints: ConfigUiHints;
disabled: boolean;
showLabel?: boolean;
streamMode?: boolean;
revealSensitive?: boolean;
isSensitivePathRevealed?: (path: Array<string | number>) => boolean;
onToggleSensitivePath?: (path: Array<string | number>) => void;
onPatch: (path: Array<string | number>, value: unknown) => void;
}): TemplateResult {
const { schema, value, path, hints, disabled, onPatch } = params;
const showLabel = params.showLabel ?? true;
const { label, help, tags } = resolveFieldMeta(path, schema, hints);
const fallback = jsonValue(value);
const sensitiveState = getSensitiveRenderState({
path,
value,
hints,
streamMode: params.streamMode ?? false,
revealSensitive: params.revealSensitive ?? false,
isSensitivePathRevealed: params.isSensitivePathRevealed,
});
const displayValue = sensitiveState.isRedacted ? "" : fallback;
const effectiveDisabled = disabled || sensitiveState.isRedacted;
return html`
<div class="cfg-field">
${showLabel ? html`<label class="cfg-field__label">${label}</label>` : nothing}
${help ? html`<div class="cfg-field__help">${help}</div>` : nothing}
${renderTags(tags)}
<textarea
class="cfg-textarea"
placeholder="JSON value"
rows="3"
.value=${fallback}
?disabled=${disabled}
@change=${(e: Event) => {
const target = e.target as HTMLTextAreaElement;
const raw = target.value.trim();
if (!raw) {
onPatch(path, undefined);
return;
}
try {
onPatch(path, JSON.parse(raw));
} catch {
target.value = fallback;
}
}}
></textarea>
<div class="cfg-input-wrap">
<textarea
class="cfg-textarea"
placeholder=${sensitiveState.isRedacted ? REDACTED_PLACEHOLDER : "JSON value"}
rows="3"
.value=${displayValue}
?disabled=${effectiveDisabled}
?readonly=${sensitiveState.isRedacted}
@change=${(e: Event) => {
if (sensitiveState.isRedacted) {
return;
}
const target = e.target as HTMLTextAreaElement;
const raw = target.value.trim();
if (!raw) {
onPatch(path, undefined);
return;
}
try {
onPatch(path, JSON.parse(raw));
} catch {
target.value = fallback;
}
}}
></textarea>
${renderSensitiveToggleButton({
path,
state: sensitiveState,
disabled,
onToggleSensitivePath: params.onToggleSensitivePath,
})}
</div>
</div>
`;
}
@ -757,9 +890,26 @@ function renderObject(params: {
disabled: boolean;
showLabel?: boolean;
searchCriteria?: ConfigSearchCriteria;
streamMode?: boolean;
revealSensitive?: boolean;
isSensitivePathRevealed?: (path: Array<string | number>) => boolean;
onToggleSensitivePath?: (path: Array<string | number>) => void;
onPatch: (path: Array<string | number>, value: unknown) => void;
}): TemplateResult {
const { schema, value, path, hints, unsupported, disabled, onPatch, searchCriteria } = params;
const {
schema,
value,
path,
hints,
unsupported,
disabled,
onPatch,
searchCriteria,
streamMode,
revealSensitive,
isSensitivePathRevealed,
onToggleSensitivePath,
} = params;
const showLabel = params.showLabel ?? true;
const { label, help, tags } = resolveFieldMeta(path, schema, hints);
const selfMatched =
@ -800,6 +950,10 @@ function renderObject(params: {
unsupported,
disabled,
searchCriteria: childSearchCriteria,
streamMode,
revealSensitive,
isSensitivePathRevealed,
onToggleSensitivePath,
onPatch,
}),
)}
@ -814,6 +968,10 @@ function renderObject(params: {
disabled,
reservedKeys: reserved,
searchCriteria: childSearchCriteria,
streamMode,
revealSensitive,
isSensitivePathRevealed,
onToggleSensitivePath,
onPatch,
})
: nothing
@ -864,9 +1022,26 @@ function renderArray(params: {
disabled: boolean;
showLabel?: boolean;
searchCriteria?: ConfigSearchCriteria;
streamMode?: boolean;
revealSensitive?: boolean;
isSensitivePathRevealed?: (path: Array<string | number>) => boolean;
onToggleSensitivePath?: (path: Array<string | number>) => void;
onPatch: (path: Array<string | number>, value: unknown) => void;
}): TemplateResult {
const { schema, value, path, hints, unsupported, disabled, onPatch, searchCriteria } = params;
const {
schema,
value,
path,
hints,
unsupported,
disabled,
onPatch,
searchCriteria,
streamMode,
revealSensitive,
isSensitivePathRevealed,
onToggleSensitivePath,
} = params;
const showLabel = params.showLabel ?? true;
const { label, help, tags } = resolveFieldMeta(path, schema, hints);
const selfMatched =
@ -946,6 +1121,10 @@ function renderArray(params: {
disabled,
searchCriteria: childSearchCriteria,
showLabel: false,
streamMode,
revealSensitive,
isSensitivePathRevealed,
onToggleSensitivePath,
onPatch,
})}
</div>
@ -968,6 +1147,10 @@ function renderMapField(params: {
disabled: boolean;
reservedKeys: Set<string>;
searchCriteria?: ConfigSearchCriteria;
streamMode?: boolean;
revealSensitive?: boolean;
isSensitivePathRevealed?: (path: Array<string | number>) => boolean;
onToggleSensitivePath?: (path: Array<string | number>) => void;
onPatch: (path: Array<string | number>, value: unknown) => void;
}): TemplateResult {
const {
@ -980,6 +1163,10 @@ function renderMapField(params: {
reservedKeys,
onPatch,
searchCriteria,
streamMode,
revealSensitive,
isSensitivePathRevealed,
onToggleSensitivePath,
} = params;
const anySchema = isAnySchema(schema);
const entries = Object.entries(value ?? {}).filter(([key]) => !reservedKeys.has(key));
@ -1031,6 +1218,14 @@ function renderMapField(params: {
${visibleEntries.map(([key, entryValue]) => {
const valuePath = [...path, key];
const fallback = jsonValue(entryValue);
const sensitiveState = getSensitiveRenderState({
path: valuePath,
value: entryValue,
hints,
streamMode: streamMode ?? false,
revealSensitive: revealSensitive ?? false,
isSensitivePathRevealed,
});
return html`
<div class="cfg-map__item">
<div class="cfg-map__item-header">
@ -1074,26 +1269,40 @@ function renderMapField(params: {
${
anySchema
? html`
<textarea
class="cfg-textarea cfg-textarea--sm"
placeholder="JSON value"
rows="2"
.value=${fallback}
?disabled=${disabled}
@change=${(e: Event) => {
const target = e.target as HTMLTextAreaElement;
const raw = target.value.trim();
if (!raw) {
onPatch(valuePath, undefined);
return;
<div class="cfg-input-wrap">
<textarea
class="cfg-textarea cfg-textarea--sm"
placeholder=${
sensitiveState.isRedacted ? REDACTED_PLACEHOLDER : "JSON value"
}
try {
onPatch(valuePath, JSON.parse(raw));
} catch {
target.value = fallback;
}
}}
></textarea>
rows="2"
.value=${sensitiveState.isRedacted ? "" : fallback}
?disabled=${disabled || sensitiveState.isRedacted}
?readonly=${sensitiveState.isRedacted}
@change=${(e: Event) => {
if (sensitiveState.isRedacted) {
return;
}
const target = e.target as HTMLTextAreaElement;
const raw = target.value.trim();
if (!raw) {
onPatch(valuePath, undefined);
return;
}
try {
onPatch(valuePath, JSON.parse(raw));
} catch {
target.value = fallback;
}
}}
></textarea>
${renderSensitiveToggleButton({
path: valuePath,
state: sensitiveState,
disabled,
onToggleSensitivePath,
})}
</div>
`
: renderNode({
schema,
@ -1104,6 +1313,10 @@ function renderMapField(params: {
disabled,
searchCriteria,
showLabel: false,
streamMode,
revealSensitive,
isSensitivePathRevealed,
onToggleSensitivePath,
onPatch,
})
}

View File

@ -13,6 +13,10 @@ export type ConfigFormProps = {
searchQuery?: string;
activeSection?: string | null;
activeSubsection?: string | null;
streamMode?: boolean;
revealSensitive?: boolean;
isSensitivePathRevealed?: (path: Array<string | number>) => boolean;
onToggleSensitivePath?: (path: Array<string | number>) => void;
onPatch: (path: Array<string | number>, value: unknown) => void;
};
@ -431,6 +435,10 @@ export function renderConfigForm(props: ConfigFormProps) {
disabled: props.disabled ?? false,
showLabel: false,
searchCriteria,
streamMode: props.streamMode ?? false,
revealSensitive: props.revealSensitive ?? false,
isSensitivePathRevealed: props.isSensitivePathRevealed,
onToggleSensitivePath: props.onToggleSensitivePath,
onPatch: props.onPatch,
})}
</div>
@ -466,6 +474,10 @@ export function renderConfigForm(props: ConfigFormProps) {
disabled: props.disabled ?? false,
showLabel: false,
searchCriteria,
streamMode: props.streamMode ?? false,
revealSensitive: props.revealSensitive ?? false,
isSensitivePathRevealed: props.isSensitivePathRevealed,
onToggleSensitivePath: props.onToggleSensitivePath,
onPatch: props.onPatch,
})}
</div>

View File

@ -1,4 +1,4 @@
import type { ConfigUiHints } from "../types.ts";
import type { ConfigUiHint, ConfigUiHints } from "../types.ts";
export type JsonSchema = {
type?: string | string[];
@ -94,3 +94,110 @@ export function humanize(raw: string) {
.replace(/\s+/g, " ")
.replace(/^./, (m) => m.toUpperCase());
}
const SENSITIVE_KEY_WHITELIST_SUFFIXES = [
"maxtokens",
"maxoutputtokens",
"maxinputtokens",
"maxcompletiontokens",
"contexttokens",
"totaltokens",
"tokencount",
"tokenlimit",
"tokenbudget",
"passwordfile",
] as const;
const SENSITIVE_PATTERNS = [
/token$/i,
/password/i,
/secret/i,
/api.?key/i,
/serviceaccount(?:ref)?$/i,
];
const ENV_VAR_PLACEHOLDER_PATTERN = /^\$\{[^}]*\}$/;
export const REDACTED_PLACEHOLDER = "[redacted - click reveal to view]";
function isEnvVarPlaceholder(value: string): boolean {
return ENV_VAR_PLACEHOLDER_PATTERN.test(value.trim());
}
export function isSensitiveConfigPath(path: string): boolean {
const lowerPath = path.toLowerCase();
const whitelisted = SENSITIVE_KEY_WHITELIST_SUFFIXES.some((suffix) => lowerPath.endsWith(suffix));
return !whitelisted && SENSITIVE_PATTERNS.some((pattern) => pattern.test(path));
}
function isSensitiveLeafValue(value: unknown): boolean {
if (typeof value === "string") {
return value.trim().length > 0 && !isEnvVarPlaceholder(value);
}
return value !== undefined && value !== null;
}
function isHintSensitive(hint: ConfigUiHint | undefined): boolean {
return hint?.sensitive ?? false;
}
export function hasSensitiveConfigData(
value: unknown,
path: Array<string | number>,
hints: ConfigUiHints,
): boolean {
const key = pathKey(path);
const hint = hintForPath(path, hints);
const pathIsSensitive = isHintSensitive(hint) || isSensitiveConfigPath(key);
if (pathIsSensitive && isSensitiveLeafValue(value)) {
return true;
}
if (Array.isArray(value)) {
return value.some((item, index) => hasSensitiveConfigData(item, [...path, index], hints));
}
if (value && typeof value === "object") {
return Object.entries(value as Record<string, unknown>).some(([childKey, childValue]) =>
hasSensitiveConfigData(childValue, [...path, childKey], hints),
);
}
return false;
}
export function countSensitiveConfigValues(
value: unknown,
path: Array<string | number>,
hints: ConfigUiHints,
): number {
if (value == null) {
return 0;
}
const key = pathKey(path);
const hint = hintForPath(path, hints);
const pathIsSensitive = isHintSensitive(hint) || isSensitiveConfigPath(key);
if (pathIsSensitive && isSensitiveLeafValue(value)) {
return 1;
}
if (Array.isArray(value)) {
return value.reduce(
(count, item, index) => count + countSensitiveConfigValues(item, [...path, index], hints),
0,
);
}
if (value && typeof value === "object") {
return Object.entries(value as Record<string, unknown>).reduce(
(count, [childKey, childValue]) =>
count + countSensitiveConfigValues(childValue, [...path, childKey], hints),
0,
);
}
return 0;
}

View File

@ -1,8 +1,12 @@
import { render } from "lit";
import { describe, expect, it, vi } from "vitest";
import { renderConfig } from "./config.ts";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { renderConfig, resetConfigViewStateForTests } from "./config.ts";
describe("config view", () => {
beforeEach(() => {
resetConfigViewStateForTests();
});
const baseProps = () => ({
raw: "{\n}\n",
originalRaw: "{\n}\n",
@ -20,11 +24,13 @@ describe("config view", () => {
schemaLoading: false,
uiHints: {},
formMode: "form" as const,
showModeToggle: true,
formValue: {},
originalValue: {},
searchQuery: "",
activeSection: null,
activeSubsection: null,
streamMode: false,
onRawChange: vi.fn(),
onFormModeChange: vi.fn(),
onFormPatch: vi.fn(),
@ -35,6 +41,13 @@ describe("config view", () => {
onApply: vi.fn(),
onUpdate: vi.fn(),
onSubsectionChange: vi.fn(),
version: "",
theme: "claw" as const,
themeMode: "system" as const,
setTheme: vi.fn(),
setThemeMode: vi.fn(),
gatewayUrl: "",
assistantName: "",
});
function findActionButtons(container: HTMLElement): {
@ -134,6 +147,102 @@ describe("config view", () => {
expect(applyButton?.disabled).toBe(false);
});
it("keeps raw secrets out of the DOM while stream mode is enabled", () => {
const container = document.createElement("div");
render(
renderConfig({
...baseProps(),
formMode: "raw",
streamMode: true,
raw: '{\n gateway: { auth: { token: "secret-123" } }\n}\n',
originalRaw: "{\n}\n",
formValue: { gateway: { auth: { token: "secret-123" } } },
uiHints: {
"gateway.auth.token": { sensitive: true },
},
}),
container,
);
const textarea = container.querySelector("textarea");
expect(textarea).not.toBeNull();
expect(textarea?.value).toBe("");
expect(textarea?.getAttribute("placeholder")).toContain("redacted");
const toggle = container.querySelector<HTMLButtonElement>(
'button[aria-label="Toggle raw config redaction"]',
);
expect(toggle?.disabled).toBe(true);
});
it("reveals raw secrets only after explicit toggle when stream mode is off", () => {
const container = document.createElement("div");
const props = {
...baseProps(),
formMode: "raw" as const,
streamMode: false,
raw: '{\n gateway: { auth: { token: "secret-123" } }\n}\n',
originalRaw: "{\n}\n",
formValue: { gateway: { auth: { token: "secret-123" } } },
uiHints: {
"gateway.auth.token": { sensitive: true },
},
};
render(renderConfig(props), container);
const initialTextarea = container.querySelector("textarea");
expect(initialTextarea?.value).toBe("");
const toggle = container.querySelector<HTMLButtonElement>(
'button[aria-label="Toggle raw config redaction"]',
);
expect(toggle?.disabled).toBe(false);
toggle?.click();
render(renderConfig(props), container);
const revealedTextarea = container.querySelector("textarea");
expect(revealedTextarea?.value).toContain("secret-123");
});
it("reveals env values through the peek control instead of CSS-only masking", () => {
const container = document.createElement("div");
const props = {
...baseProps(),
activeSection: "env" as const,
formMode: "form" as const,
streamMode: false,
schema: {
type: "object",
properties: {
env: {
type: "object",
additionalProperties: { type: "string" },
},
},
},
formValue: {
env: {
OPENAI_API_KEY: "secret-123",
},
},
};
render(renderConfig(props), container);
const hiddenInput = container.querySelector<HTMLInputElement>(".cfg-input:not(.cfg-input--sm)");
expect(hiddenInput?.value).toBe("");
const peekButton = Array.from(container.querySelectorAll<HTMLButtonElement>("button")).find(
(button) => button.textContent?.includes("Peek"),
);
peekButton?.click();
render(renderConfig(props), container);
const revealedInput = container.querySelector<HTMLInputElement>(
".cfg-input:not(.cfg-input--sm)",
);
expect(revealedInput?.value).toBe("secret-123");
});
it("switches mode via the sidebar toggle", () => {
const container = document.createElement("div");
const onFormModeChange = vi.fn();
@ -204,12 +313,7 @@ describe("config view", () => {
const container = document.createElement("div");
render(renderConfig(baseProps()), container);
const options = Array.from(container.querySelectorAll(".config-search__tag-option")).map(
(option) => option.textContent?.trim(),
);
expect(options).toContain("tag:security");
expect(options).toContain("tag:advanced");
expect(options).toHaveLength(15);
expect(container.querySelectorAll(".config-search__tag-option")).toHaveLength(0);
});
it("updates search query when toggling a tag option", () => {
@ -226,8 +330,7 @@ describe("config view", () => {
const option = container.querySelector<HTMLButtonElement>(
'.config-search__tag-option[data-tag="security"]',
);
expect(option).toBeTruthy();
option?.click();
expect(onSearchChange).toHaveBeenCalledWith("tag:security");
expect(option).toBeNull();
expect(onSearchChange).not.toHaveBeenCalled();
});
});

View File

@ -3,7 +3,14 @@ import { icons } from "../icons.ts";
import type { ThemeTransitionContext } from "../theme-transition.ts";
import type { ThemeMode, ThemeName } from "../theme.ts";
import type { ConfigUiHints } from "../types.ts";
import { humanize, schemaType, type JsonSchema } from "./config-form.shared.ts";
import {
countSensitiveConfigValues,
humanize,
pathKey,
REDACTED_PLACEHOLDER,
schemaType,
type JsonSchema,
} from "./config-form.shared.ts";
import { analyzeConfigSchema, renderConfigForm, SECTION_META } from "./config-form.ts";
export type ConfigProps = {
@ -494,42 +501,6 @@ function truncateValue(value: unknown, maxLen = 40): string {
return str.slice(0, maxLen - 3) + "...";
}
const SENSITIVE_KEY_RE = /token|password|secret|api.?key/i;
const SENSITIVE_KEY_WHITELIST_RE =
/maxtokens|maxoutputtokens|maxinputtokens|maxcompletiontokens|contexttokens|totaltokens|tokencount|tokenlimit|tokenbudget|passwordfile/i;
function countSensitiveValues(formValue: Record<string, unknown> | null): number {
if (!formValue) {
return 0;
}
let count = 0;
function walk(obj: unknown, key?: string) {
if (obj == null) {
return;
}
if (typeof obj === "object" && !Array.isArray(obj)) {
for (const [k, v] of Object.entries(obj as Record<string, unknown>)) {
walk(v, k);
}
} else if (Array.isArray(obj)) {
for (const item of obj) {
walk(item);
}
} else if (
key &&
typeof obj === "string" &&
SENSITIVE_KEY_RE.test(key) &&
!SENSITIVE_KEY_WHITELIST_RE.test(key)
) {
if (obj.trim() && !/^\$\{[^}]*\}$/.test(obj.trim())) {
count++;
}
}
}
walk(formValue);
return count;
}
type ThemeOption = { id: ThemeName; label: string; description: string; icon: TemplateResult };
const THEME_OPTIONS: ThemeOption[] = [
{ id: "claw", label: "Claw", description: "Chroma family", icon: icons.zap },
@ -644,7 +615,33 @@ function renderAppearanceSection(props: ConfigProps) {
}
let rawRevealed = false;
let envRevealed = false;
let validityDismissed = false;
const revealedSensitivePaths = new Set<string>();
function isSensitivePathRevealed(path: Array<string | number>): boolean {
const key = pathKey(path);
return key ? revealedSensitivePaths.has(key) : false;
}
function toggleSensitivePathReveal(path: Array<string | number>) {
const key = pathKey(path);
if (!key) {
return;
}
if (revealedSensitivePaths.has(key)) {
revealedSensitivePaths.delete(key);
} else {
revealedSensitivePaths.add(key);
}
}
export function resetConfigViewStateForTests() {
rawRevealed = false;
envRevealed = false;
validityDismissed = false;
revealedSensitivePaths.clear();
}
export function renderConfig(props: ConfigProps) {
const showModeToggle = props.showModeToggle ?? false;
@ -659,6 +656,7 @@ export function renderConfig(props: ConfigProps) {
unsupportedPaths: scopeUnsupportedPaths(rawAnalysis.unsupportedPaths, { include, exclude }),
};
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
const schemaProps = analysis.schema?.properties ?? {};
@ -949,17 +947,21 @@ export function renderConfig(props: ConfigProps) {
props.activeSection === "env"
? html`
<button
class="config-env-peek-btn"
title="Toggle value visibility"
@click=${(e: Event) => {
const btn = e.currentTarget as HTMLElement;
const content = btn
.closest(".config-main")
?.querySelector(".config-content");
if (content) {
content.classList.toggle("config-env-values--visible");
class="config-env-peek-btn ${envSensitiveVisible ? "config-env-peek-btn--active" : ""}"
title=${
props.streamMode
? "Disable stream mode to reveal env values"
: envSensitiveVisible
? "Hide env values"
: "Reveal env values"
}
?disabled=${props.streamMode}
@click=${() => {
if (props.streamMode) {
return;
}
btn.classList.toggle("config-env-peek-btn--active");
envRevealed = !envRevealed;
props.onRawChange(props.raw);
}}
>
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" width="16" height="16">
@ -976,7 +978,7 @@ export function renderConfig(props: ConfigProps) {
: nothing
}
<!-- Form content -->
<div class="config-content ${props.activeSection === "env" ? "config-env-values--blurred" : ""}">
<div class="config-content">
${
props.activeSection === "__appearance__"
? includeVirtualSections
@ -1003,6 +1005,14 @@ export function renderConfig(props: ConfigProps) {
searchQuery: props.searchQuery,
activeSection: props.activeSection,
activeSubsection: effectiveSubsection,
streamMode: props.streamMode,
revealSensitive:
props.activeSection === "env" ? envSensitiveVisible : false,
isSensitivePathRevealed,
onToggleSensitivePath: (path) => {
toggleSensitivePathReveal(path);
props.onRawChange(props.raw);
},
})
}
${
@ -1016,8 +1026,13 @@ export function renderConfig(props: ConfigProps) {
}
`
: (() => {
const sensitiveCount = countSensitiveValues(props.formValue);
const sensitiveCount = countSensitiveConfigValues(
props.formValue,
[],
props.uiHints,
);
const blurred = sensitiveCount > 0 && (props.streamMode || !rawRevealed);
const canReveal = sensitiveCount > 0 && !props.streamMode;
return html`
<label class="field config-raw-field">
<span style="display:flex;align-items:center;gap:8px;">
@ -1029,10 +1044,20 @@ export function renderConfig(props: ConfigProps) {
<button
class="btn btn--icon ${blurred ? "" : "active"}"
style="width:28px;height:28px;padding:0;"
title=${blurred ? "Reveal sensitive values" : "Hide sensitive values"}
title=${
canReveal
? blurred
? "Reveal sensitive values"
: "Hide sensitive values"
: "Disable stream mode to reveal sensitive values"
}
aria-label="Toggle raw config redaction"
aria-pressed=${!blurred}
?disabled=${!canReveal}
@click=${() => {
if (!canReveal) {
return;
}
rawRevealed = !rawRevealed;
props.onRawChange(props.raw);
}}
@ -1045,9 +1070,15 @@ export function renderConfig(props: ConfigProps) {
</span>
<textarea
class="${blurred ? "config-raw-redacted" : ""}"
.value=${props.raw}
@input=${(e: Event) =>
props.onRawChange((e.target as HTMLTextAreaElement).value)}
placeholder=${blurred ? REDACTED_PLACEHOLDER : "Raw JSON5 config"}
.value=${blurred ? "" : props.raw}
?readonly=${blurred}
@input=${(e: Event) => {
if (blurred) {
return;
}
props.onRawChange((e.target as HTMLTextAreaElement).value);
}}
></textarea>
</label>
`;