mirror of
https://github.com/usebruno/bruno.git
synced 2026-07-08 14:35:03 +00:00
feat(ai): add security preferences management to AI settings (#8453)
This commit is contained in:
@@ -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);
|
||||
|
||||
Binary file not shown.
@@ -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 }))
|
||||
];
|
||||
|
||||
|
||||
@@ -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 = '<truncated>';
|
||||
@@ -76,8 +115,11 @@ const redactResponseValues = (data, depth = 0, maxDepth = 6) => {
|
||||
return REDACTED_BY_TYPE[typeof data] || '<unknown>';
|
||||
};
|
||||
|
||||
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,
|
||||
|
||||
@@ -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": "<redacted>"');
|
||||
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: <redacted>');
|
||||
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('<redacted>');
|
||||
});
|
||||
|
||||
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: <redacted>');
|
||||
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: '<redacted>', scope: 'env', secret: true }
|
||||
], { security: { redactVariables: false } });
|
||||
expect(out).toContain('plain_name (secret)');
|
||||
});
|
||||
});
|
||||
|
||||
describe('searchVariables / formatSearchVariablesResult', () => {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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}`);
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user