diff --git a/packages/bruno-app/src/components/Preferences/AI/SecurityPane.js b/packages/bruno-app/src/components/Preferences/AI/SecurityPane.js new file mode 100644 index 000000000..3dd967a87 --- /dev/null +++ b/packages/bruno-app/src/components/Preferences/AI/SecurityPane.js @@ -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 ( + <> +
+ setDraft(e.target.value)} + onKeyDown={handleKeyDown} + disabled={atCapacity} + data-testid={inputTestId} + /> + +
+ + {atCapacity && ( + + Reached the {CHIP_MAX_COUNT}-entry limit. Remove one to add another. + + )} + + {values.length > 0 && ( + + )} + + ); +}; + +const SecurityPane = ({ + aiEnabled, + redactHeaders, + redactBody, + redactVariables, + redactResponse, + customRedactedHeaders, + customRedactedVariables, + onToggleRedactHeaders, + onToggleRedactBody, + onToggleRedactVariables, + onToggleRedactResponse, + onChangeCustomRedactedHeaders, + onChangeCustomRedactedVariables +}) => { + if (!aiEnabled) { + return ( +
+
+ Turn on AI in the Configuration tab to configure redaction. +
+
+ ); + } + + return ( +
+
+ 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. +
+ +
+
+
+ Redact sensitive header values + + Masks Authorization, cookies, API keys, and other credential-bearing headers in the request context. + +
+ onToggleRedactHeaders(!redactHeaders)} + data-testid="ai-security-headers-toggle" + /> +
+ +
+
+ Redact sensitive body keys + + Masks values under keys like password, *_token, secret in JSON and GraphQL variables. Structure and non-sensitive fields still pass through. + +
+ onToggleRedactBody(!redactBody)} + data-testid="ai-security-body-toggle" + /> +
+ +
+
+ Redact response values + + Sends the response as a shape only — real values replaced with type placeholders (<string>, <number>). Turn off to send the actual response body. + +
+ onToggleRedactResponse(!redactResponse)} + data-testid="ai-security-response-toggle" + /> +
+ +
+
+ Redact secret variable values + + Masks values whose names look like secrets. Variables explicitly marked secret are always redacted regardless of this switch. + +
+ onToggleRedactVariables(!redactVariables)} + data-testid="ai-security-variables-toggle" + /> +
+
+ +
+
+
+ Custom redacted headers + + Exact, case-insensitive header names to always mask on top of the built-in list. + +
+ +
+ +
+
+ Custom redacted variables + + 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 secret. + +
+ +
+ +
+ Already covered by default +
+ {BUILT_IN_HEADER_EXAMPLES.map((name) => ( + {name} + ))} + + plus any name matching token, secret, password, or api_key. + +
+
+
+
+ ); +}; + +export default SecurityPane; diff --git a/packages/bruno-app/src/components/Preferences/AI/StyledWrapper.js b/packages/bruno-app/src/components/Preferences/AI/StyledWrapper.js index b6e5d5312..adba3b50b 100644 --- a/packages/bruno-app/src/components/Preferences/AI/StyledWrapper.js +++ b/packages/bruno-app/src/components/Preferences/AI/StyledWrapper.js @@ -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); } diff --git a/packages/bruno-app/src/components/Preferences/AI/index.js b/packages/bruno-app/src/components/Preferences/AI/index.js index 8294d8a8f..33f12082c 100644 --- a/packages/bruno-app/src/components/Preferences/AI/index.js +++ b/packages/bruno-app/src/components/Preferences/AI/index.js @@ -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 = () => { Autocomplete + {statusError && ( @@ -430,6 +476,26 @@ const AI = () => { /> )} + + {activeTab === 'security' && ( +
+ 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)} + /> +
+ )} ); }; diff --git a/packages/bruno-app/src/components/ToggleSwitch/StyledWrapper.js b/packages/bruno-app/src/components/ToggleSwitch/StyledWrapper.js index 3e624be2d..e1c7aa7f4 100644 --- a/packages/bruno-app/src/components/ToggleSwitch/StyledWrapper.js +++ b/packages/bruno-app/src/components/ToggleSwitch/StyledWrapper.js @@ -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%; } `; diff --git a/packages/bruno-app/src/components/ToggleSwitch/index.js b/packages/bruno-app/src/components/ToggleSwitch/index.js index 655e10b8b..b12f10876 100644 --- a/packages/bruno-app/src/components/ToggleSwitch/index.js +++ b/packages/bruno-app/src/components/ToggleSwitch/index.js @@ -3,9 +3,17 @@ import { Checkbox, Inner, Label, Switch, SwitchButton } from './StyledWrapper'; const ToggleSwitch = ({ isOn, handleToggle, size = 'm', activeColor, ...props }) => { const id = useId(); + return ( - - {}} /> + +