Skip to content

Commit 8eb77f5

Browse files
committed
feat(pii): model-editor pattern/builtins editor + Traces row
Add the React UI for the pattern detector tier: - PatternListEditor: add/remove rows of {name, match, action, min_len} with a monospace match input and a restricted-grammar hint. Server-side Validate is authoritative, so no regex engine ships to the client. - ConfigFieldRenderer: two branches keyed on field.component — pii-builtins-select (checkbox list of built-in secret patterns) and pii-pattern-list (PatternListEditor). - Traces: distinct badge colors for the pattern_pii and token_classify backend trace types. - e2e: built-in checklist + custom pattern-list editor specs. Assisted-by: claude-code:claude-opus-4-8 [Claude Code]
1 parent f90d43c commit 8eb77f5

4 files changed

Lines changed: 167 additions & 0 deletions

File tree

core/http/react-ui/e2e/model-config.spec.js

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,8 @@ const MOCK_METADATA = {
1212
{ path: 'cuda', yaml_key: 'cuda', go_type: 'bool', ui_type: 'bool', section: 'general', label: 'CUDA', description: 'Enable CUDA GPU acceleration', component: 'toggle', order: 30 },
1313
{ path: 'parameters.temperature', yaml_key: 'temperature', go_type: '*float64', ui_type: 'float', section: 'parameters', label: 'Temperature', description: 'Sampling temperature', component: 'slider', min: 0, max: 2, step: 0.1, order: 0 },
1414
{ path: 'parameters.top_p', yaml_key: 'top_p', go_type: '*float64', ui_type: 'float', section: 'parameters', label: 'Top P', description: 'Nucleus sampling threshold', component: 'slider', min: 0, max: 1, step: 0.05, order: 10 },
15+
{ path: 'pii_detection.builtins', yaml_key: 'builtins', go_type: '[]string', ui_type: '[]string', section: 'general', label: 'Built-in Secret Patterns', description: 'Built-in credential patterns', component: 'pii-builtins-select', options: [{ value: 'anthropic_api_key', label: 'anthropic_api_key — Anthropic API key' }, { value: 'github_token', label: 'github_token — GitHub token' }], order: 213 },
16+
{ path: 'pii_detection.patterns', yaml_key: 'patterns', go_type: '[]config.PIIPattern', ui_type: 'object', section: 'general', label: 'Custom Secret Patterns', description: 'Operator-defined restricted-regex patterns', component: 'pii-pattern-list', order: 214 },
1517
],
1618
}
1719

@@ -258,4 +260,31 @@ test.describe('Model Editor - Interactive Tab', () => {
258260
await expect(page.locator('nav').first()).toBeVisible()
259261
})
260262

263+
test('built-in secret patterns render as a checklist from field options', async ({ page }) => {
264+
const searchInput = page.locator('input[placeholder="Search fields to add..."]')
265+
await searchInput.fill('Built-in Secret Patterns')
266+
const dropdown = searchInput.locator('..').locator('..')
267+
await dropdown.locator('div', { hasText: 'Built-in Secret Patterns' }).first().click()
268+
269+
// One checkbox per catalogue option; toggling one enables Save.
270+
const anthropic = page.locator('label', { hasText: 'Anthropic API key' }).locator('input[type="checkbox"]')
271+
await expect(anthropic).toHaveCount(1)
272+
await anthropic.check()
273+
await expect(anthropic).toBeChecked()
274+
})
275+
276+
test('custom secret patterns render the pattern-list editor', async ({ page }) => {
277+
const searchInput = page.locator('input[placeholder="Search fields to add..."]')
278+
await searchInput.fill('Custom Secret Patterns')
279+
const dropdown = searchInput.locator('..').locator('..')
280+
await dropdown.locator('div', { hasText: 'Custom Secret Patterns' }).first().click()
281+
282+
// Empty state + an Add button; adding a row shows the name + match inputs.
283+
const addBtn = page.locator('button', { hasText: 'Add pattern' })
284+
await expect(addBtn).toBeVisible()
285+
await addBtn.click()
286+
await expect(page.locator('input[placeholder^="Name (group)"]')).toBeVisible()
287+
await expect(page.locator('input[placeholder^="match,"]')).toBeVisible()
288+
})
289+
261290
})

