feat(ai): add security preferences management to AI settings (#8453)

This commit is contained in:
naman-bruno
2026-07-02 14:42:48 +05:30
committed by GitHub
parent 022426a3ab
commit 408aef9eef
14 changed files with 734 additions and 66 deletions

View File

@@ -0,0 +1,255 @@
import { useState } from 'react';
import { IconPlus, IconTrash } from '@tabler/icons';
import ToggleSwitch from 'components/ToggleSwitch';
const BUILT_IN_HEADER_EXAMPLES = [
'Authorization',
'Proxy-Authorization',
'Cookie',
'Set-Cookie',
'X-API-Key',
'X-Auth-Token',
'X-Access-Token',
'X-CSRF-Token'
];
const normalize = (raw) => String(raw || '').trim();
/**
* Compact editor for a case-insensitive name list. Used for both custom
* header names and custom variable names — the shape is identical.
*/
const CHIP_MAX_LENGTH = 200;
const CHIP_MAX_COUNT = 200;
const ChipListEditor = ({ list, placeholder, onChange, addTestId, inputTestId, removeTestIdPrefix }) => {
const [draft, setDraft] = useState('');
const values = Array.isArray(list) ? list : [];
const atCapacity = values.length >= CHIP_MAX_COUNT;
const handleAdd = () => {
const value = normalize(draft);
if (!value || value.length > CHIP_MAX_LENGTH || atCapacity) return;
if (values.some((v) => v.toLowerCase() === value.toLowerCase())) {
setDraft('');
return;
}
onChange([...values, value]);
setDraft('');
};
const handleRemove = (name) => {
onChange(values.filter((v) => v !== name));
};
const handleKeyDown = (e) => {
if (e.key === 'Enter') {
e.preventDefault();
handleAdd();
}
};
const trimmedDraft = normalize(draft);
const draftTooLong = trimmedDraft.length > CHIP_MAX_LENGTH;
const addDisabled = !trimmedDraft || draftTooLong || atCapacity;
return (
<>
<div className="security-add-row flex items-center gap-2">
<input
type="text"
className="security-input flex-1"
placeholder={placeholder}
value={draft}
maxLength={CHIP_MAX_LENGTH}
onChange={(e) => setDraft(e.target.value)}
onKeyDown={handleKeyDown}
disabled={atCapacity}
data-testid={inputTestId}
/>
<button
type="button"
className="security-add-btn inline-flex items-center gap-1 text-[11px] font-medium"
onClick={handleAdd}
disabled={addDisabled}
data-testid={addTestId}
>
<IconPlus size={13} strokeWidth={1.75} />
Add
</button>
</div>
{atCapacity && (
<span className="security-sub text-[10.5px]">
Reached the {CHIP_MAX_COUNT}-entry limit. Remove one to add another.
</span>
)}
{values.length > 0 && (
<ul className="security-chip-list flex flex-wrap gap-1.5">
{values.map((name) => (
<li key={name} className="security-chip inline-flex items-center gap-1">
<span className="security-chip-text">{name}</span>
<button
type="button"
className="security-chip-remove"
onClick={() => handleRemove(name)}
aria-label={`Remove ${name}`}
data-testid={removeTestIdPrefix ? `${removeTestIdPrefix}-${name}` : undefined}
>
<IconTrash size={11} strokeWidth={1.75} />
</button>
</li>
))}
</ul>
)}
</>
);
};
const SecurityPane = ({
aiEnabled,
redactHeaders,
redactBody,
redactVariables,
redactResponse,
customRedactedHeaders,
customRedactedVariables,
onToggleRedactHeaders,
onToggleRedactBody,
onToggleRedactVariables,
onToggleRedactResponse,
onChangeCustomRedactedHeaders,
onChangeCustomRedactedVariables
}) => {
if (!aiEnabled) {
return (
<div className="security-tab flex flex-col gap-3">
<div className="ai-empty-notice px-3.5 py-3 text-xs">
Turn on AI in the Configuration tab to configure redaction.
</div>
</div>
);
}
return (
<div className="security-tab flex flex-col gap-3">
<div className="ai-empty-notice px-3.5 py-3 text-xs">
Bruno strips sensitive values from the context it sends to AI providers. Toggle any check off if it gets in the way, or extend the lists below.
</div>
<div className="security-card">
<div className="security-row flex items-center justify-between gap-3 px-3.5 py-3">
<div className="flex flex-col gap-0.5 min-w-0">
<span className="text-[12.5px] font-semibold">Redact sensitive header values</span>
<span className="security-sub text-[11px]">
Masks Authorization, cookies, API keys, and other credential-bearing headers in the request context.
</span>
</div>
<ToggleSwitch
size="m"
isOn={redactHeaders}
handleToggle={() => onToggleRedactHeaders(!redactHeaders)}
data-testid="ai-security-headers-toggle"
/>
</div>
<div className="security-row flex items-center justify-between gap-3 px-3.5 py-3">
<div className="flex flex-col gap-0.5 min-w-0">
<span className="text-[12.5px] font-semibold">Redact sensitive body keys</span>
<span className="security-sub text-[11px]">
Masks values under keys like <code>password</code>, <code>*_token</code>, <code>secret</code> in JSON and GraphQL variables. Structure and non-sensitive fields still pass through.
</span>
</div>
<ToggleSwitch
size="m"
isOn={redactBody}
handleToggle={() => onToggleRedactBody(!redactBody)}
data-testid="ai-security-body-toggle"
/>
</div>
<div className="security-row flex items-center justify-between gap-3 px-3.5 py-3">
<div className="flex flex-col gap-0.5 min-w-0">
<span className="text-[12.5px] font-semibold">Redact response values</span>
<span className="security-sub text-[11px]">
Sends the response as a shape only real values replaced with type placeholders (<code>&lt;string&gt;</code>, <code>&lt;number&gt;</code>). Turn off to send the actual response body.
</span>
</div>
<ToggleSwitch
size="m"
isOn={redactResponse}
handleToggle={() => onToggleRedactResponse(!redactResponse)}
data-testid="ai-security-response-toggle"
/>
</div>
<div className="security-row flex items-center justify-between gap-3 px-3.5 py-3">
<div className="flex flex-col gap-0.5 min-w-0">
<span className="text-[12.5px] font-semibold">Redact secret variable values</span>
<span className="security-sub text-[11px]">
Masks values whose names look like secrets. Variables explicitly marked <em>secret</em> are always redacted regardless of this switch.
</span>
</div>
<ToggleSwitch
size="m"
isOn={redactVariables}
handleToggle={() => onToggleRedactVariables(!redactVariables)}
data-testid="ai-security-variables-toggle"
/>
</div>
</div>
<div className="security-card">
<div className="security-row flex flex-col gap-2 px-3.5 py-3">
<div className="flex flex-col gap-0.5">
<span className="text-[12.5px] font-semibold">Custom redacted headers</span>
<span className="security-sub text-[11px]">
Exact, case-insensitive header names to always mask on top of the built-in list.
</span>
</div>
<ChipListEditor
list={customRedactedHeaders}
placeholder="X-Custom-Token"
onChange={onChangeCustomRedactedHeaders}
inputTestId="ai-security-custom-header-input"
addTestId="ai-security-custom-header-add"
removeTestIdPrefix="ai-security-custom-header-remove"
/>
</div>
<div className="security-row flex flex-col gap-2 px-3.5 py-3">
<div className="flex flex-col gap-0.5">
<span className="text-[12.5px] font-semibold">Custom redacted variables</span>
<span className="security-sub text-[11px]">
Variable names whose values should always be masked when Bruno lists them for the model for anything you want redacted besides values already flagged as <em>secret</em>.
</span>
</div>
<ChipListEditor
list={customRedactedVariables}
placeholder="MY_SESSION_TOKEN"
onChange={onChangeCustomRedactedVariables}
inputTestId="ai-security-custom-var-input"
addTestId="ai-security-custom-var-add"
removeTestIdPrefix="ai-security-custom-var-remove"
/>
</div>
<div className="security-row flex flex-col gap-1 px-3.5 py-3">
<span className="text-[11px] font-medium security-sub">Already covered by default</span>
<div className="security-builtin flex flex-wrap gap-1.5">
{BUILT_IN_HEADER_EXAMPLES.map((name) => (
<span key={name} className="security-builtin-chip">{name}</span>
))}
<span className="security-builtin-more text-[10.5px]">
plus any name matching <code>token</code>, <code>secret</code>, <code>password</code>, or <code>api_key</code>.
</span>
</div>
</div>
</div>
</div>
);
};
export default SecurityPane;

View File

@@ -466,6 +466,118 @@ const StyledWrapper = styled.div`
}
}
.security-card {
border: 1px solid ${(props) => props.theme.input.border};
border-radius: ${(props) => props.theme.border.radius.md};
background: ${(props) => props.theme.input.bg};
}
.security-sub {
color: ${(props) => props.theme.colors.text.muted};
code {
font-family: ${(props) => props.theme.font.monospace || 'monospace'};
color: ${(props) => props.theme.text};
}
}
.security-row + .security-row {
border-top: 1px dashed ${(props) => props.theme.input.border};
}
.security-input {
padding: 5px 8px;
font-size: 12px;
font-family: ${(props) => props.theme.font.monospace || 'monospace'};
border-radius: ${(props) => props.theme.border.radius.sm};
border: 1px solid ${(props) => props.theme.input.border};
background: ${(props) => props.theme.bg};
color: ${(props) => props.theme.text};
&::placeholder {
color: ${(props) => props.theme.colors.text.muted};
opacity: 0.7;
}
&:focus {
outline: none;
border-color: ${(props) => props.theme.input.focusBorder};
}
}
.security-add-btn {
color: ${(props) => props.theme.colors.text.muted};
background: transparent;
border: 1px solid ${(props) => props.theme.input.border};
border-radius: ${(props) => props.theme.border.radius.sm};
padding: 4px 10px;
cursor: pointer;
transition: color 0.15s ease, border-color 0.15s ease;
&:hover:not(:disabled) {
color: ${(props) => props.theme.text};
border-color: ${(props) => props.theme.colors.accent}80;
}
&:disabled {
opacity: 0.5;
cursor: not-allowed;
}
}
.security-chip-list {
list-style: none;
padding: 0;
margin: 0;
}
.security-chip {
padding: 3px 4px 3px 8px;
font-size: 11px;
font-family: ${(props) => props.theme.font.monospace || 'monospace'};
border: 1px solid ${(props) => props.theme.input.border};
border-radius: ${(props) => props.theme.border.radius.sm};
background: ${(props) => props.theme.bg};
color: ${(props) => props.theme.text};
}
.security-chip-remove {
display: inline-flex;
align-items: center;
justify-content: center;
padding: 2px;
border: none;
background: transparent;
color: ${(props) => props.theme.colors.text.muted};
cursor: pointer;
border-radius: ${(props) => props.theme.border.radius.sm};
transition: color 0.15s ease, background-color 0.15s ease;
&:hover {
color: ${(props) => props.theme.colors.text.danger};
background: ${(props) => props.theme.colors.bg.danger}15;
}
}
.security-builtin-chip {
padding: 2px 7px;
font-size: 10.5px;
font-family: ${(props) => props.theme.font.monospace || 'monospace'};
color: ${(props) => props.theme.colors.text.muted};
border: 1px dashed ${(props) => props.theme.input.border};
border-radius: ${(props) => props.theme.border.radius.sm};
}
.security-builtin-more {
color: ${(props) => props.theme.colors.text.muted};
align-self: center;
code {
font-family: ${(props) => props.theme.font.monospace || 'monospace'};
color: ${(props) => props.theme.text};
}
}
@keyframes spin {
from { transform: rotate(0deg); }
to { transform: rotate(360deg); }

View File

@@ -6,13 +6,14 @@ import { useFormik } from 'formik';
import { useDispatch, useSelector } from 'react-redux';
import * as Yup from 'yup';
import toast from 'react-hot-toast';
import { IconPlus, IconSettings, IconTerminal2 } from '@tabler/icons';
import { IconPlus, IconSettings, IconShieldLock, IconTerminal2 } from '@tabler/icons';
import { savePreferences } from 'providers/ReduxStore/slices/app';
import ToggleSwitch from 'components/ToggleSwitch';
import { clearAiApiKey, getAiStatus } from 'utils/ai';
import ProviderCard from './ProviderCard';
import CompatEndpointCard from './CompatEndpointCard';
import AutocompletePane from './AutocompletePane';
import SecurityPane from './SecurityPane';
import StyledWrapper from './StyledWrapper';
const OPENAI_COMPATIBLE_PREFIX = 'openai-compatible:';
@@ -41,6 +42,14 @@ const aiPreferencesSchema = Yup.object().shape({
enabled: Yup.boolean(),
model: Yup.string().max(200).nullable(),
triggerMode: Yup.string().oneOf(['aggressive', 'debounced', 'manual']).nullable()
}),
security: Yup.object().shape({
redactHeaders: Yup.boolean(),
redactBody: Yup.boolean(),
redactVariables: Yup.boolean(),
redactResponse: Yup.boolean(),
customRedactedHeaders: Yup.array().of(Yup.string().max(200)).max(200),
customRedactedVariables: Yup.array().of(Yup.string().max(200)).max(200)
})
});
@@ -69,6 +78,12 @@ const AI = () => {
const formik = useFormik({
enableReinitialize: true,
// Skip per-change validation — every toggle would otherwise re-run the
// full nested schema (arrays of endpoints × models × …), which adds tens
// of ms of blocking work per click. debouncedSave already validates via
// `aiPreferencesSchema.validate` right before persisting.
validateOnChange: false,
validateOnBlur: false,
initialValues: {
enabled: get(preferences, 'ai.enabled', false),
providers: providerIds.reduce((acc, id) => {
@@ -82,6 +97,14 @@ const AI = () => {
enabled: get(preferences, 'ai.autocomplete.enabled', true),
model: get(preferences, 'ai.autocomplete.model', ''),
triggerMode: get(preferences, 'ai.autocomplete.triggerMode', 'debounced')
},
security: {
redactHeaders: get(preferences, 'ai.security.redactHeaders', true),
redactBody: get(preferences, 'ai.security.redactBody', true),
redactVariables: get(preferences, 'ai.security.redactVariables', true),
redactResponse: get(preferences, 'ai.security.redactResponse', true),
customRedactedHeaders: get(preferences, 'ai.security.customRedactedHeaders', []),
customRedactedVariables: get(preferences, 'ai.security.customRedactedVariables', [])
}
},
validationSchema: aiPreferencesSchema,
@@ -103,6 +126,18 @@ const AI = () => {
enabled: values.autocomplete?.enabled !== false,
model: values.autocomplete?.model || '',
triggerMode: values.autocomplete?.triggerMode || 'debounced'
},
security: {
redactHeaders: values.security?.redactHeaders !== false,
redactBody: values.security?.redactBody !== false,
redactVariables: values.security?.redactVariables !== false,
redactResponse: values.security?.redactResponse !== false,
customRedactedHeaders: Array.isArray(values.security?.customRedactedHeaders)
? values.security.customRedactedHeaders
: [],
customRedactedVariables: Array.isArray(values.security?.customRedactedVariables)
? values.security.customRedactedVariables
: []
}
}
})
@@ -270,6 +305,17 @@ const AI = () => {
<IconTerminal2 size={14} strokeWidth={1.5} />
Autocomplete
</button>
<button
type="button"
role="tab"
aria-selected={activeTab === 'security'}
className={`ai-tab ${activeTab === 'security' ? 'active' : ''}`}
onClick={() => setActiveTab('security')}
data-testid="ai-tab-security"
>
<IconShieldLock size={14} strokeWidth={1.5} />
Security
</button>
</div>
{statusError && (
@@ -430,6 +476,26 @@ const AI = () => {
/>
</div>
)}
{activeTab === 'security' && (
<div className="ai-tab-panel" role="tabpanel">
<SecurityPane
aiEnabled={formik.values.enabled}
redactHeaders={formik.values.security?.redactHeaders !== false}
redactBody={formik.values.security?.redactBody !== false}
redactVariables={formik.values.security?.redactVariables !== false}
redactResponse={formik.values.security?.redactResponse !== false}
customRedactedHeaders={formik.values.security?.customRedactedHeaders || []}
customRedactedVariables={formik.values.security?.customRedactedVariables || []}
onToggleRedactHeaders={(next) => formik.setFieldValue('security.redactHeaders', next)}
onToggleRedactBody={(next) => formik.setFieldValue('security.redactBody', next)}
onToggleRedactVariables={(next) => formik.setFieldValue('security.redactVariables', next)}
onToggleRedactResponse={(next) => formik.setFieldValue('security.redactResponse', next)}
onChangeCustomRedactedHeaders={(next) => formik.setFieldValue('security.customRedactedHeaders', next)}
onChangeCustomRedactedVariables={(next) => formik.setFieldValue('security.customRedactedVariables', next)}
/>
</div>
)}
</StyledWrapper>
);
};

View File

@@ -52,7 +52,7 @@ export const Label = styled.label`
height: 100%;
background-color: ${(props) => props.theme.colors.text.muted};
border-radius: 24px;
transition: transform 0.2s;
transition: transform 0.1s ease-out, background-color 0.1s ease-out;
}
`;
@@ -63,7 +63,7 @@ export const Inner = styled.div`
right: 2px;
bottom: 2px;
background-color: #fafafa;
transition: 0.4s;
transition: background-color 0.1s ease-out;
border-radius: ${(props) => getSizeValues(props.size).height - 2}px;
`;
@@ -74,7 +74,7 @@ export const SwitchButton = styled.div`
left: 2px;
bottom: 2px;
background-color: white;
transition: 0.4s;
transition: transform 0.1s ease-out;
border-radius: 50%;
&:before {
@@ -85,7 +85,7 @@ export const SwitchButton = styled.div`
background-color: white;
top: 2px;
left: 2px;
transition: 0.4s;
transition: transform 0.1s ease-out;
border-radius: 50%;
}
`;

View File

@@ -3,9 +3,17 @@ import { Checkbox, Inner, Label, Switch, SwitchButton } from './StyledWrapper';
const ToggleSwitch = ({ isOn, handleToggle, size = 'm', activeColor, ...props }) => {
const id = useId();
return (
<Switch size={size} {...props} onClick={handleToggle}>
<Checkbox checked={isOn} id={id} type="checkbox" size={size} activeColor={activeColor} onChange={() => {}} />
<Switch size={size} {...props}>
<Checkbox
checked={isOn}
id={id}
type="checkbox"
size={size}
activeColor={activeColor}
onChange={handleToggle}
/>
<Label htmlFor={id}>
<Inner size={size} />
<SwitchButton size={size} />

View File

@@ -73,6 +73,14 @@ const initialState = {
enabled: true,
model: '',
triggerMode: 'debounced'
},
security: {
redactHeaders: true,
redactBody: true,
redactVariables: true,
redactResponse: true,
customRedactedHeaders: [],
customRedactedVariables: []
}
}
},
@@ -292,15 +300,18 @@ export const {
} = appSlice.actions;
export const savePreferences = (preferences) => (dispatch, getState) => {
return new Promise((resolve, reject) => {
const { ipcRenderer } = window;
const previous = getState().app.preferences;
dispatch(updatePreferences(preferences));
ipcRenderer
.invoke('renderer:save-preferences', preferences)
.then(() => dispatch(updatePreferences(preferences)))
.then(resolve)
.catch(reject);
});
const { ipcRenderer } = window;
return ipcRenderer
.invoke('renderer:save-preferences', preferences)
.catch((err) => {
if (getState().app.preferences === preferences) {
dispatch(updatePreferences(previous));
}
throw err;
});
};
export const deleteCookiesForDomain = (domain) => (dispatch, getState) => {