Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 4 additions & 15 deletions SECURITY.md
Original file line number Diff line number Diff line change
Expand Up @@ -33,9 +33,7 @@ attacker-influenced string.

## 1. Reporting a vulnerability

Report privately by emailing **security@maka.app** (or open a
private GitHub Security Advisory once that's enabled). Please do
NOT open public issues for security vulnerabilities.
Report privately by emailing **security@apache.org**. Please do NOT open public issues for security vulnerabilities.

Useful reports include:

Expand Down Expand Up @@ -153,10 +151,7 @@ selection rules are documented in
5. **Settings sensitive masking.** Tokens, API keys, and proxy
passwords are masked at the IPC store boundary
(`maskAppSettings` in `apps/desktop/src/main/settings-ipc-helpers.ts`).
Re-submitting the mask sentinel `••••••` is interpreted as
"keep current" by the merge logic; an empty string is
interpreted as an explicit clear. The Tavily API key follows the
same boundary.
Re-submitting the mask sentinel `••••••••` (or the legacy `••••••`) is interpreted as "keep current" by the merge logic; an empty string is interpreted as an explicit clear. The Tavily API key follows the same boundary.
6. **Network egress through user-configured proxies.** The
`network.proxy` settings drive Electron's session proxy. Tools
that bypass `proxiedFetch` (Tavily lives in main, uses
Expand Down Expand Up @@ -250,21 +245,15 @@ SUBMITS a token when the user pastes it into a Settings input.
The main process NEVER returns the cleartext token to the
renderer.**

Reads of `settings:get` return the bullet-mask sentinel `••••••`
for all sensitive fields. The renderer can detect "has a stored
value" vs "empty" by comparing against the sentinel, but cannot
ever see the value. An update payload that contains exactly the
sentinel is interpreted by
`mergeWebSearchSettings` (and its peers in `mergeSettings`) as
"keep current", so a round-trip through `settings:get →
settings:update` cannot accidentally overwrite the stored value.
Reads of `settings:get` return the bullet-mask sentinel `••••••••` for masked sensitive fields. The renderer can detect "has a stored value" vs "empty" by comparing against the sentinel, but cannot ever see the value. An update payload that contains exactly that sentinel or the legacy `••••••` is interpreted as "keep current" by the credential update and settings merge paths, so a round-trip through `settings:get → settings:update` cannot accidentally overwrite the stored value.

Credential-test requests may submit an unsaved cleartext token so the
user can verify it before saving. The main process accepts it for that
single request and does not echo it in the response.

The contract tests for this policy:
- `apps/desktop/src/main/__tests__/settings-ipc-helpers.test.ts`
- `apps/desktop/src/main/__tests__/runtime-host-settings-ipc-main.test.ts`
- `apps/desktop/src/main/__tests__/runtime-host-connections-ipc-main.test.ts`

## 5. Versioning
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,8 @@

import assert from 'node:assert/strict';
import { describe, test } from 'node:test';
import type { BotIncomingMessage, BotRegistry } from '@maka/runtime/bots';
import { BaseBotAdapter, type BotIncomingMessage, type BotRegistry } from '@maka/runtime/bots';
import { createDefaultBotChannel } from '@maka/core/bot-chat-settings';
import { createBotIncomingMainService } from '../bot-incoming-main.js';
import { BotSessionUnavailableError } from '../bot-session-adapter.js';
import { waitFor as pollFor } from '@maka/core/test-only/async-primitives';
Expand All @@ -31,6 +32,12 @@ async function waitFor(predicate: () => boolean): Promise<void> {
});
}

class IncomingAdapter extends BaseBotAdapter {
async start(): Promise<void> {}
async stop(): Promise<void> {}
receive(message: BotIncomingMessage): void { this.emitIncomingMessage(message); }
}