core/http/react-ui/src/components/ConfigFieldRenderer.jsx

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import AutocompleteInput from './AutocompleteInput'
77
import CodeEditor from './CodeEditor'
88
import StructuredCodeEditor from './StructuredCodeEditor'
99
import EntityActionListEditor from './EntityActionListEditor'
10+
import PatternListEditor from './PatternListEditor'
1011
import ModelMultiSelect from './ModelMultiSelect'
1112
import RouterCandidatesEditor from './RouterCandidatesEditor'
1213
import RouterPoliciesEditor from './RouterPoliciesEditor'
@@ -430,6 +431,45 @@ export default function ConfigFieldRenderer({ field, value, onChange, onRemove,
430431
)
431432
}
432433

434+
// PII built-in secret patterns — a checklist of named built-in patterns
435+
// (pii_detection.builtins). value is an array of selected names.
436+
if (component === 'pii-builtins-select') {
437+
const selected = Array.isArray(value) ? value : []
438+
const toggle = (name) => {
439+
handleChange(selected.includes(name) ? selected.filter(n => n !== name) : [...selected, name])
440+
}
441+
return (
442+
<div style={{ padding: 'var(--spacing-sm) 0', borderBottom: '1px solid var(--color-border-subtle)' }}>
443+
<div style={{ marginBottom: 4 }}>
444+
<div style={{ fontSize: '0.875rem', fontWeight: 500 }}><FieldLabel field={field} /></div>
445+
<div style={{ fontSize: '0.75rem', color: 'var(--color-text-muted)', marginTop: 2 }}>{description}</div>
446+
</div>
447+
<div style={{ display: 'flex', flexDirection: 'column', gap: 4 }}>
448+
{(field.options || []).map(opt => (
449+
<label key={opt.value} style={{ display: 'flex', alignItems: 'center', gap: 8, fontSize: '0.8125rem', cursor: 'pointer' }}>
450+
<input type="checkbox" checked={selected.includes(opt.value)} onChange={() => toggle(opt.value)} />
451+
{opt.label || opt.value}
452+
</label>
453+
))}
454+
</div>
455+
</div>
456+
)
457+
}
458+
459+
// PII custom secret patterns — operator-defined restricted-regex rules
460+
// (pii_detection.patterns). value is an array of {name, match, action, min_len}.
461+
if (component === 'pii-pattern-list') {
462+
return (
463+
<div style={{ padding: 'var(--spacing-sm) 0', borderBottom: '1px solid var(--color-border-subtle)' }}>
464+
<div style={{ marginBottom: 4 }}>
465+
<div style={{ fontSize: '0.875rem', fontWeight: 500 }}><FieldLabel field={field} /></div>
466+
<div style={{ fontSize: '0.75rem', color: 'var(--color-text-muted)', marginTop: 2 }}>{description}</div>
467+
</div>
468+
<PatternListEditor value={value} onChange={handleChange} />
469+
</div>
470+
)
471+
}
472+
433473
// Map editor
434474
if (component === 'map-editor') {
435475
return (
Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,96 @@
1+
import { useMemo } from 'react'
2+
import SearchableSelect from './SearchableSelect'
3+
4+
// Editor for a pattern detector's pii_detection.patterns: a list of
5+
// operator-defined secret patterns. Value is an array of
6+
// { name, match, action?, min_len? }; this renders one row per pattern and
7+
// emits a fresh array on every change. Patterns use a restricted regex subset
8+
// validated server-side at save (an invalid pattern surfaces as the save
9+
// error), so no regex engine is shipped to the client.
10+
11+
const ACTION_OPTIONS = [
12+
{ value: '', label: 'default (use Default Action)' },
13+
{ value: 'mask', label: 'mask — replace the span' },
14+
{ value: 'block', label: 'block — reject the request' },
15+
{ value: 'allow', label: 'allow — detect & log only' },
16+
]
17+
18+
function emptyPattern() {
19+
return { name: '', match: '', action: '', min_len: 0 }
20+
}
21+
22+
export default function PatternListEditor({ value, onChange }) {
23+
const rows = useMemo(() => (Array.isArray(value) ? value : []), [value])
24+
25+
const update = (index, patch) => {
26+
onChange(rows.map((r, i) => (i === index ? { ...r, ...patch } : r)))
27+
}
28+
const remove = (index) => onChange(rows.filter((_, i) => i !== index))
29+
const add = () => onChange([...rows, emptyPattern()])
30+
31+
return (
32+
<div style={{ display: 'flex', flexDirection: 'column', gap: 8, width: '100%' }}>
33+
<div style={{ fontSize: '0.75rem', color: 'var(--color-text-muted)' }}>
34+
Restricted regex: literals, <code>[…]</code> classes, <code>\w \d \s</code>, <code>?*+{'{m,n}'}</code>, anchors.
35+
Each pattern must contain a fixed literal run of ≥3 characters (e.g. <code>sk-prefix-</code>);
36+
<code>.</code> and capturing groups are not allowed. Matches report under the pattern name.
37+
</div>
38+
39+
{rows.length === 0 && (
40+
<div style={{ fontSize: '0.75rem', color: 'var(--color-text-muted)' }}>
41+
No custom patterns. Enable built-ins above, or add a pattern for an internal credential
42+
format (e.g. <code>tok-[A-Za-z0-9]{'{32,64}'}</code>).
43+
</div>
44+
)}
45+
46+
{rows.map((r, i) => (
47+
<div key={i} style={{ display: 'flex', gap: 6, alignItems: 'center', flexWrap: 'wrap' }}>
48+
<input
49+
className="input"
50+
value={r.name || ''}
51+
placeholder="Name (group), e.g. INTERNAL_TOKEN"
52+
onChange={e => update(i, { name: e.target.value })}
53+
style={{ flex: '1 1 180px', minWidth: 150, fontSize: '0.8125rem' }}
54+
aria-label="Pattern name"
55+
/>
56+
<input
57+
className="input input-mono"
58+
value={r.match || ''}
59+
placeholder="match, e.g. tok-[A-Za-z0-9]{32,64}"
60+
onChange={e => update(i, { match: e.target.value })}
61+
style={{ flex: '2 1 240px', minWidth: 200, fontSize: '0.8125rem', fontFamily: 'var(--font-mono)' }}
62+
aria-label="Pattern match"
63+
/>
64+
<SearchableSelect
65+
value={r.action || ''}
66+
onChange={v => update(i, { action: v })}
67+
options={ACTION_OPTIONS}
68+
placeholder="Action..."
69+
style={{ flex: '1 1 200px', minWidth: 180 }}
70+
/>
71+
<input
72+
className="input"
73+
type="number"
74+
min={0}
75+
value={r.min_len || 0}
76+
title="Minimum match length (0 = no floor)"
77+
onChange={e => update(i, { min_len: parseInt(e.target.value, 10) || 0 })}
78+
style={{ width: 80, fontSize: '0.8125rem' }}
79+
aria-label="Minimum length"
80+
/>
81+
<button type="button" className="btn btn-secondary btn-sm"
82+
onClick={() => remove(i)}
83+
style={{ padding: '2px 8px', fontSize: '0.75rem' }}
84+
aria-label="Remove pattern">
85+
<i className="fas fa-times" />
86+
</button>
87+
</div>
88+
))}
89+
90+
<button type="button" className="btn btn-secondary btn-sm" onClick={add}
91+
style={{ alignSelf: 'flex-start', fontSize: '0.75rem' }}>
92+
<i className="fas fa-plus" /> Add pattern
93+
</button>
94+
</div>
95+
)
96+
}

core/http/react-ui/src/pages/Traces.jsx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -75,6 +75,8 @@ const TYPE_COLORS = {
7575
detection: { bg: 'var(--color-info-light)', color: 'var(--color-data-8)' },
7676
model_load: { bg: 'var(--color-error-light)', color: 'var(--color-data-2)' },
7777
vector_store: { bg: 'var(--color-accent-light)', color: 'var(--color-data-7)' },
78+
token_classify: { bg: 'var(--color-info-light)', color: 'var(--color-data-3)' },
79+
pattern_pii: { bg: 'var(--color-error-light)', color: 'var(--color-data-2)' },
7880
}
7981

8082
function typeBadgeStyle(type) {

0 commit comments

Comments
 (0)