mirror of
https://github.com/usebruno/bruno.git
synced 2026-07-08 06:28:33 +00:00
feat(ai): add security preferences management to AI settings (#8453)
This commit is contained in:
255
packages/bruno-app/src/components/Preferences/AI/SecurityPane.js
Normal file
255
packages/bruno-app/src/components/Preferences/AI/SecurityPane.js
Normal 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><string></code>, <code><number></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;
|
||||
@@ -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); }
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user