describe('bot session lifecycle bindings', () => {
test('rebinds a conversation after its archived session rejects a send', async () => {
const created: string[] = [];
Expand Down Expand Up @@ -127,7 +134,7 @@ describe('bot session lifecycle bindings', () => {
});
});

test('WorkHub routing bypasses Bot Session creation, explore preparation and reset commands', async () => {
test('channel admission precedes WorkHub routing, Bot Session creation and reset commands', async () => {
let routed = 0;
let closed = false;
const unexpected = async (): Promise<never> => { throw new Error('Bot Session must not be used'); };
Expand All @@ -139,7 +146,15 @@ test('WorkHub routing bypasses Bot Session creation, explore preparation and res
async close() { closed = true; },
},
});
await service.handleBotIncomingMessage({ platform: 'qq', userId: 'user', userName: 'User', chatId: 'chat', isGroup: false, receivedAt: 1, text: '重置', sourceMessageId: 'source' });
const adapter = new IncomingAdapter('qq', { ...createDefaultBotChannel('qq'), allowedUserIds: ['user'] });
const pending: Promise<void>[] = [];
adapter.on('message', (message: BotIncomingMessage) => pending.push(service.handleBotIncomingMessage(message)));
const message: BotIncomingMessage = { platform: 'qq', userId: 'outsider', userName: 'User', chatId: 'chat', isGroup: false, receivedAt: 1, text: '重置', sourceMessageId: 'source' };
adapter.receive(message);
await Promise.all(pending);
assert.equal(routed, 0);
adapter.receive({ ...message, userId: 'user' });
await Promise.all(pending);
assert.equal(routed, 1);
await service.close();
assert.equal(closed, true);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -19,14 +19,15 @@

import assert from "node:assert/strict";
import test from "node:test";
import { SENSITIVE_PLACEHOLDER } from "@maka/core/settings/network-settings";
import {
createDefaultSettings,
mergeSettings,
type UpdateAppSettingsInput,
} from "@maka/core/settings";
import { registerClientSettingsIpc } from "../client-settings-ipc-main.js";

test("client settings updates filter Host policy and return newly submitted secrets", async () => {
test("client settings updates filter Host policy and mask newly submitted secrets", async () => {
const handlers = new Map<string, (...args: unknown[]) => unknown>();
let settings = createDefaultSettings();
let storedPatch: UpdateAppSettingsInput | undefined;
Expand Down Expand Up @@ -63,8 +64,9 @@ test("client settings updates filter Host policy and return newly submitted secr
assert.equal((result as { settings: typeof settings }).settings.appearance.theme, "dark");
assert.equal(
(result as { settings: typeof settings }).settings.botChat.channels.telegram.token,
"fresh-bot-token",
SENSITIVE_PLACEHOLDER,
);
assert.equal(settings.botChat.channels.telegram.token, "fresh-bot-token");
assert.equal(settings.chatDefaults.permissionMode, "bypass");
assert.equal(applied, 1);
});
Original file line number Diff line number Diff line change
Expand Up @@ -547,9 +547,61 @@ test('Jev settings write Host policy and vault, project only a mask, and never p
const saved = await module.update({ jev: { apiKey: 'jev-secret', enabled: true } });
assert.deepEqual(saved.jev, { enabled: true, apiKey: '••••••••' });
assert.equal(JSON.stringify(local).includes('jev-secret'), false);
await module.update({ jev: { apiKey: '••••••••', enabled: false } });
assert.equal(secret, 'jev-secret');
for (const apiKey of ['••••••••', '••••••']) {
await module.update({ jev: { apiKey, enabled: false } });
assert.equal(secret, 'jev-secret');
}
const removed = await module.update({ jev: { apiKey: '', enabled: true } });
assert.deepEqual(removed.jev, { enabled: false, apiKey: '' });
assert.equal(secret, undefined);
});

test('Tavily IPC round-trips preserve the vault key for current and legacy masks', async () => {
let policy = createDefaultRuntimePolicy();
let secret: string | undefined;
let revision = 0;
const local = createDefaultSettings();
const module = createRuntimeHostSettingsModule({
client: {
queryRuntimePolicy: async () => ({ revision: 1, policy }),
queryCredential: async (locator: { scope: string }) => locator.scope === 'web_search' && secret
? { configured: true, credentialId: 'tavily-key', revision, updatedAt: 1, locator } : null,
setCredential: async (input: { locator: { scope: string }; secret: string }) => {
assert.equal(input.locator.scope, 'web_search');
secret = input.secret;
revision += 1;
return { kind: 'committed' };
},
deleteCredential: async () => { secret = undefined; return { kind: 'committed' }; },
updateRuntimePolicy: async (mutation: (current: RuntimePolicy) => { kind: string; value: RuntimePolicy['webSearch'] }) => {
const operation = mutation(policy);
assert.equal(operation.kind, 'set_web_search');
policy = { ...policy, webSearch: operation.value };
return { revision: 2, policy };
},
} as never,
settingsStore: { get: async () => local, update: async () => { assert.fail('Host keys must not enter Desktop settings'); } } as never,
applyClientSettings: async () => {},
});
const handlers = new Map<string, (...args: unknown[]) => unknown>();
registerRuntimeHostSettingsIpc({
ipcMain: { handle(channel, listener) { handlers.set(channel, listener as (...args: unknown[]) => unknown); } },
module,
});
const update = handlers.get('settings:update')!;
const get = handlers.get('settings:get')!;
await update({}, { webSearch: { providers: { tavily: { apiKey: 'stored-tavily-key' } } } });
const saved = await get({}) as ReturnType<typeof createDefaultSettings>;
assert.equal(saved.webSearch.providers.tavily.apiKey, '••••••••');
for (const apiKey of [saved.webSearch.providers.tavily.apiKey, '••••••']) {
await update({}, { webSearch: { ...saved.webSearch, providers: { tavily: { ...saved.webSearch.providers.tavily, apiKey } } } });
assert.equal(secret, 'stored-tavily-key');
assert.equal(revision, 1);
}
await update({}, { webSearch: { providers: { tavily: { apiKey: 'replacement-key' } } } });
assert.equal(secret, 'replacement-key');
await update({}, { webSearch: { providers: { tavily: { apiKey: '' } } } });
assert.equal(secret, undefined);
const cleared = await get({}) as ReturnType<typeof createDefaultSettings>;
assert.equal(cleared.webSearch.providers.tavily.apiKey, '');
});
46 changes: 29 additions & 17 deletions apps/desktop/src/main/__tests__/settings-ipc-helpers.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,11 @@

import assert from "node:assert/strict";
import { describe, test } from "node:test";
import { createDefaultSettings, mergeSettings } from "@maka/core/settings";
import { mkdtemp, rm } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { createSettingsStore } from "@maka/storage/settings-store";
import { BOT_PROVIDERS, createDefaultSettings, mergeSettings } from "@maka/core/settings";
import { SENSITIVE_PLACEHOLDER } from "@maka/core/settings/network-settings";
import {
buildSettingsUpdateResult,
Expand Down Expand Up @@ -65,30 +69,38 @@ describe("settings IPC helpers", () => {
}
});

test("reveals sensitive fields only when the current patch explicitly changes them", () => {
const settings = createDefaultSettings();
settings.botChat.channels.telegram.token = "new-bot-token";
settings.botChat.channels.feishu.appSecret = "stored-feishu-secret";

const masked = maskAppSettings(settings, {
botChat: { channels: { telegram: { token: "new-bot-token" } } },
});

assert.equal(masked.botChat.channels.telegram.token, "new-bot-token");
assert.equal(
masked.botChat.channels.feishu.appSecret,
SENSITIVE_PLACEHOLDER,
);
test("masks bot credentials in save responses and preserves them across masked saves and reloads", async () => {
const workspaceRoot = await mkdtemp(join(tmpdir(), "maka-bot-settings-"));
try {
const store = createSettingsStore(workspaceRoot);
for (const provider of BOT_PROVIDERS) {
const patch = { botChat: { channels: { [provider]: { token: "stored-token", appSecret: "stored-secret" } } } };
const configured = await store.update(patch);
const response = buildSettingsUpdateResult(configured, patch);
assert.equal(response.settings.botChat.channels[provider].token, SENSITIVE_PLACEHOLDER, provider);
assert.equal(response.settings.botChat.channels[provider].appSecret, SENSITIVE_PLACEHOLDER, provider);
await store.update({ botChat: response.settings.botChat });
const saved = await createSettingsStore(workspaceRoot).get();
assert.equal(saved.botChat.channels[provider].token, "stored-token", provider);
assert.equal(saved.botChat.channels[provider].appSecret, "stored-secret", provider);
await store.update({ botChat: { channels: { [provider]: { token: "", appSecret: "" } } } });
const cleared = await createSettingsStore(workspaceRoot).get();
assert.equal(cleared.botChat.channels[provider].token, "", provider);
assert.ok(!cleared.botChat.channels[provider].appSecret, provider);
}
} finally {
await rm(workspaceRoot, { recursive: true, force: true });
}
});

test("never reveals Tavily API key back to renderer, even on the save response", () => {
const settings = mergeSettings(createDefaultSettings(), {
webSearch: { providers: { tavily: { apiKey: "tvly-new-secret" } } },
});

const masked = maskAppSettings(settings, {
const masked = buildSettingsUpdateResult(settings, {
webSearch: { providers: { tavily: { apiKey: "tvly-new-secret" } } },
});
}).settings;

assert.equal(
masked.webSearch.providers.tavily.apiKey,
Expand Down
2 changes: 2 additions & 0 deletions apps/desktop/src/main/bot-onboarding-main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -376,6 +376,8 @@ export class BotOnboardingService {
ownedFields: ReadonlyArray<keyof BotChannelSettings>,
): Promise<void> {
const previousValues = pickChannelFields(previousChannel, ownedFields);
// An absent pre-onboarding secret needs an explicit clear on rollback.
if ('appSecret' in previousValues) previousValues.appSecret ??= '';
const rollbackPatch: UpdateAppSettingsInput = {
botChat: { channels: { [provider]: previousValues } },
};
Expand Down
8 changes: 4 additions & 4 deletions apps/desktop/src/main/runtime-host-settings-ipc-main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@ import type {
CredentialStatus,
RuntimePolicy,
} from "@maka/core/runtime-policy";
import { SENSITIVE_PLACEHOLDER } from "@maka/core/settings/network-settings";
import { isSensitivePlaceholder, SENSITIVE_PLACEHOLDER } from "@maka/core/settings/network-settings";
import type {
TestProxyInput,
TestProxySettings,
Expand Down Expand Up @@ -352,8 +352,8 @@ async function applyHostPatchWithoutLane(
let skippedCredentials = 0;
if (patch.jev) {
const apiKey = patch.jev.apiKey;
const removingKey = typeof apiKey === "string" && apiKey !== SENSITIVE_PLACEHOLDER && !apiKey.trim();
if (apiKey !== undefined && apiKey !== SENSITIVE_PLACEHOLDER) {
const removingKey = typeof apiKey === "string" && !isSensitivePlaceholder(apiKey) && !apiKey.trim();
if (apiKey !== undefined && !isSensitivePlaceholder(apiKey)) {
if (apiKey.trim()) await setCredential(client, JEV_CREDENTIAL, apiKey.trim());
else await deleteCredential(client, JEV_CREDENTIAL);
}
Expand Down Expand Up @@ -439,7 +439,7 @@ async function applyHostPatchWithoutLane(
},
}));
const apiKey = webSearch.providers?.tavily?.apiKey;
if (apiKey !== undefined && apiKey !== SENSITIVE_PLACEHOLDER) {
if (apiKey !== undefined && !isSensitivePlaceholder(apiKey)) {
if (apiKey.length === 0)
await deleteCredential(client, WEB_SEARCH_CREDENTIAL);
else await setCredential(client, WEB_SEARCH_CREDENTIAL, apiKey);
Expand Down
27 changes: 3 additions & 24 deletions apps/desktop/src/main/settings-ipc-helpers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,6 @@ import type { BotProvider } from '@maka/core/bot-chat-settings';
import { botDisplayLabel } from '@maka/core/bot-events';
import { generalizedErrorMessage, redactSecrets } from '@maka/core/redaction';
import {
SENSITIVE_PLACEHOLDER,
maskSensitive,
type TestProxyResult,
} from "@maka/core/settings/network-settings";
Expand Down Expand Up @@ -72,15 +71,12 @@ export function proxyTestFailure(result: TestProxyResult): {

export function maskAppSettings(
settings: RuntimeHostAppSettings,
revealPatch?: UpdateAppSettingsInput,
): RuntimeHostAppSettings;
export function maskAppSettings(
settings: AppSettings,
revealPatch?: UpdateAppSettingsInput,
): AppSettings;
export function maskAppSettings(
settings: AppSettings,
revealPatch: UpdateAppSettingsInput = {},
): AppSettings {
return {
...settings,
Expand All @@ -92,17 +88,8 @@ export function maskAppSettings(
provider,
{
...channel,
token: shouldReveal(
revealPatch.botChat?.channels?.[provider as BotProvider]?.token,
)
? channel.token
: (maskSensitive(channel.token) ?? ""),
appSecret: shouldReveal(
revealPatch.botChat?.channels?.[provider as BotProvider]
?.appSecret,
)
? channel.appSecret
: (maskSensitive(channel.appSecret) ?? ""),
token: maskSensitive(channel.token) ?? "",

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P3 (pre-existing): save responses are now masked immediately. Bot credential PasswordInputs save on every keystroke and re-render from the response, so typing into a masked field persists ••••••••x as the credential. On main the follow-up settings:get reload already masked the field, so this is not a regression. A follow-up could clear the field on focus when it shows the sentinel, or commit on blur.

appSecret: maskSensitive(channel.appSecret) ?? "",
},
]),
) as AppSettings["botChat"]["channels"],
Expand Down Expand Up @@ -182,19 +169,11 @@ export function buildSettingsUpdateResult(
): UpdateAppSettingsResult {
const personalization = collectPersonalizationWarnings(patch.personalization);
return {
settings: maskAppSettings(settings, patch),
settings: maskAppSettings(settings),
...(personalization.length ? { warnings: { personalization } } : {}),
};
}

function shouldReveal(value: string | undefined): boolean {
return (
typeof value === "string" &&
value.length > 0 &&
value !== SENSITIVE_PLACEHOLDER
);
}

export function toSettingsTestResult(
provider: BotProvider,
result: BotTestResult,
Expand Down
6 changes: 2 additions & 4 deletions apps/desktop/src/preload/preload.ts
Original file line number Diff line number Diff line change
Expand Up @@ -209,7 +209,6 @@ import { createBrowserSelectionCoordinator } from './browser-selection.js';
import type { SessionTodoItem } from '@maka/core/session-todo';
import {
isWebSearchProvider,
MASKED_TOKEN_SENTINEL,
normalizeWebSearchLimit,
normalizeWebSearchQuery,
} from '@maka/core/web-search';
Expand All @@ -232,7 +231,7 @@ import type { GoalState } from '@maka/runtime/goal-state';
import type { BundledSkillCatalogEntry, ManagedSkillSourceEntry, ManagedSkillUpdatePreview, SkillEntry } from '@maka/ui';
import type { ConfigCategory } from '@maka/storage/config-transfer';
import {
SENSITIVE_PLACEHOLDER,
isSensitivePlaceholder,
type TestProxyInput,
} from '@maka/core/settings/network-settings';
import type { Result } from '@maka/core/result';
Expand Down Expand Up @@ -1477,8 +1476,7 @@ function unsupportedWebSearchProvider(): WebSearchResponse {
function webSearchCredentialOverride(value: unknown): string | undefined {
return typeof value === 'string' &&
value.length > 0 &&
value !== MASKED_TOKEN_SENTINEL &&
value !== SENSITIVE_PLACEHOLDER
!isSensitivePlaceholder(value)
? value
: undefined;
}
Expand Down
Loading