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}
+ />
+
+
+ Add
+
+
+
+ {atCapacity && (
+
+ Reached the {CHIP_MAX_COUNT}-entry limit. Remove one to add another.
+
+ )}
+
+ {values.length > 0 && (
+
+ {values.map((name) => (
+
+ {name}
+ handleRemove(name)}
+ aria-label={`Remove ${name}`}
+ data-testid={removeTestIdPrefix ? `${removeTestIdPrefix}-${name}` : undefined}
+ >
+
+
+
+ ))}
+
+ )}
+ >
+ );
+};
+
+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
+ setActiveTab('security')}
+ data-testid="ai-tab-security"
+ >
+
+ Security
+
{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 (
-
- {}} />
+
+
diff --git a/packages/bruno-app/src/providers/ReduxStore/slices/app.js b/packages/bruno-app/src/providers/ReduxStore/slices/app.js
index 22d7dd21d..e16ce0f06 100644
--- a/packages/bruno-app/src/providers/ReduxStore/slices/app.js
+++ b/packages/bruno-app/src/providers/ReduxStore/slices/app.js
@@ -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) => {
diff --git a/packages/bruno-electron/src/ipc/ai/autocomplete-prompts.js b/packages/bruno-electron/src/ipc/ai/autocomplete-prompts.js
index 89abab91b..654240769 100644
--- a/packages/bruno-electron/src/ipc/ai/autocomplete-prompts.js
+++ b/packages/bruno-electron/src/ipc/ai/autocomplete-prompts.js
@@ -70,8 +70,11 @@ const truncateLinesFromStart = (text, maxLines) => {
return lines.slice(0, maxLines).join('\n');
};
-const formatRequestContext = (ctx) => {
+const { redactJsonBodyString, buildRedactionPolicy } = require('./context');
+
+const formatRequestContext = (ctx, security) => {
if (!ctx) return '';
+ const policy = buildRedactionPolicy(security);
const parts = [];
if (ctx.url || ctx.method) {
parts.push(`${ctx.method || 'GET'} ${ctx.url || ''}`.trim());
@@ -85,7 +88,7 @@ const formatRequestContext = (ctx) => {
const body = ctx.body;
if (body && body.mode && body.mode !== 'none') {
let bodyText = '';
- if (body.mode === 'json') bodyText = body.json || '';
+ if (body.mode === 'json') bodyText = redactJsonBodyString(body.json || '', policy);
else if (body.mode === 'text') bodyText = body.text || '';
else if (body.mode === 'xml') bodyText = body.xml || '';
else if (body.mode === 'graphql') bodyText = body.graphql?.query || '';
@@ -118,7 +121,7 @@ const formatSiblingScripts = (siblings) => {
.join('\n\n');
};
-const buildUserPrompt = ({ prefix, suffix, scriptType, requestContext, variableNames, siblingScripts }) => {
+const buildUserPrompt = ({ prefix, suffix, scriptType, requestContext, variableNames, siblingScripts, security }) => {
// Keep recent context in the prefix (last 80 lines) and a short forward window (30 lines).
// Cloud LLMs can handle more, but trimming keeps latency low and the model focused.
const trimmedPrefix = truncateLines(prefix || '', 80);
@@ -126,7 +129,7 @@ const buildUserPrompt = ({ prefix, suffix, scriptType, requestContext, variableN
const sections = [];
- const reqStr = formatRequestContext(requestContext);
+ const reqStr = formatRequestContext(requestContext, security);
if (reqStr) sections.push(`### Request being scripted\n${reqStr}`);
const varStr = formatVariableNames(variableNames);
diff --git a/packages/bruno-electron/src/ipc/ai/autocomplete.js b/packages/bruno-electron/src/ipc/ai/autocomplete.js
index 650166f44..f97ae5357 100644
Binary files a/packages/bruno-electron/src/ipc/ai/autocomplete.js and b/packages/bruno-electron/src/ipc/ai/autocomplete.js differ
diff --git a/packages/bruno-electron/src/ipc/ai/chat.js b/packages/bruno-electron/src/ipc/ai/chat.js
index ce1c35886..58e3c1275 100644
--- a/packages/bruno-electron/src/ipc/ai/chat.js
+++ b/packages/bruno-electron/src/ipc/ai/chat.js
@@ -1,6 +1,7 @@
const { ipcMain } = require('electron');
const { streamText, stepCountIs } = require('ai');
const { z } = require('zod');
+const { get } = require('lodash');
const { CONTENT_TYPES, TOOL_LABELS, buildSystemPrompt, resolveContentType } = require('./chat-prompts');
const {
formatRequestContext,
@@ -9,6 +10,9 @@ const {
searchVariables,
formatSearchVariablesResult
} = require('./context');
+const { getPreferences } = require('../../store/preferences');
+
+const getSecurityPrefs = () => get(getPreferences(), 'ai.security', null);
const activeStreams = new Map();
@@ -20,14 +24,14 @@ const CONTENT_LABELS = {
'docs': 'Documentation'
};
-const buildContextMessage = (contentType, allContent, requestContext, variables) => {
+const buildContextMessage = (contentType, allContent, requestContext, variables, security) => {
const parts = [];
- const ctx = formatRequestContext(requestContext, { includeResponse: true });
+ const ctx = formatRequestContext(requestContext, { includeResponse: true, security });
if (ctx) {
parts.push(`HTTP Request Context:\n${ctx}`);
}
- const varsStr = formatVariablesList(variables);
+ const varsStr = formatVariablesList(variables, { security });
if (varsStr) {
parts.push(`Available Variables (names only — call search_variables(query) for a value):\n${varsStr}`);
}
@@ -134,6 +138,7 @@ const registerChatIpc = ({ mainWindow, resolveModel, pickDefaultModelId, isAiEna
const normalizedContent = allContent || {};
const effectiveType = contentType || 'app';
const hasMultiple = Object.values(normalizedContent).filter((c) => c && c.trim()).length > 1;
+ const security = getSecurityPrefs();
const readState = {};
const writeResults = [];
@@ -185,7 +190,7 @@ const registerChatIpc = ({ mainWindow, resolveModel, pickDefaultModelId, isAiEna
if (!status && data == null) {
return '(No response available — the request has not been executed yet. The user needs to run the request first.)';
}
- const formatted = formatResponseShape(status, data);
+ const formatted = formatResponseShape(status, data, { security });
return formatted || '(empty response)';
}
},
@@ -197,13 +202,13 @@ const registerChatIpc = ({ mainWindow, resolveModel, pickDefaultModelId, isAiEna
return '(No variables available — the collection has no environment, runtime, or collection variables defined.)';
}
const result = searchVariables(variables, query);
- return formatSearchVariablesResult(result, query);
+ return formatSearchVariablesResult(result, query, { security });
}
}
};
const allMessages = [
- { role: 'user', content: buildContextMessage(effectiveType, normalizedContent, requestContext, variables) },
+ { role: 'user', content: buildContextMessage(effectiveType, normalizedContent, requestContext, variables, security) },
...messages.map((m) => ({ role: m.role, content: m.content }))
];
diff --git a/packages/bruno-electron/src/ipc/ai/context.js b/packages/bruno-electron/src/ipc/ai/context.js
index 45cbfa636..364715039 100644
--- a/packages/bruno-electron/src/ipc/ai/context.js
+++ b/packages/bruno-electron/src/ipc/ai/context.js
@@ -37,6 +37,45 @@ const isSensitiveName = (name) => {
const maskValue = (name, value) => (isSensitiveName(name) ? REDACTED_VALUE : value);
+const normalizeList = (list) =>
+ Array.isArray(list)
+ ? list.map((s) => String(s || '').trim().toLowerCase()).filter(Boolean)
+ : [];
+
+const buildRedactionPolicy = (security) => {
+ const redactHeaders = security?.redactHeaders !== false;
+ const redactBody = security?.redactBody !== false;
+ const redactVariables = security?.redactVariables !== false;
+ const redactResponse = security?.redactResponse !== false;
+ const customHeaderSet = new Set(normalizeList(security?.customRedactedHeaders));
+ const customVarSet = new Set(normalizeList(security?.customRedactedVariables));
+
+ return {
+ redactHeaders,
+ redactBody,
+ redactVariables,
+ redactResponse,
+ isSensitiveHeader: (name) => {
+ if (!name) return false;
+ if (customHeaderSet.has(String(name).toLowerCase())) return true;
+ return isSensitiveName(name);
+ },
+ // Body checks reuse the header list — custom entries are matched exactly,
+ // so a user who adds `password` here also catches JSON keys named the same.
+ isSensitiveKey: (name) => {
+ if (!name) return false;
+ if (customHeaderSet.has(String(name).toLowerCase())) return true;
+ return isSensitiveName(name);
+ },
+ // Explicit custom entries always redact — the toggle only gates
+ // pattern-based matches, since the user opted into the specific names.
+ isCustomVariable: (name) => Boolean(name) && customVarSet.has(String(name).toLowerCase()),
+ matchesVariablePattern: (name) => Boolean(name) && isSensitiveName(name)
+ };
+};
+
+const DEFAULT_POLICY = buildRedactionPolicy(null);
+
// --- Response body shape redaction ---------------------------------------
const REDACTED_TRUNCATED = '';
@@ -76,8 +115,11 @@ const redactResponseValues = (data, depth = 0, maxDepth = 6) => {
return REDACTED_BY_TYPE[typeof data] || '';
};
-const formatResponseShape = (status, data) => {
+const RESPONSE_RAW_BODY_MAX_CHARS = 8000;
+
+const formatResponseShape = (status, data, opts = {}) => {
if (!status && data == null) return '';
+ const policy = buildRedactionPolicy(opts.security);
const parts = [];
if (status) parts.push(`**Last Response Status:** ${status}`);
@@ -93,12 +135,27 @@ const formatResponseShape = (status, data) => {
}
if (parsedOk) {
- const redacted = redactResponseValues(parsed);
- if (redacted != null) {
- parts.push(`**Response Shape (values redacted — ${REDACTION_NOTICE}):**\n\`\`\`json\n${JSON.stringify(redacted, null, 2)}\n\`\`\``);
+ if (policy.redactResponse) {
+ const redacted = redactResponseValues(parsed);
+ if (redacted != null) {
+ parts.push(`**Response Shape (values redacted — ${REDACTION_NOTICE}):**\n\`\`\`json\n${JSON.stringify(redacted, null, 2)}\n\`\`\``);
+ }
+ } else {
+ // User opted out of response-value redaction — send the real body,
+ // capped so a huge response doesn't blow the model's context window.
+ const raw = JSON.stringify(parsed, null, 2);
+ const truncated = raw.length > RESPONSE_RAW_BODY_MAX_CHARS;
+ const shown = truncated ? `${raw.slice(0, RESPONSE_RAW_BODY_MAX_CHARS)}…` : raw;
+ parts.push(`**Response Body:**\n\`\`\`json\n${shown}\n\`\`\`${truncated ? '\n\n(truncated)' : ''}`);
}
} else if (typeof data === 'string' && data.trim()) {
- parts.push(`**Response:** non-JSON, ${data.length} chars (call read_response() for the redacted view)`);
+ if (policy.redactResponse) {
+ parts.push(`**Response:** non-JSON, ${data.length} chars (call read_response() for the redacted view)`);
+ } else {
+ const truncated = data.length > RESPONSE_RAW_BODY_MAX_CHARS;
+ const shown = truncated ? `${data.slice(0, RESPONSE_RAW_BODY_MAX_CHARS)}…` : data;
+ parts.push(`**Response Body:**\n\`\`\`\n${shown}\n\`\`\`${truncated ? '\n\n(truncated)' : ''}`);
+ }
}
}
@@ -114,18 +171,17 @@ const formatResponseShape = (status, data) => {
* type placeholders): we DO want the model to see non-sensitive request body
* values so it can write code that references them correctly.
*/
-const redactJsonBodyValues = (data, depth = 0, maxDepth = 8) => {
+const redactJsonBodyValues = (data, policy, depth = 0, maxDepth = 8) => {
if (data === null || data === undefined) return data;
if (depth >= maxDepth) return REDACTED_TRUNCATED;
- if (Array.isArray(data)) return data.map((item) => redactJsonBodyValues(item, depth + 1, maxDepth));
+ if (Array.isArray(data)) return data.map((item) => redactJsonBodyValues(item, policy, depth + 1, maxDepth));
if (typeof data === 'object') {
const out = {};
for (const key of Object.keys(data)) {
- const val = data[key];
- if (isSensitiveName(key) && val !== null && typeof val !== 'object') {
+ if (policy.isSensitiveKey(key)) {
out[key] = REDACTED_VALUE;
} else {
- out[key] = redactJsonBodyValues(val, depth + 1, maxDepth);
+ out[key] = redactJsonBodyValues(data[key], policy, depth + 1, maxDepth);
}
}
return out;
@@ -133,11 +189,12 @@ const redactJsonBodyValues = (data, depth = 0, maxDepth = 8) => {
return data;
};
-const redactJsonBodyString = (raw) => {
+const redactJsonBodyString = (raw, policy = DEFAULT_POLICY) => {
if (typeof raw !== 'string' || !raw.trim()) return raw || '';
+ if (!policy.redactBody) return raw;
try {
const parsed = JSON.parse(raw);
- return JSON.stringify(redactJsonBodyValues(parsed), null, 2);
+ return JSON.stringify(redactJsonBodyValues(parsed, policy), null, 2);
} catch {
// Not parseable JSON — return as-is. The renderer-side patterns + variable
// redaction are the main line of defense for arbitrary text bodies.
@@ -157,15 +214,21 @@ const redactJsonBodyString = (raw) => {
* bodyMaxChars - truncate body to this many chars (default null = full)
* includeResponse - inline the redacted response shape (default false)
* includeDocs - include the request's docs field (default true)
+ * security - user-configured redaction toggles (see
+ * buildRedactionPolicy). Omit for strict defaults.
*/
const formatRequestContext = (ctx, opts = {}) => {
const {
includeBody = true,
bodyMaxChars = null,
includeResponse = false,
- includeDocs = true
+ includeDocs = true,
+ security = null
} = opts;
if (!ctx) return '';
+ const policy = buildRedactionPolicy(security);
+ const maskHeader = (name, value) => (policy.redactHeaders && policy.isSensitiveHeader(name) ? REDACTED_VALUE : (value ?? ''));
+ const maskFormField = (name, value) => (policy.redactBody && policy.isSensitiveKey(name) ? REDACTED_VALUE : (value ?? ''));
const parts = [];
if (ctx.url || ctx.method) {
@@ -174,17 +237,17 @@ const formatRequestContext = (ctx, opts = {}) => {
const headers = (ctx.headers || []).filter((h) => h?.enabled && h?.name);
if (headers.length) {
- parts.push(`**Headers:**\n${headers.map((h) => ` ${h.name}: ${maskValue(h.name, h.value ?? '')}`).join('\n')}`);
+ parts.push(`**Headers:**\n${headers.map((h) => ` ${h.name}: ${maskHeader(h.name, h.value)}`).join('\n')}`);
}
const params = (ctx.params || []).filter((p) => p?.enabled && p?.name);
const query = params.filter((p) => p.type === 'query' || !p.type);
const pathParams = params.filter((p) => p.type === 'path');
if (query.length) {
- parts.push(`**Query Parameters:**\n${query.map((p) => ` ${p.name}: ${maskValue(p.name, p.value ?? '')}`).join('\n')}`);
+ parts.push(`**Query Parameters:**\n${query.map((p) => ` ${p.name}: ${maskHeader(p.name, p.value)}`).join('\n')}`);
}
if (pathParams.length) {
- parts.push(`**Path Parameters:**\n${pathParams.map((p) => ` ${p.name}: ${maskValue(p.name, p.value ?? '')}`).join('\n')}`);
+ parts.push(`**Path Parameters:**\n${pathParams.map((p) => ` ${p.name}: ${maskHeader(p.name, p.value)}`).join('\n')}`);
}
const body = ctx.body;
@@ -194,18 +257,18 @@ const formatRequestContext = (ctx, opts = {}) => {
// JSON bodies often contain fields like `password`, `client_secret`,
// `refresh_token`. Redact by key so the model sees the structure but
// not the secret values.
- case 'json': content = redactJsonBodyString(body.json || ''); break;
+ case 'json': content = redactJsonBodyString(body.json || '', policy); break;
case 'text': content = body.text || ''; break;
case 'xml': content = body.xml || ''; break;
case 'sparql': content = body.sparql || ''; break;
case 'formUrlEncoded': {
const items = (body.formUrlEncoded || []).filter((p) => p.enabled);
- content = items.map((p) => ` ${p.name}: ${maskValue(p.name, p.value ?? '')}`).join('\n');
+ content = items.map((p) => ` ${p.name}: ${maskFormField(p.name, p.value)}`).join('\n');
break;
}
case 'multipartForm': {
const items = (body.multipartForm || []).filter((p) => p.enabled);
- content = items.map((p) => ` ${p.name}: ${p.type === 'file' ? '[file]' : maskValue(p.name, p.value ?? '')}`).join('\n');
+ content = items.map((p) => ` ${p.name}: ${p.type === 'file' ? '[file]' : maskFormField(p.name, p.value)}`).join('\n');
break;
}
case 'graphql':
@@ -213,7 +276,7 @@ const formatRequestContext = (ctx, opts = {}) => {
if (body.graphql?.variables) {
// GraphQL variables are stored as a JSON string in Bruno — same
// key-based redaction applies.
- content += `\n\nVariables:\n${redactJsonBodyString(body.graphql.variables)}`;
+ content += `\n\nVariables:\n${redactJsonBodyString(body.graphql.variables, policy)}`;
}
break;
default: content = '';
@@ -226,7 +289,7 @@ const formatRequestContext = (ctx, opts = {}) => {
}
if (includeResponse) {
- const responseStr = formatResponseShape(ctx.responseStatus, ctx.responseData);
+ const responseStr = formatResponseShape(ctx.responseStatus, ctx.responseData, { security });
if (responseStr) parts.push(responseStr);
}
@@ -250,11 +313,21 @@ const formatRequestContext = (ctx, opts = {}) => {
* too — never trust the value field on a secret. The backend re-masks.
*/
-const isSecretVariable = (v) => Boolean(v && (v.secret || isSensitiveName(v.name)));
+const isSecretVariable = (v, policy = DEFAULT_POLICY) => {
+ if (!v) return false;
+ // `secret: true` is a hard promise from the renderer's variable
+ // pipeline - always honor it, even when the pattern-match toggle is off.
+ if (v.secret) return true;
+ // Custom-listed names redact unconditionally, the user opted in explicitly.
+ if (policy.isCustomVariable(v.name)) return true;
+ // Built-in pattern matches only apply when the toggle is on.
+ if (!policy.redactVariables) return false;
+ return policy.matchesVariablePattern(v.name);
+};
-const variableValueForModel = (v) => {
+const variableValueForModel = (v, policy) => {
if (!v) return '';
- if (isSecretVariable(v)) return REDACTED_VALUE;
+ if (isSecretVariable(v, policy)) return REDACTED_VALUE;
if (v.value == null) return '';
return String(v.value);
};
@@ -265,8 +338,9 @@ const VAR_NAMES_PREVIEW_PER_SCOPE = 25;
* Inline preview shown in the prompt. Names + a small per-scope sample so
* the model knows what's available without us dumping 500 env vars.
*/
-const formatVariablesList = (variables) => {
+const formatVariablesList = (variables, opts = {}) => {
if (!Array.isArray(variables) || !variables.length) return '';
+ const policy = buildRedactionPolicy(opts.security);
const byScope = new Map();
for (const v of variables) {
@@ -282,7 +356,7 @@ const formatVariablesList = (variables) => {
const preview = list.slice(0, VAR_NAMES_PREVIEW_PER_SCOPE);
const more = total > preview.length ? ` (+${total - preview.length} more — use search_variables to find them)` : '';
const names = preview
- .map((v) => (isSecretVariable(v) ? `${v.name} (secret)` : v.name))
+ .map((v) => (isSecretVariable(v, policy) ? `${v.name} (secret)` : v.name))
.join(', ');
lines.push(`- ${scope} (${total}): ${names}${more}`);
}
@@ -307,20 +381,19 @@ const searchVariables = (variables, rawQuery, limit = SEARCH_LIMIT) => {
return { items: filtered.slice(0, limit), totalMatched: filtered.length, limit };
};
-const formatVariableLine = (v) => {
- const value = variableValueForModel(v);
- const tags = [v.scope || 'unknown'];
- if (isSecretVariable(v)) tags.push('secret');
- return ` ${v.name} = ${value} [${tags.join(', ')}]`;
-};
-
-const formatSearchVariablesResult = ({ items, totalMatched, limit }, query) => {
+const formatSearchVariablesResult = ({ items, totalMatched, limit }, query, opts = {}) => {
if (!items.length) {
return query
? `No variables match "${query}".`
: 'No variables defined for this collection/environment.';
}
- const lines = items.map(formatVariableLine);
+ const policy = buildRedactionPolicy(opts.security);
+ const lines = items.map((v) => {
+ const value = variableValueForModel(v, policy);
+ const tags = [v.scope || 'unknown'];
+ if (isSecretVariable(v, policy)) tags.push('secret');
+ return ` ${v.name} = ${value} [${tags.join(', ')}]`;
+ });
const heading = query
? `Found ${items.length}${totalMatched > items.length ? ` of ${totalMatched}` : ''} variable(s) matching "${query}":`
: `Variables (${items.length}${totalMatched > items.length ? ` of ${totalMatched}` : ''}):`;
@@ -337,11 +410,13 @@ module.exports = {
REDACTION_NOTICE,
isSensitiveName,
maskValue,
+ buildRedactionPolicy,
// response shape
redactResponseValues,
formatResponseShape,
// request context
formatRequestContext,
+ redactJsonBodyString,
// variables
isSecretVariable,
formatVariablesList,
diff --git a/packages/bruno-electron/src/ipc/ai/context.spec.js b/packages/bruno-electron/src/ipc/ai/context.spec.js
index a0b419be2..c498fa0b2 100644
--- a/packages/bruno-electron/src/ipc/ai/context.spec.js
+++ b/packages/bruno-electron/src/ipc/ai/context.spec.js
@@ -108,6 +108,50 @@ describe('ipc/ai/context', () => {
expect(out).not.toContain('secret-key');
});
+ it('redacts the full subtree under a sensitive key (not just direct primitives)', () => {
+ const out = formatRequestContext({
+ method: 'POST',
+ url: '/x',
+ headers: [],
+ params: [],
+ body: {
+ mode: 'json',
+ json: JSON.stringify({
+ password: { value: 'hunter2', hint: 'first pet' },
+ data: { safe: 'ok' }
+ })
+ }
+ });
+ expect(out).toContain('"password": ""');
+ expect(out).not.toContain('hunter2');
+ expect(out).not.toContain('first pet');
+ expect(out).toContain('"safe": "ok"');
+ });
+
+ it('masks formUrlEncoded values based on redactBody, not redactHeaders', () => {
+ const ctx = {
+ method: 'POST',
+ url: '/login',
+ headers: [],
+ params: [],
+ body: {
+ mode: 'formUrlEncoded',
+ formUrlEncoded: [
+ { name: 'username', value: 'alice', enabled: true },
+ { name: 'password', value: 'hunter2', enabled: true }
+ ]
+ }
+ };
+ // Headers-only off + body on: form field values still masked.
+ const bodyOn = formatRequestContext(ctx, { security: { redactHeaders: false, redactBody: true } });
+ expect(bodyOn).toContain('password: ');
+ expect(bodyOn).not.toContain('hunter2');
+
+ // Body off: raw values pass through even if headers still redacted.
+ const bodyOff = formatRequestContext(ctx, { security: { redactHeaders: true, redactBody: false } });
+ expect(bodyOff).toContain('password: hunter2');
+ });
+
it('redacts sensitive keys inside JSON bodies but keeps the shape', () => {
const out = formatRequestContext({
method: 'POST',
@@ -171,6 +215,53 @@ describe('ipc/ai/context', () => {
expect(out).toContain('…');
expect(out).not.toContain('x'.repeat(60));
});
+
+ it('leaves sensitive header/body values intact when security toggles are off', () => {
+ const out = formatRequestContext({
+ method: 'POST',
+ url: '/x',
+ headers: [{ name: 'Authorization', value: 'Bearer xyz', enabled: true }],
+ params: [],
+ body: { mode: 'json', json: JSON.stringify({ password: 'hunter2' }) }
+ }, { security: { redactHeaders: false, redactBody: false } });
+ expect(out).toContain('Authorization: Bearer xyz');
+ expect(out).toContain('hunter2');
+ expect(out).not.toContain('');
+ });
+
+ it('honors customRedactedHeaders for a user-added header name', () => {
+ const out = formatRequestContext({
+ method: 'GET',
+ url: '/x',
+ headers: [{ name: 'X-Trace-Id', value: 'trace-abc', enabled: true }],
+ params: [],
+ body: null
+ }, { security: { customRedactedHeaders: ['X-Trace-Id'] } });
+ expect(out).toContain('X-Trace-Id: ');
+ expect(out).not.toContain('trace-abc');
+ });
+
+ it('sends the raw response body when redactResponse is off', () => {
+ const base = {
+ method: 'GET',
+ url: '/x',
+ headers: [],
+ params: [],
+ body: null,
+ responseStatus: 200,
+ responseData: { user: { id: 42, email: 'a@b' } }
+ };
+ const redacted = formatRequestContext(base, { includeResponse: true });
+ expect(redacted).toContain('Response Shape');
+ expect(redacted).not.toContain('a@b');
+
+ const raw = formatRequestContext(base, {
+ includeResponse: true,
+ security: { redactResponse: false }
+ });
+ expect(raw).toContain('Response Body');
+ expect(raw).toContain('"email": "a@b"');
+ });
});
describe('formatVariablesList', () => {
@@ -190,6 +281,27 @@ describe('ipc/ai/context', () => {
expect(formatVariablesList([])).toBe('');
expect(formatVariablesList(null)).toBe('');
});
+
+ it('drops the (secret) tag from name-pattern matches when redactVariables is off', () => {
+ const out = formatVariablesList([
+ { name: 'API_TOKEN', value: 'v', scope: 'env', secret: false }
+ ], { security: { redactVariables: false } });
+ expect(out).not.toContain('(secret)');
+ });
+
+ it('always tags variables in customRedactedVariables as secret, even when redactVariables is off', () => {
+ const out = formatVariablesList([
+ { name: 'MY_SESSION', value: 'v', scope: 'env', secret: false }
+ ], { security: { redactVariables: false, customRedactedVariables: ['MY_SESSION'] } });
+ expect(out).toContain('MY_SESSION (secret)');
+ });
+
+ it('keeps secret: true variables tagged even with all toggles off', () => {
+ const out = formatVariablesList([
+ { name: 'plain_name', value: '', scope: 'env', secret: true }
+ ], { security: { redactVariables: false } });
+ expect(out).toContain('plain_name (secret)');
+ });
});
describe('searchVariables / formatSearchVariablesResult', () => {
diff --git a/packages/bruno-electron/src/ipc/ai/index.js b/packages/bruno-electron/src/ipc/ai/index.js
index 5ee84f9b1..2dda0afb2 100644
--- a/packages/bruno-electron/src/ipc/ai/index.js
+++ b/packages/bruno-electron/src/ipc/ai/index.js
@@ -31,6 +31,8 @@ const activeStreams = new Map();
const getAiPrefs = () => getPreferences().ai || {};
+const getSecurityPrefs = () => getAiPrefs().security || null;
+
const isEnabled = () => Boolean(getAiPrefs().enabled);
const buildStatus = () => {
@@ -191,6 +193,7 @@ const registerAiIpc = (mainWindow) => {
return { error: err.message };
}
+ const security = getSecurityPrefs();
const controller = streamId ? new AbortController() : null;
if (streamId && controller) {
activeStreams.set(streamId, controller);
@@ -211,7 +214,7 @@ const registerAiIpc = (mainWindow) => {
if (!status && data == null) {
return '(No response available — the request has not been executed yet.)';
}
- return formatResponseShape(status, data) || '(empty response)';
+ return formatResponseShape(status, data, { security }) || '(empty response)';
}
},
search_variables: {
@@ -227,7 +230,7 @@ const registerAiIpc = (mainWindow) => {
return '(No variables available — the collection has no environment, runtime, or collection variables defined.)';
}
const result = searchVariables(variables, query);
- return formatSearchVariablesResult(result, query);
+ return formatSearchVariablesResult(result, query, { security });
}
}
};
@@ -242,7 +245,8 @@ const registerAiIpc = (mainWindow) => {
requestContext,
docsContext,
variables,
- scriptType
+ scriptType,
+ security
}),
tools,
// Cap tool-call iteration — the model gets a few chances to look
diff --git a/packages/bruno-electron/src/ipc/ai/script-prompts.js b/packages/bruno-electron/src/ipc/ai/script-prompts.js
index fdeba23e1..0a383d5ff 100644
--- a/packages/bruno-electron/src/ipc/ai/script-prompts.js
+++ b/packages/bruno-electron/src/ipc/ai/script-prompts.js
@@ -299,7 +299,8 @@ const buildScriptUserPrompt = ({
requestContext,
docsContext,
variables,
- scriptType
+ scriptType,
+ security
}) => {
const sections = [];
const docsContextStr = formatDocsContext(docsContext);
@@ -308,10 +309,10 @@ const buildScriptUserPrompt = ({
// Same redaction rules as the chat sidebar — sensitive headers/params masked,
// response shape only (no real values). Body is sent in full so the model
// can write code that references real keys.
- const contextStr = formatRequestContext(requestContext, { includeResponse: true });
+ const contextStr = formatRequestContext(requestContext, { includeResponse: true, security });
if (contextStr) sections.push(`HTTP Request Context\n${contextStr}`);
- const varsStr = formatVariablesList(variables);
+ const varsStr = formatVariablesList(variables, { security });
if (varsStr) {
sections.push(`Available Variables (names only — call search_variables(query) for a value)\n${varsStr}`);
}
diff --git a/packages/bruno-electron/src/store/preferences.js b/packages/bruno-electron/src/store/preferences.js
index bb3b59c9b..f809badf2 100644
--- a/packages/bruno-electron/src/store/preferences.js
+++ b/packages/bruno-electron/src/store/preferences.js
@@ -86,6 +86,14 @@ const defaultPreferences = {
enabled: true,
model: '',
triggerMode: 'debounced'
+ },
+ security: {
+ redactHeaders: true,
+ redactBody: true,
+ redactVariables: true,
+ redactResponse: true,
+ customRedactedHeaders: [],
+ customRedactedVariables: []
}
}
};
@@ -182,6 +190,14 @@ const preferencesSchema = Yup.object().shape({
enabled: Yup.boolean(),
model: Yup.string().max(200).nullable(),
triggerMode: Yup.string().oneOf(['aggressive', 'debounced', 'manual']).nullable()
+ }).optional(),
+ security: Yup.object({
+ 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)
}).optional()
}).optional()
});