[eric] settings: legible secret-masked settings-change chip in the agent transcript

This commit is contained in:
ciregenz
2026-06-20 03:22:11 -07:00
parent 2e2fff0869
commit 6b9fd37489
3 changed files with 91 additions and 3 deletions
@@ -0,0 +1,73 @@
// Render an agent's SettingsWrite/SettingsRead so the transcript shows WHAT it
// touched at a glance, with secrets masked. The agent's raw `changes` input can
// carry a key it's trying to set, and the generic MCP renderer would paint it; a
// settings value is the one new place a secret could land on screen, so it's the
// one place we mask. Mirrors the backend's name rule (redaction.is_secret_field).
const SECRET_NAME_RE = /_(key|token|secret)$/i;
// Narrow, prefix-anchored so it can't mask a long path or system prompt (those
// have slashes/spaces); it only catches a real key pasted into a non-secret field.
const KEYISH_VALUE_RE = /^(sk-|sk-ant-|AIza|ghp_|gho_|github_pat_|xox[baprs]-)/;
export function isSettingsWriteTool(toolName: string): boolean {
return /__SettingsWrite$/.test(toolName);
}
export function isSettingsReadTool(toolName: string): boolean {
return /__SettingsRead$/.test(toolName);
}
function isSecretField(name: string): boolean {
return SECRET_NAME_RE.test(name) || name === 'installation_id';
}
function maskValue(key: string, value: unknown, cap: number): string {
if (isSecretField(key)) return '••••';
if (typeof value === 'string') {
if (KEYISH_VALUE_RE.test(value.trim())) return '••••';
return value.length > cap ? value.slice(0, cap) + '…' : value;
}
if (value === null || value === undefined) return 'none';
return JSON.stringify(value);
}
const LABELS: Record<string, string> = {
default_model: 'Default model',
default_mode: 'Default mode',
default_system_prompt: 'System prompt',
default_thinking_level: 'Thinking level',
default_max_turns: 'Max turns',
default_folder: 'Default folder',
theme: 'Theme',
browser_homepage: 'Browser homepage',
new_agent_shortcut: 'New-agent shortcut',
zoom_sensitivity: 'Zoom sensitivity',
};
function humanizeKey(key: string): string {
return LABELS[key] || key.replace(/_/g, ' ').replace(/^\w/, (ch) => ch.toUpperCase());
}
interface SettingsChangeRow {
label: string;
value: string;
}
function settingsChangeRows(input: unknown, cap: number): SettingsChangeRow[] {
const changes = (input as { changes?: unknown })?.changes;
if (!changes || typeof changes !== 'object') return [];
return Object.entries(changes as Record<string, unknown>)
// some models tack a free-form 'reason' onto the args; it isn't a setting.
.filter(([k]) => k !== 'reason')
.map(([k, v]) => ({ label: humanizeKey(k), value: maskValue(k, v, cap) }));
}
/** One-line header preview, e.g. "Theme → Light · Default model → Sonnet". */
export function settingsWriteSummary(input: unknown): string {
return settingsChangeRows(input, 40).map((r) => `${r.label}${r.value}`).join(' · ');
}
/** Expanded body: one masked change per line. Replaces the raw-JSON render. */
export function settingsWriteDisplay(input: unknown): string {
const rows = settingsChangeRows(input, 200);
return rows.length === 0 ? 'No changes.' : rows.map((r) => `${r.label}: ${r.value}`).join('\n');
}
@@ -1,6 +1,7 @@
import { AgentMessage } from '@/shared/state/agentsSlice';
import { prettyPath, prettyUrl, quoteQuery, bashCommandDetail } from './toolLabels';
import { parseMcpToolName, getMcpInputSummary, getGmailHeader } from '@/shared/mcpToolMeta';
import { isSettingsWriteTool, isSettingsReadTool, settingsWriteSummary, settingsWriteDisplay } from './settingsToolMeta';
export function getToolData(call: AgentMessage) {
const content = typeof call.content === 'object' ? call.content : {};
@@ -18,6 +19,11 @@ export function isBashTool(name: string) {
export function getInputSummary(toolName: string, input: any): string {
try {
// Settings tool first: legible change list, secrets masked. Must precede the
// generic MCP path (it is an MCP tool) or it renders raw changes JSON.
if (isSettingsWriteTool(toolName)) return settingsWriteSummary(input);
if (isSettingsReadTool(toolName)) return '';
const mcp = parseMcpToolName(toolName);
if (mcp.isMcp) return getMcpInputSummary(input);
@@ -60,6 +66,11 @@ function formatMcpInputDisplay(input: any): string {
export function formatInputDisplay(toolName: string, input: any): string {
try {
// Masked, one-per-line change list instead of raw changes JSON (which would
// paint a secret value the agent tried to set).
if (isSettingsWriteTool(toolName)) return settingsWriteDisplay(input);
if (isSettingsReadTool(toolName)) return '';
const mcp = parseMcpToolName(toolName);
if (mcp.isMcp) return formatMcpInputDisplay(input);
@@ -15,6 +15,7 @@ import { GoogleServiceIcon } from '../mcp-cards/GoogleServiceIcon';
import { ElapsedTimer, formatElapsed } from '../parsing/toolBubbleChrome';
import { useTermColors } from '../parsing/toolColorize';
import { ParsedResult } from '../parsing/toolResultParsing';
import { isSettingsWriteTool, settingsWriteSummary } from '../parsing/settingsToolMeta';
import { McpToolInfo, getMcpShortAction } from '@/shared/mcpToolMeta';
import { McpResultCard } from '../mcp-cards/McpResultCard';
@@ -56,6 +57,9 @@ export const CompactMcpBubble: React.FC<CompactMcpBubbleProps> = ({
const ServiceIcon = mcpInfo.isMcp && mcpInfo.service
? <GoogleServiceIcon service={mcpInfo.service} size={14} />
: null;
// A grouped settings write shows the masked change list (input-derived, so it
// reads even while pending) instead of the generic "Applied: theme" result line.
const headerSummary = isSettingsWriteTool(toolName) ? settingsWriteSummary(input) : resultSummary;
return (
<Box {...selectAttrs} sx={{ my: 0 }}>
@@ -83,7 +87,7 @@ export const CompactMcpBubble: React.FC<CompactMcpBubbleProps> = ({
>
{serviceLabel}
</Typography>
{resultSummary && !isError && (
{headerSummary && !isError && (
<Typography
sx={{
color: c.text.secondary,
@@ -95,10 +99,10 @@ export const CompactMcpBubble: React.FC<CompactMcpBubbleProps> = ({
: { overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }),
}}
>
{resultSummary}
{headerSummary}
</Typography>
)}
{!resultSummary && !showTimer && <Box sx={{ flex: 1 }} />}
{!headerSummary && !showTimer && <Box sx={{ flex: 1 }} />}
{showTimer && (
<>
<Box sx={{ flex: 1 }} />