diff --git a/ui/src/components/shared/code-editor.tsx b/ui/src/components/shared/code-editor.tsx
index 1a65aed4..cccf534e 100644
--- a/ui/src/components/shared/code-editor.tsx
+++ b/ui/src/components/shared/code-editor.tsx
@@ -79,11 +79,50 @@ function validateToml(code: string): ValidationResult {
const SENSITIVE_PATTERNS: Record<'json' | 'yaml' | 'toml', RegExp> = {
// For JSON we scan the whole document text (not anchored to line starts) so
// we can catch keys inside nested objects without re-parsing.
- json: /"([^"\\]+)"\s*:\s*/g,
- yaml: /^(\s*)([A-Za-z0-9_.-]+)\s*:\s*/gm,
- toml: /^(\s*)([A-Za-z0-9_.-]+)\s*=\s*/gm,
+ json: /"((?:[^"\\]|\\.)+)"\s*:\s*/g,
+ yaml: /^(\s*)("(?:[^"\\]|\\.)*"|'[^']*'|[A-Za-z0-9_.-]+)\s*:\s*/gm,
+ // TOML keys may be bare (`API_KEY`), basic-string ("..."), or literal-string
+ // ('...'). Capture all three so quoted-key configs are not silently skipped.
+ toml: /^(\s*)("(?:[^"\\]|\\.)*"|'[^']*'|[A-Za-z0-9_.-]+)\s*=\s*/gm,
};
+/**
+ * Strip the surrounding quotes (and unescape minimally for basic strings) from
+ * a key captured by the patterns above.
+ */
+function normalizeKey(raw: string): string {
+ if (raw.length >= 2 && raw[0] === '"' && raw[raw.length - 1] === '"') {
+ return raw.slice(1, -1).replace(/\\(.)/g, '$1');
+ }
+ if (raw.length >= 2 && raw[0] === "'" && raw[raw.length - 1] === "'") {
+ return raw.slice(1, -1);
+ }
+ return raw;
+}
+
+/**
+ * Advance past a TOML/JSON string starting at `start` (whose first char is the
+ * opening quote). Basic strings (") honor backslash escapes; literal strings
+ * (') do not.
+ */
+function skipString(text: string, start: number, quote: '"' | "'"): number {
+ let i = start + 1;
+ if (quote === "'") {
+ const idx = text.indexOf("'", i);
+ return idx >= 0 ? idx + 1 : text.length;
+ }
+ while (i < text.length) {
+ const ch = text[i];
+ if (ch === '\\') {
+ i += 2;
+ continue;
+ }
+ if (ch === '"') return i + 1;
+ i++;
+ }
+ return text.length;
+}
+
/**
* Locate the end offset of the value that begins at `valueStart`. Handles
* multi-line TOML strings (""" … """, ''' … '''), arrays ([ … ]), YAML block
@@ -107,15 +146,16 @@ function findValueEnd(
return closeIdx >= 0 ? valueStart + 3 + closeIdx + 3 : docLen;
}
if (lookahead[0] === '[') {
- // Track bracket depth, skipping content inside strings.
+ // Track bracket depth, skipping content inside strings. Use the
+ // backslash-aware skipper so `\"` inside basic strings does not
+ // prematurely close the string scan.
+ const text = doc.toString();
let depth = 0;
let i = valueStart;
while (i < docLen) {
- const ch = doc.sliceString(i, i + 1);
+ const ch = text[i];
if (ch === '"' || ch === "'") {
- const rest = doc.sliceString(i + 1, docLen);
- const closeIdx = rest.indexOf(ch);
- i = closeIdx >= 0 ? i + 1 + closeIdx + 1 : docLen;
+ i = skipString(text, i, ch);
continue;
}
if (ch === '[') depth++;
@@ -157,25 +197,13 @@ function findValueEnd(
if (lookahead[0] === '[' || lookahead[0] === '{') {
const openCh = lookahead[0];
const closeCh = openCh === '[' ? ']' : '}';
+ const text = doc.toString();
let depth = 0;
let i = valueStart;
while (i < docLen) {
- const ch = doc.sliceString(i, i + 1);
+ const ch = text[i];
if (ch === '"') {
- // Skip past JSON string, honoring backslash escapes.
- i++;
- while (i < docLen) {
- const c = doc.sliceString(i, i + 1);
- if (c === '\\') {
- i += 2;
- continue;
- }
- if (c === '"') {
- i++;
- break;
- }
- i++;
- }
+ i = skipString(text, i, '"');
continue;
}
if (ch === openCh) depth++;
@@ -206,10 +234,10 @@ function buildSensitiveDecorations(
let key: string;
let keyIndent: number;
if (language === 'json') {
- key = match[1];
+ key = normalizeKey(`"${match[1]}"`);
keyIndent = 0;
} else {
- key = match[2];
+ key = normalizeKey(match[2]);
keyIndent = match[1].length;
}
if (!isSensitiveKey(key)) continue;
diff --git a/ui/tests/unit/components/ui/code-editor.test.tsx b/ui/tests/unit/components/ui/code-editor.test.tsx
index 2dc501fa..4ac14eb1 100644
--- a/ui/tests/unit/components/ui/code-editor.test.tsx
+++ b/ui/tests/unit/components/ui/code-editor.test.tsx
@@ -120,6 +120,30 @@ describe('CodeEditor', () => {
expect(masked).not.toContain('visible');
});
+ it('masks values whose key uses a TOML quoted-key form', () => {
+ const value = ['"API_KEY" = "supersecret"', 'visible = "ok"'].join('\n');
+
+ const { container } = render();
+ const masked = Array.from(container.querySelectorAll('.cm-sensitive-mask'))
+ .map((el) => el.textContent ?? '')
+ .join('');
+ expect(masked).toContain('supersecret');
+ expect(masked).not.toContain('ok');
+ });
+
+ it('respects escaped quotes inside TOML array values when masking', () => {
+ const value = ['AUTH_TOKEN = ["a \\"quoted\\" value", "x"]', 'visible = "ok"'].join('\n');
+
+ const { container } = render();
+ const masked = Array.from(container.querySelectorAll('.cm-sensitive-mask'))
+ .map((el) => el.textContent ?? '')
+ .join('');
+ // The closing bracket must be reached; both array members and the
+ // unrelated `visible` line stay on their respective sides of the mask.
+ expect(masked).toContain('"x"');
+ expect(masked).not.toContain('ok');
+ });
+
it('masks an entire multi-line TOML array value bound to a sensitive key', () => {
const value = ['AUTH_TOKEN = [', ' "first",', ' "second",', ']', 'visible = "ok"'].join('\n');