diff --git a/desktop/renderer/env.d.ts b/desktop/renderer/env.d.ts index 529fb47..1759248 100644 --- a/desktop/renderer/env.d.ts +++ b/desktop/renderer/env.d.ts @@ -22,7 +22,15 @@ interface AppSettings { startMinimized: boolean; themeMode: string; accentColor: string; - privacyLevel: string; + privacyLevel: "basic" | "strict"; + privacyControls?: { + phone: boolean; + idCard: boolean; + bankCard: boolean; + email: boolean; + apiKey: boolean; + fileAccessAudit: boolean; + }; } interface ChatAttachment { diff --git a/desktop/renderer/src/i18n/en-US.ts b/desktop/renderer/src/i18n/en-US.ts index df394eb..c97c928 100644 --- a/desktop/renderer/src/i18n/en-US.ts +++ b/desktop/renderer/src/i18n/en-US.ts @@ -267,18 +267,11 @@ export default { "settings.privacyProtectionDesc": "Control how MicroClaw handles your sensitive data before it's sent to AI models.", "settings.privacyBasic": "Basic", - "settings.privacyBasicDesc1": "Allow reading all accessible files", - "settings.privacyBasicDesc2": "No PII detection or filtering", - "settings.privacyBasicDesc3": "Full file content sent to model", - "settings.privacyBalanced": "Balanced", - "settings.privacyBalancedDesc1": "Warn before reading sensitive files (.env, keys, certs)", - "settings.privacyBalancedDesc2": "Detect & highlight PII (ID numbers, phone, bank cards)", - "settings.privacyBalancedDesc3": "Chat history saved, one-click clear", + "settings.privacyBasicDesc1": "No PII detection or filtering", + "settings.privacyBasicDesc2": "Messages are sent without automatic redaction", "settings.privacyStrict": "Strict", - "settings.privacyStrictDesc1": "Only allow reading whitelisted directories", - "settings.privacyStrictDesc2": "Auto-redact PII before sending to model", - "settings.privacyStrictDesc3": "Some features may be limited", - "settings.privacyRecommended": "Recommended", + "settings.privacyStrictDesc1": "Warn before reading sensitive files (.env, keys, certs)", + "settings.privacyStrictDesc2": "Detect and auto-redact PII before sending to model", "settings.piiDetection": "PII Detection", "settings.piiDetectionDesc": "Scan outgoing messages for personal information before they're sent to the AI model.", diff --git a/desktop/renderer/src/i18n/zh-CN.ts b/desktop/renderer/src/i18n/zh-CN.ts index 73934b8..116c197 100644 --- a/desktop/renderer/src/i18n/zh-CN.ts +++ b/desktop/renderer/src/i18n/zh-CN.ts @@ -252,18 +252,11 @@ export default { "settings.privacyProtection": "隐私防护", "settings.privacyProtectionDesc": "控制 MicroClaw 在将数据发送给 AI 模型前如何处理你的敏感信息。", "settings.privacyBasic": "基本", - "settings.privacyBasicDesc1": "允许读取所有可访问的文件", - "settings.privacyBasicDesc2": "不检测或过滤个人信息", - "settings.privacyBasicDesc3": "完整文件内容发送给模型", - "settings.privacyBalanced": "均衡", - "settings.privacyBalancedDesc1": "读取敏感文件时预警(.env、密钥、证书)", - "settings.privacyBalancedDesc2": "检测并高亮个人信息(身份证号、手机号、银行卡)", - "settings.privacyBalancedDesc3": "聊天记录保存,支持一键清除", + "settings.privacyBasicDesc1": "不检测或过滤个人信息", + "settings.privacyBasicDesc2": "消息发送前不会自动脱敏", "settings.privacyStrict": "严格", - "settings.privacyStrictDesc1": "仅允许读取白名单目录", - "settings.privacyStrictDesc2": "自动脱敏个人信息后再发送给模型", - "settings.privacyStrictDesc3": "部分功能可能受限", - "settings.privacyRecommended": "推荐", + "settings.privacyStrictDesc1": "读取敏感文件时预警(.env、密钥、证书)", + "settings.privacyStrictDesc2": "检测个人信息并自动脱敏后再发送给模型", "settings.piiDetection": "个人信息检测", "settings.piiDetectionDesc": "在发送给 AI 模型前,扫描传出消息中的个人信息。", "settings.piiPhone": "手机号码", diff --git a/desktop/renderer/src/main.ts b/desktop/renderer/src/main.ts index fe645ee..e2e2904 100644 --- a/desktop/renderer/src/main.ts +++ b/desktop/renderer/src/main.ts @@ -43,7 +43,7 @@ if (isBrowserDev) { startMinimized: false, themeMode: "light", accentColor: "#1e1f25", - privacyLevel: "balanced", + privacyLevel: "basic", }), set: noopAsync, }, diff --git a/desktop/renderer/src/stores/chat.test.ts b/desktop/renderer/src/stores/chat.test.ts index deebd02..fc71f50 100644 --- a/desktop/renderer/src/stores/chat.test.ts +++ b/desktop/renderer/src/stores/chat.test.ts @@ -7,6 +7,7 @@ const mockSendMessage = vi.fn().mockResolvedValue(undefined); const mockAbort = vi.fn().mockResolvedValue(undefined); const mockDeleteSession = vi.fn().mockResolvedValue(undefined); const mockIsConnected = vi.fn().mockResolvedValue(false); +const mockGetSettings = vi.fn().mockResolvedValue({}); Object.defineProperty(globalThis, "window", { value: { @@ -32,7 +33,7 @@ Object.defineProperty(globalThis, "window", { onWsDisconnected: vi.fn(), restart: vi.fn(), }, - settings: { get: vi.fn().mockResolvedValue({}) }, + settings: { get: mockGetSettings }, sandbox: { onPermissionRequest: vi.fn() }, skills: { pendingIntegrityResult: vi.fn().mockResolvedValue(null) }, cron: { list: vi.fn().mockResolvedValue({ jobs: [] }) }, @@ -918,6 +919,67 @@ describe("useChatStore — attachments", () => { }); }); +describe("useChatStore — privacy", () => { + beforeEach(() => { + Object.keys(storage).forEach((k) => delete storage[k]); + setActivePinia(createPinia()); + mockSendMessage.mockReset().mockResolvedValue(undefined); + }); + + it("sends PII unchanged when privacy defaults to basic", async () => { + const store = useChatStore(); + + await store.sendMessage("phone 13812345678"); + + expect(mockSendMessage).toHaveBeenCalledWith("main", "phone 13812345678", undefined); + }); + + it("redacts PII before sending in strict mode", async () => { + mockGetSettings.mockResolvedValueOnce({ privacyLevel: "strict" }); + const store = useChatStore(); + + await store.sendMessage("phone 13812345678"); + + expect(mockSendMessage).toHaveBeenCalledWith("main", "phone 138****5678", undefined); + }); + + it("honors disabled Strict categories while redacting enabled ones", async () => { + mockGetSettings.mockResolvedValueOnce({ + privacyLevel: "strict", + privacyControls: { + phone: false, + idCard: true, + bankCard: true, + email: true, + apiKey: true, + fileAccessAudit: false, + }, + }); + const store = useChatStore(); + + await store.sendMessage("phone 13812345678 email alice@example.com"); + + expect(mockSendMessage).toHaveBeenCalledWith( + "main", + "phone 13812345678 email al***@example.com", + undefined, + ); + }); + + it("defaults missing legacy Strict controls to enabled", async () => { + mockGetSettings.mockResolvedValueOnce({ privacyLevel: "strict", privacyControls: {} }); + const store = useChatStore(); + + await store.sendMessage("phone 13812345678 email alice@example.com"); + + expect(mockSendMessage).toHaveBeenCalledWith( + "main", + "phone 138****5678 email al***@example.com", + undefined, + ); + }); +}); + describe("useChatStore — session deletion", () => { beforeEach(() => { Object.keys(storage).forEach((k) => delete storage[k]); diff --git a/desktop/renderer/src/stores/chat.ts b/desktop/renderer/src/stores/chat.ts index 341b63c..1bffcf5 100644 --- a/desktop/renderer/src/stores/chat.ts +++ b/desktop/renderer/src/stores/chat.ts @@ -1,7 +1,8 @@ import { defineStore } from "pinia"; import { ref, computed } from "vue"; import { useSessionStore } from "./sessions"; -import { scanPii, redactPii } from "@/utils/pii-scanner"; +import { redactPii } from "@/utils/pii-scanner"; +import { hydratePrivacyControls, privacyControlsToScanOptions } from "@/utils/privacy-settings"; /** * Chat store — mirrors the webchat gateway protocol. @@ -838,17 +839,13 @@ export const useChatStore = defineStore("chat", () => { lastError.value = null; let optimisticTimestamp: number | undefined; try { - // Privacy protection: scan for PII based on privacy level + // Strict privacy mode redacts PII before sending. let finalMsg = msg; const privacySettings = await window.openclaw.settings.get(); - const privacyLevel = privacySettings?.privacyLevel ?? "balanced"; - if (privacyLevel !== "basic") { - const piiMatches = scanPii(msg); - if (privacyLevel === "strict" && piiMatches.length > 0) { - // Auto-redact in strict mode - finalMsg = redactPii(msg); - } - // In balanced mode, piiMatches are available for UI warning (future) + const privacyLevel = privacySettings?.privacyLevel ?? "basic"; + if (privacyLevel === "strict") { + const controls = hydratePrivacyControls("strict", privacySettings.privacyControls); + finalMsg = redactPii(msg, privacyControlsToScanOptions(controls)); } // Optimistic: add user message locally @@ -1088,7 +1085,8 @@ export const useChatStore = defineStore("chat", () => { const { phase, name, toolCallId, meta } = payload.data; const args = (payload.data as Record).args as - Record | undefined; + | Record + | undefined; if (phase === "start") { // Build a descriptive display name combining tool name + primary argument let displayName = name; diff --git a/desktop/renderer/src/utils/privacy-settings.test.ts b/desktop/renderer/src/utils/privacy-settings.test.ts new file mode 100644 index 0000000..a5e38a2 --- /dev/null +++ b/desktop/renderer/src/utils/privacy-settings.test.ts @@ -0,0 +1,66 @@ +import { describe, expect, it } from "vitest"; +import { hydratePrivacyControls, privacyControlsToScanOptions } from "./privacy-settings"; + +describe("hydratePrivacyControls", () => { + it("defaults every control off for Basic", () => { + expect(hydratePrivacyControls("basic")).toEqual({ + phone: false, + idCard: false, + bankCard: false, + email: false, + apiKey: false, + fileAccessAudit: false, + }); + }); + + it("defaults every control on for Strict", () => { + expect(hydratePrivacyControls("strict")).toEqual({ + phone: true, + idCard: true, + bankCard: true, + email: true, + apiKey: true, + fileAccessAudit: true, + }); + }); + + it("preserves explicit Strict choices and defaults only missing fields", () => { + expect( + hydratePrivacyControls("strict", { + phone: false, + email: false, + fileAccessAudit: false, + }), + ).toEqual({ + phone: false, + idCard: true, + bankCard: true, + email: false, + apiKey: true, + fileAccessAudit: false, + }); + }); + + it("forces controls off when hydrating Basic", () => { + expect(hydratePrivacyControls("basic", { phone: true })).toMatchObject({ phone: false }); + }); + + it("maps every PII control to typed scanner options", () => { + expect( + privacyControlsToScanOptions({ + phone: false, + idCard: true, + bankCard: false, + email: true, + apiKey: false, + fileAccessAudit: true, + }), + ).toEqual({ + phone: false, + idCard: true, + bankCard: false, + email: true, + apiKey: false, + }); + }); +}); diff --git a/desktop/renderer/src/utils/privacy-settings.ts b/desktop/renderer/src/utils/privacy-settings.ts new file mode 100644 index 0000000..47073f1 --- /dev/null +++ b/desktop/renderer/src/utils/privacy-settings.ts @@ -0,0 +1,37 @@ +export type PrivacyLevel = "basic" | "strict"; + +export interface PrivacyControls { + phone: boolean; + idCard: boolean; + bankCard: boolean; + email: boolean; + apiKey: boolean; + fileAccessAudit: boolean; +} + +export function hydratePrivacyControls( + level: PrivacyLevel, + saved?: Partial, +): PrivacyControls { + const defaultEnabled = level === "strict"; + const persisted = level === "strict" ? saved : undefined; + return { + phone: persisted?.phone ?? defaultEnabled, + idCard: persisted?.idCard ?? defaultEnabled, + bankCard: persisted?.bankCard ?? defaultEnabled, + email: persisted?.email ?? defaultEnabled, + apiKey: persisted?.apiKey ?? defaultEnabled, + fileAccessAudit: persisted?.fileAccessAudit ?? defaultEnabled, + }; +} + +export function privacyControlsToScanOptions(controls: PrivacyControls): ScanOptions { + return { + phone: controls.phone, + idCard: controls.idCard, + bankCard: controls.bankCard, + email: controls.email, + apiKey: controls.apiKey, + }; +} +import type { ScanOptions } from "./pii-scanner"; diff --git a/desktop/renderer/src/views/SettingsView.vue b/desktop/renderer/src/views/SettingsView.vue index 42c5acc..b34d44a 100644 --- a/desktop/renderer/src/views/SettingsView.vue +++ b/desktop/renderer/src/views/SettingsView.vue @@ -86,7 +86,8 @@
{{ t("settings.totalSpend") }} {{ t("settings.currencySymbol") }}{{ toCny(usageData.totalSpend).toFixed(2) }}{{ t("settings.currencySymbol") + }}{{ toCny(usageData.totalSpend).toFixed(2) }}
@@ -94,7 +95,8 @@
{{ t("settings.currencySymbol") }}{{ toCny(usageData.totalSpend).toFixed(2) }} / - {{ t("settings.currencySymbol") }}{{ toCny(usageData.maxBudget).toFixed(2) }}
- @@ -316,9 +317,7 @@ @@ -337,11 +336,7 @@ {{ t("settings.port") }}
ws://127.0.0.1 : - +
@@ -668,23 +663,6 @@
  • {{ t("settings.privacyBasicDesc1") }}
  • {{ t("settings.privacyBasicDesc2") }}
  • -
  • {{ t("settings.privacyBasicDesc3") }}
  • -
-
-
-
- ⚖️ - {{ t("settings.privacyBalanced") }} - {{ t("settings.privacyRecommended") }} -
-
    -
  • {{ t("settings.privacyBalancedDesc1") }}
  • -
  • {{ t("settings.privacyBalancedDesc2") }}
  • -
  • {{ t("settings.privacyBalancedDesc3") }}
  • {{ t("settings.privacyStrictDesc1") }}
  • {{ t("settings.privacyStrictDesc2") }}
  • -
  • {{ t("settings.privacyStrictDesc3") }}
  • @@ -710,26 +687,43 @@
    {{ t("settings.piiPhone") }} - +
    {{ t("settings.piiIdCard") }} - +
    {{ t("settings.piiBankCard") }}
    {{ t("settings.piiEmail") }} - +
    {{ t("settings.piiApiKey") }} - +
    @@ -762,6 +756,7 @@ @@ -858,6 +853,11 @@ import { removeGitHubCopilotModelReferences, } from "@/utils/auth-managed-models"; import { getManagedModelProvider } from "@/utils/managed-model-providers"; +import { + hydratePrivacyControls, + type PrivacyControls, + type PrivacyLevel, +} from "@/utils/privacy-settings"; const route = useRoute(); const gateway = useGatewayStore(); @@ -1157,16 +1157,16 @@ const settings = reactive({ autoStart: false, startMinimized: false, themeMode: "light", - privacyLevel: "balanced" as "basic" | "balanced" | "strict", - fileAccessAudit: true, + privacyLevel: "basic" as PrivacyLevel, + fileAccessAudit: false, }); const piiToggles = reactive({ - phone: true, - idCard: true, - bankCard: true, - email: true, - apiKey: true, + phone: false, + idCard: false, + bankCard: false, + email: false, + apiKey: false, }); // --- Models & API state --- @@ -1397,32 +1397,30 @@ watch( }, ); -function setPrivacyLevel(level: "basic" | "balanced" | "strict") { +function currentPrivacyControls(): PrivacyControls { + return { + ...piiToggles, + fileAccessAudit: settings.fileAccessAudit, + }; +} + +function applyPrivacyControls(controls: PrivacyControls) { + piiToggles.phone = controls.phone; + piiToggles.idCard = controls.idCard; + piiToggles.bankCard = controls.bankCard; + piiToggles.email = controls.email; + piiToggles.apiKey = controls.apiKey; + settings.fileAccessAudit = controls.fileAccessAudit; +} + +function persistPrivacyControls() { + return window.openclaw.settings.set("privacyControls", currentPrivacyControls()); +} + +function setPrivacyLevel(level: PrivacyLevel) { settings.privacyLevel = level; - window.openclaw.settings.set("privacyLevel", level); - // Auto-configure PII toggles based on level - if (level === "basic") { - piiToggles.phone = false; - piiToggles.idCard = false; - piiToggles.bankCard = false; - piiToggles.email = false; - piiToggles.apiKey = false; - settings.fileAccessAudit = false; - } else if (level === "balanced") { - piiToggles.phone = true; - piiToggles.idCard = true; - piiToggles.bankCard = true; - piiToggles.email = true; - piiToggles.apiKey = true; - settings.fileAccessAudit = true; - } else { - piiToggles.phone = true; - piiToggles.idCard = true; - piiToggles.bankCard = true; - piiToggles.email = true; - piiToggles.apiKey = true; - settings.fileAccessAudit = true; - } + applyPrivacyControls(hydratePrivacyControls(level)); + void Promise.all([window.openclaw.settings.set("privacyLevel", level), persistPrivacyControls()]); } // --- Auto-load data when tab is selected --- @@ -1552,15 +1550,12 @@ onMounted(async () => { settings.autoStart = saved.autoStart ?? false; settings.startMinimized = saved.startMinimized ?? false; settings.themeMode = saved.themeMode ?? "light"; - settings.privacyLevel = (saved.privacyLevel ?? "balanced") as "basic" | "balanced" | "strict"; - // Init PII toggles based on loaded privacy level - if (settings.privacyLevel === "basic") { - piiToggles.phone = false; - piiToggles.idCard = false; - piiToggles.bankCard = false; - piiToggles.email = false; - piiToggles.apiKey = false; - settings.fileAccessAudit = false; + const savedPrivacyLevel: string | undefined = saved.privacyLevel; + settings.privacyLevel = savedPrivacyLevel === "strict" ? "strict" : "basic"; + applyPrivacyControls(hydratePrivacyControls(settings.privacyLevel, saved.privacyControls)); + await persistPrivacyControls(); + if (savedPrivacyLevel === "balanced") { + await window.openclaw.settings.set("privacyLevel", "basic"); } } @@ -1576,7 +1571,6 @@ onMounted(async () => { // Load web search provider configuration loadSearchConfig(config); - }); onUnmounted(() => { @@ -1693,11 +1687,9 @@ async function removeCustomModel(idx: number) { async function disconnectGitHubCopilot() { if (switchingModelRef.value || removingModelRef.value || copilotDisconnecting.value) return; try { - await ElMessageBox.confirm( - t("settings.copilotDisconnectConfirm"), - t("settings.confirm"), - { type: "warning" }, - ); + await ElMessageBox.confirm(t("settings.copilotDisconnectConfirm"), t("settings.confirm"), { + type: "warning", + }); } catch { return; } @@ -2638,7 +2630,7 @@ async function clearChatHistory() { /* ── Privacy Protection ── */ .privacy-levels { display: grid; - grid-template-columns: repeat(3, 1fr); + grid-template-columns: repeat(2, 1fr); gap: 12px; } @@ -2679,16 +2671,6 @@ async function clearChatHistory() { color: var(--text-primary); } -.privacy-badge-recommended { - font-size: 10px; - font-weight: 600; - padding: 2px 8px; - border-radius: 10px; - background: rgba(212, 168, 67, 0.15); - color: var(--accent-selected); - margin-left: auto; -} - .privacy-card-list { list-style: none; padding: 0; diff --git a/desktop/src/main.ts b/desktop/src/main.ts index 0fb2c09..f7eb660 100644 --- a/desktop/src/main.ts +++ b/desktop/src/main.ts @@ -179,8 +179,17 @@ const settingsStore = new Store<{ sandboxUserDirsRO: string[]; /** All directories we've ever granted AC ACL to. Used to detect stale ACLs on startup. */ sandboxGrantHistory: string[]; - /** Privacy protection level: basic, balanced, strict */ - privacyLevel: string; + /** Privacy protection level. */ + privacyLevel: "basic" | "strict"; + /** Per-control privacy preferences. Missing fields use mode-specific defaults. */ + privacyControls?: { + phone: boolean; + idCard: boolean; + bankCard: boolean; + email: boolean; + apiKey: boolean; + fileAccessAudit: boolean; + }; }>({ name: "settings", defaults: { @@ -203,7 +212,7 @@ const settingsStore = new Store<{ sandboxUserDirsRW: [], sandboxUserDirsRO: [], sandboxGrantHistory: [], - privacyLevel: "balanced", + privacyLevel: "basic", }, }); @@ -1427,9 +1436,7 @@ async function addCatalogAgent( } if (restartAttempted && managedGateway) { try { - await restartManagedGatewayAndRequireReady( - `Rolling back failed agent addition ${agentId}`, - ); + await restartManagedGatewayAndRequireReady(`Rolling back failed agent addition ${agentId}`); } catch (rollbackError) { rollbackErrors.push(rollbackError); } @@ -1489,9 +1496,7 @@ async function removeCatalogAgent( } if (restartAttempted && managedGateway) { try { - await restartManagedGatewayAndRequireReady( - `Rolling back failed agent removal ${agentId}`, - ); + await restartManagedGatewayAndRequireReady(`Rolling back failed agent removal ${agentId}`); } catch (rollbackError) { rollbackErrors.push(rollbackError); } @@ -3674,24 +3679,15 @@ function registerIpcHandlers(): void { }); if (result.canceled) return { attachments: [], rejections: [] }; const currentTotalBytes = - typeof params?.currentTotalBytes === "number" && - Number.isFinite(params.currentTotalBytes) + typeof params?.currentTotalBytes === "number" && Number.isFinite(params.currentTotalBytes) ? Math.max(0, params.currentTotalBytes) : 0; - return prepareChatAttachments( - result.filePaths, - undefined, - undefined, - currentTotalBytes, - ); + return prepareChatAttachments(result.filePaths, undefined, undefined, currentTotalBytes); }); ipcMain.handle( "chat:send-message", - async ( - _event, - params: { sessionKey: string; message: string; attachments?: unknown }, - ) => { + async (_event, params: { sessionKey: string; message: string; attachments?: unknown }) => { if (!gwClient?.connected) throw new Error("Gateway not connected"); // Mark that the latest input is from the local desktop UI. lastInputFromRemote = false; @@ -4167,9 +4163,7 @@ function registerIpcHandlers(): void { ipcMain.handle("model:github-copilot:status", () => { const client = gwClient; - const queryGateway = client?.connected - ? () => client.request("models.authStatus") - : undefined; + const queryGateway = client?.connected ? () => client.request("models.authStatus") : undefined; return getGitHubCopilotAuthStatus(resolveGitHubCopilotAuthRuntime(), queryGateway); });