diff --git a/desktop/renderer/env.d.ts b/desktop/renderer/env.d.ts index 10b1261..2bebb1f 100644 --- a/desktop/renderer/env.d.ts +++ b/desktop/renderer/env.d.ts @@ -22,7 +22,14 @@ interface AppSettings { minimizeToTray: boolean; themeMode: string; accentColor: string; - privacyLevel: string; + privacyLevel: "basic" | "strict"; + privacyControls?: { + phone: boolean; + idCard: boolean; + bankCard: boolean; + email: boolean; + apiKey: boolean; + }; } interface ChatAttachment { diff --git a/desktop/renderer/src/browser-openclaw.ts b/desktop/renderer/src/browser-openclaw.ts index 35a7ef2..9cfd18f 100644 --- a/desktop/renderer/src/browser-openclaw.ts +++ b/desktop/renderer/src/browser-openclaw.ts @@ -105,7 +105,7 @@ export function createBrowserOpenClawMock(): OpenClawAPI { minimizeToTray: false, themeMode: "light", accentColor: "#1e1f25", - privacyLevel: "balanced", + privacyLevel: "basic", }), set: noopAsync, }, diff --git a/desktop/renderer/src/i18n/en-US.ts b/desktop/renderer/src/i18n/en-US.ts index 556d872..1918975 100644 --- a/desktop/renderer/src/i18n/en-US.ts +++ b/desktop/renderer/src/i18n/en-US.ts @@ -269,19 +269,12 @@ 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.piiDetection": "PII detection", + "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.", "settings.piiPhone": "Phone numbers", @@ -293,10 +286,6 @@ export default { "settings.sensitiveFilesDesc": "Ask for confirmation before the agent reads files that match these patterns.", "settings.sensitiveFilePatterns": ".env, *_key*, *.pem, *.p12, *.pfx, id_rsa, credentials", - "settings.fileAccessAudit": "File access audit", - "settings.fileAccessAuditToggle": "Log file access", - "settings.fileAccessAuditDesc": - "Log every file the agent reads during conversations. Audit logs are saved in the workspace data directory.", "settings.security": "Security sandbox", "settings.sandboxEnabled": "Tool sandbox (AppContainer)", "settings.externalApps": "Apps allowed outside the sandbox", diff --git a/desktop/renderer/src/i18n/zh-CN.ts b/desktop/renderer/src/i18n/zh-CN.ts index 8971c88..9f3c92c 100644 --- a/desktop/renderer/src/i18n/zh-CN.ts +++ b/desktop/renderer/src/i18n/zh-CN.ts @@ -254,18 +254,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": "手机号码", @@ -276,10 +269,6 @@ export default { "settings.sensitiveFiles": "敏感文件保护", "settings.sensitiveFilesDesc": "智能体读取匹配以下模式的文件前,需要用户确认。", "settings.sensitiveFilePatterns": ".env, *_key*, *.pem, *.p12, *.pfx, id_rsa, credentials", - "settings.fileAccessAudit": "文件访问审计", - "settings.fileAccessAuditToggle": "记录文件访问", - "settings.fileAccessAuditDesc": - "记录智能体在对话中读取的所有文件。审计日志保存在工作区数据目录中。", "settings.security": "安全沙箱", "settings.sandboxEnabled": "工具沙箱(AppContainer)", "settings.externalApps": "允许在沙箱外运行的应用", @@ -347,8 +336,7 @@ export default { "settings.customModelDeleted": "自定义模型已删除", "settings.copilotDisconnected": "GitHub Copilot 已断开连接", "settings.copilotDisconnectFailed": "无法断开 GitHub Copilot:{error}", - "settings.copilotDisconnectedRestartFailed": - "GitHub Copilot 已断开,但网关重启失败:{error}", + "settings.copilotDisconnectedRestartFailed": "GitHub Copilot 已断开,但网关重启失败:{error}", "settings.copilotConfigUnavailable": "无法读取当前模型配置", "settings.searchSettingsSaved": "联网搜索设置已保存", "settings.searchConfigured": "已配置", diff --git a/desktop/renderer/src/stores/chat.test.ts b/desktop/renderer/src/stores/chat.test.ts index beccab0..76b1279 100644 --- a/desktop/renderer/src/stores/chat.test.ts +++ b/desktop/renderer/src/stores/chat.test.ts @@ -8,6 +8,7 @@ const mockAbort = vi.fn().mockResolvedValue(undefined); const mockDeleteSession = vi.fn().mockResolvedValue(undefined); const mockGenerateSessionTitle = vi.fn().mockResolvedValue("Summary title"); const mockIsConnected = vi.fn().mockResolvedValue(false); +const mockGetSettings = vi.fn().mockResolvedValue({}); Object.defineProperty(globalThis, "window", { value: { @@ -34,7 +35,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: [] }) }, @@ -926,6 +927,66 @@ 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, + }, + }); + 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 8b40652..adc1715 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. @@ -839,17 +840,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 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..a452678 --- /dev/null +++ b/desktop/renderer/src/utils/privacy-settings.test.ts @@ -0,0 +1,61 @@ +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, + }); + }); + + it("defaults every control on for Strict", () => { + expect(hydratePrivacyControls("strict")).toEqual({ + phone: true, + idCard: true, + bankCard: true, + email: true, + apiKey: true, + }); + }); + + it("preserves explicit Strict choices and defaults only missing fields", () => { + expect( + hydratePrivacyControls("strict", { + phone: false, + email: false, + }), + ).toEqual({ + phone: false, + idCard: true, + bankCard: true, + email: false, + apiKey: true, + }); + }); + + 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, + }), + ).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..f21f8a2 --- /dev/null +++ b/desktop/renderer/src/utils/privacy-settings.ts @@ -0,0 +1,35 @@ +export type PrivacyLevel = "basic" | "strict"; + +export interface PrivacyControls { + phone: boolean; + idCard: boolean; + bankCard: boolean; + email: boolean; + apiKey: 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, + }; +} + +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.test.ts b/desktop/renderer/src/views/SettingsView.test.ts index acdc43a..ad8ea9d 100644 --- a/desktop/renderer/src/views/SettingsView.test.ts +++ b/desktop/renderer/src/views/SettingsView.test.ts @@ -24,7 +24,7 @@ describe("SettingsView", () => { autoStart: false, minimizeToTray: false, themeMode: "light", - privacyLevel: "balanced", + privacyLevel: "basic", }), set: vi.fn().mockResolvedValue(undefined), }, diff --git a/desktop/renderer/src/views/SettingsView.vue b/desktop/renderer/src/views/SettingsView.vue index 93a3d3b..4de3800 100644 --- a/desktop/renderer/src/views/SettingsView.vue +++ b/desktop/renderer/src/views/SettingsView.vue @@ -664,23 +664,6 @@ - -
-
- ⚖️ - {{ t("settings.privacyBalanced") }} - {{ t("settings.privacyRecommended") }} -
-
  • {{ t("settings.privacyStrictDesc1") }}
  • {{ t("settings.privacyStrictDesc2") }}
  • -
  • {{ t("settings.privacyStrictDesc3") }}
  • @@ -706,26 +688,43 @@
    {{ t("settings.piiPhone") }} - +
    {{ t("settings.piiIdCard") }} - +
    {{ t("settings.piiBankCard") }}
    {{ t("settings.piiEmail") }} - +
    {{ t("settings.piiApiKey") }} - +
    @@ -750,19 +749,6 @@ - -
    {{ t("settings.fileAccessAudit") }}
    -
    -
    - {{ t("settings.fileAccessAuditToggle") }} - -
    -
    - -
    {{ t("settings.chatHistory") }}
    @@ -854,6 +840,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(); @@ -1152,16 +1143,15 @@ const settings = reactive({ autoStart: false, minimizeToTray: false, themeMode: "light", - privacyLevel: "balanced" as "basic" | "balanced" | "strict", - fileAccessAudit: true, + privacyLevel: "basic" as PrivacyLevel, }); 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 --- @@ -1421,32 +1411,26 @@ watch( }, ); -function setPrivacyLevel(level: "basic" | "balanced" | "strict") { +function currentPrivacyControls(): PrivacyControls { + return { ...piiToggles }; +} + +function applyPrivacyControls(controls: PrivacyControls) { + piiToggles.phone = controls.phone; + piiToggles.idCard = controls.idCard; + piiToggles.bankCard = controls.bankCard; + piiToggles.email = controls.email; + piiToggles.apiKey = controls.apiKey; +} + +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 --- @@ -1576,15 +1560,12 @@ onMounted(async () => { settings.autoStart = saved.autoStart ?? false; settings.minimizeToTray = saved.minimizeToTray ?? 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"); } } @@ -2744,7 +2725,7 @@ async function clearChatHistory() { /* ── Privacy Protection ── */ .privacy-levels { display: grid; - grid-template-columns: repeat(3, 1fr); + grid-template-columns: repeat(2, 1fr); gap: 12px; } @@ -2785,16 +2766,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 3230de7..447b24c 100644 --- a/desktop/src/main.ts +++ b/desktop/src/main.ts @@ -190,8 +190,16 @@ 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; + }; }>({ name: "settings", defaults: { @@ -214,7 +222,7 @@ const settingsStore = new Store<{ sandboxUserDirsRW: [], sandboxUserDirsRO: [], sandboxGrantHistory: [], - privacyLevel: "balanced", + privacyLevel: "basic", }, });