From 91942e8077b2fe694d1d324d7064aa0dd098ac81 Mon Sep 17 00:00:00 2001 From: Copilot App <223556219+Copilot@users.noreply.github.com> Date: Thu, 15 Oct 2026 16:46:30 +0800 Subject: [PATCH 1/3] fix: fail closed for WeChat approval prompts Replace denylist-based command rendering with an exact structured allowlist and constant-label presentation so unknown or sensitive values remain local-only. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- desktop/renderer/src/i18n/en-US.ts | 2 +- desktop/renderer/src/i18n/zh-CN.ts | 2 +- .../renderer/src/views/SettingsView.test.ts | 2 + ...ws-node-mxc-weixin-remote-approval.test.ts | 182 ++++---- ...windows-node-mxc-weixin-remote-approval.ts | 429 +++++++++--------- docs/experimental-windows-node-mxc.md | 38 +- 6 files changed, 336 insertions(+), 319 deletions(-) diff --git a/desktop/renderer/src/i18n/en-US.ts b/desktop/renderer/src/i18n/en-US.ts index cb89f12..c8b34f6 100644 --- a/desktop/renderer/src/i18n/en-US.ts +++ b/desktop/renderer/src/i18n/en-US.ts @@ -306,7 +306,7 @@ export default { "settings.weixinRemoteApprovalRequiresOwner": "Exactly one authenticated WeChat account whose owner is in the explicit sender allowlist is required.", "settings.weixinRemoteApprovalOnceOnly": - "Replies approve only the currently listed request(s) once. They never create durable approvals, folder grants, or future approval. Secret-bearing or unclassified free-text command fields stay local-only.", + "Remote prompts are limited to exact predefined read-only Windows queries with no opaque operands. Free-form, unknown, or secret-bearing requests stay local-only, and raw command fields are not sent to WeChat. Replies approve only the currently listed request(s) once and never create durable approvals, folder grants, or future approval.", "settings.weixinRemoteApprovalEnable": "Enable and bind locally", "settings.weixinRemoteApprovalDisable": "Disable", "settings.weixinRemoteApprovalRevoke": "Revoke owner binding", diff --git a/desktop/renderer/src/i18n/zh-CN.ts b/desktop/renderer/src/i18n/zh-CN.ts index 2b702e4..95c59f1 100644 --- a/desktop/renderer/src/i18n/zh-CN.ts +++ b/desktop/renderer/src/i18n/zh-CN.ts @@ -287,7 +287,7 @@ export default { "settings.weixinRemoteApprovalRequiresOwner": "必须且只能存在一个已登录微信账户,且其所有者必须位于显式发送者白名单中。", "settings.weixinRemoteApprovalOnceOnly": - "回复仅一次性批准当前列出的请求;绝不会创建永久审批、文件夹授权或未来请求授权。包含秘密或无法分类的自由文本命令字段仅允许本地审批。", + "远程提示仅支持不含不透明操作数的精确预定义 Windows 只读查询。自由文本、未知或含密请求仅能在本机审批,原始命令字段不会发送到微信。回复仅一次性批准当前列出的请求;绝不会创建永久审批、文件夹授权或未来请求授权。", "settings.weixinRemoteApprovalEnable": "在本机确认启用并绑定", "settings.weixinRemoteApprovalDisable": "禁用", "settings.weixinRemoteApprovalRevoke": "撤销所有者绑定", diff --git a/desktop/renderer/src/views/SettingsView.test.ts b/desktop/renderer/src/views/SettingsView.test.ts index e8ebd65..3472b29 100644 --- a/desktop/renderer/src/views/SettingsView.test.ts +++ b/desktop/renderer/src/views/SettingsView.test.ts @@ -358,6 +358,7 @@ describe("SettingsView", () => { expect(card.text()).toContain("WeChat remote approval (Experimental)"); expect(card.text()).toContain("owner@im.wechat"); expect(card.text()).toContain("allowlisted openclaw-weixin plugin and Gateway"); + expect(card.text()).toContain("Free-form, unknown, or secret-bearing requests stay local-only"); expect(card.text()).toContain("never create durable approvals"); expect(card.text()).toContain("unclassified free-text command fields stay local-only"); await wrapper.find('[data-testid="weixin-remote-approval-enable"]').trigger("click"); @@ -376,6 +377,7 @@ describe("SettingsView", () => { const card = wrapper.find('[data-testid="weixin-remote-approval"]'); expect(card.text()).toContain("微信远程审批(实验性)"); expect(card.text()).toContain("白名单 openclaw-weixin 插件和 Gateway"); + expect(card.text()).toContain("自由文本、未知或含密请求仅能在本机审批"); expect(card.text()).toContain("绝不会创建永久审批"); expect(card.text()).toContain("无法分类的自由文本命令字段仅允许本地审批"); }); diff --git a/desktop/src/windows-node-mxc-weixin-remote-approval.test.ts b/desktop/src/windows-node-mxc-weixin-remote-approval.test.ts index a9ea9ae..78eee64 100644 --- a/desktop/src/windows-node-mxc-weixin-remote-approval.test.ts +++ b/desktop/src/windows-node-mxc-weixin-remote-approval.test.ts @@ -2,6 +2,7 @@ import { describe, expect, it } from "vitest"; import type { WindowsNodeMxcGatewayApproval } from "./windows-node-mxc"; import { + classifyWeixinRemoteApprovalPresentation, migrateWeixinRemoteApprovalConfiguration, normalizeWeixinRemoteApprovalOwner, renderWeixinRemoteApprovalPrompt, @@ -13,10 +14,10 @@ import { const approval: WindowsNodeMxcGatewayApproval = { id: "approval-1", - command: "whoami.exe /all", - executable: "whoami.exe", - arguments: ["/all"], - executablePath: "C:\\Windows\\System32\\whoami.exe", + command: "hostname.exe", + executable: "hostname.exe", + arguments: [], + executablePath: "C:\\Windows\\System32\\hostname.exe", executableSha256: "a".repeat(64), canonicalCwd: "isolated-scratch:v1", agent: "main", @@ -133,6 +134,61 @@ describe("WeChat remote approval broker", () => { expect(() => normalizeWeixinRemoteApprovalOwner("bot", "display name")).toThrow(); }); + it.each([ + ["hostname.exe", "C:\\Windows\\System32\\hostname.exe", [], "hostname.exe"], + ["whoami.exe", "C:\\Windows\\System32\\whoami.exe", [], "whoami.exe"], + ["whoami.exe", "C:\\Windows\\System32\\whoami.exe", ["/user"], "whoami.exe /user"], + ["whoami.exe", "C:\\Windows\\System32\\whoami.exe", ["/groups"], "whoami.exe /groups"], + ["whoami.exe", "C:\\Windows\\System32\\whoami.exe", ["/priv"], "whoami.exe /priv"], + ["ipconfig.exe", "C:\\Windows\\System32\\ipconfig.exe", [], "ipconfig.exe"], + ["ipconfig.exe", "C:\\Windows\\System32\\ipconfig.exe", ["/all"], "ipconfig.exe /all"], + ["systeminfo.exe", "C:\\Windows\\System32\\systeminfo.exe", [], "systeminfo.exe"], + ] as const)( + "allows only the exact predefined tuple %s %j", + (executable, executablePath, argumentsValue, command) => { + const broker = configuredBroker(); + const allowed = input(); + allowed.requests = [ + { + approval: { + ...approval, + executable, + executablePath, + arguments: [...argumentsValue], + command, + }, + }, + ]; + expect(broker.createChallenge(allowed)).not.toBeNull(); + }, + ); + + it.each([ + ["unknown executable", { executable: "curl.exe" }], + ["unknown executable path", { executablePath: "C:\\Tools\\hostname.exe" }], + ["unknown argument", { arguments: ["/unknown"] }], + ["command drift", { command: "hostname.exe " }], + ["declaration", { declaredAccess: [{ access: "ro" as const, path: "C:\\safe" }] }], + ["non-scratch CWD", { canonicalCwd: "C:\\Temp" }], + ["mutable operand", { hasMutableFileOperand: true }], + [ + "missing one-use decision", + { allowedDecisions: ["deny"] as WindowsNodeMxcGatewayApproval["allowedDecisions"] }, + ], + ])("keeps structurally unrecognized %s requests local-only", (_name, approvalOverride) => { + const broker = configuredBroker(); + const blocked = input(); + blocked.requests = [{ approval: { ...approval, ...approvalOverride } }]; + expect(broker.createChallenge(blocked)).toBeNull(); + }); + + it("keeps unknown effective containment modes local-only", () => { + const broker = configuredBroker(); + const blocked = input(); + blocked.security = { ...blocked.security, effectiveMode: "future-tier" }; + expect(broker.createChallenge(blocked)).toBeNull(); + }); + it.each([ ["1", "allow-once", ["approval-1"]], ["2", "deny", ["approval-1"]], @@ -172,10 +228,10 @@ describe("WeChat remote approval broker", () => { approval: { ...approval, id: "approval-2", - command: "hostname.exe", - executable: "hostname.exe", - executablePath: "C:\\Windows\\System32\\hostname.exe", - arguments: [], + command: "whoami.exe /groups", + executable: "whoami.exe", + executablePath: "C:\\Windows\\System32\\whoami.exe", + arguments: ["/groups"], }, }, ]; @@ -324,42 +380,37 @@ describe("WeChat remote approval broker", () => { ).toMatchObject({ status: "rejected", reason: "stale" }); }); - it("renders safely classified values without trimming, path rewriting, or number conversion", () => { + it("never sends raw authorization fields and keeps arbitrary free text local-only", () => { const broker = configuredBroker(); - const exact = input(); - const exactPath = "C:\\Users\\other-user\\Documents\\Data-01.txt"; - exact.requests = [ - { - approval: { - ...approval, - command: `whoami.exe ${exactPath} --mode -7.50`, - arguments: [exactPath, "--mode", "-7.50"], - }, - }, - ]; - const delivery = broker.createChallenge(exact)!; - expect(delivery.text).toContain(JSON.stringify(exactPath)); - expect(delivery.text).toContain(JSON.stringify("-7.50")); - expect(delivery.text).not.toContain("%USERPROFILE%"); - }); + const delivery = broker.createChallenge(input())!; + expect(delivery.text).toContain("Windows host name"); + for (const rawValue of [ + approval.command, + approval.executable, + approval.executablePath, + approval.executableSha256, + approval.canonicalCwd, + approval.agent!, + approval.sessionKey, + ]) { + expect(delivery.text).not.toContain(rawValue); + } - it.each([ - '{"safe":1,"safe":2,"number":9007199254740993}', - "Cafe\u0301", - "任意文本", - ])("keeps unclassified exact text local-only without transforming it: %s", (value) => { - const broker = configuredBroker(); - const exact = input(); - exact.requests = [ + const arbitrary = input(); + const duplicateJson = '{"safe":1,"safe":2,"number":9007199254740993}'; + const decomposed = "Cafe\u0301"; + arbitrary.requests = [ { approval: { ...approval, - command: `whoami.exe "${value}"`, - arguments: [value], + command: `tool.exe "${decomposed}"`, + executable: "tool.exe", + executablePath: "C:\\Tools\\tool.exe", + arguments: ["C:\\Users\\other-user\\Documents\\data.txt", duplicateJson, decomposed], }, }, ]; - expect(broker.createChallenge(exact)).toBeNull(); + expect(broker.createChallenge(arbitrary)).toBeNull(); }); it.each([ @@ -388,21 +439,14 @@ describe("WeChat remote approval broker", () => { }); it.each([ - "--******", - "--passphrase=correct-horse-battery-staple", - "AccountKey=abcdefghijklmnopqrstuvwxyz0123456789+/=", - "sv=2026-01-01&sig=abcdefghijklmnopqrstuvwxyz0123456789", - "npm_abcdefghijklmnopqrstuvwxyz0123456789", - ["xoxb", "123456789012", "123456789012", "abcdefghijklmnopqrstuvwxyz"].join("-"), - ])("keeps known missed credential forms local-only: %s", (secretValue) => { - const broker = configuredBroker(); - const unsafe = input(); - unsafe.requests = [{ approval: { ...approval, arguments: [secretValue] } }]; - expect(broker.createChallenge(unsafe)).toBeNull(); - expect(JSON.stringify(broker.getAuditEvents())).not.toContain(secretValue); - }); - - it.each([ + "opaque-positional-password-value", + "--passphrase=hunter2", + "AccountKey=azure-storage-secret", + "sv=2024-01-01&sig=azure-sas-secret", + "//registry.npmjs.org/:_authToken=npm-secret", + "xoxb-1234567890-1234567890-opaque", + "X-Auth: custom-credential", + "dGhpcy1pcy1hbi11bmtub3duLWNyZWRlbnRpYWw=", "--token=inline-secret", "--password adjacent-secret", "Authorization: Bearer abcdef", @@ -423,32 +467,6 @@ describe("WeChat remote approval broker", () => { expect(JSON.stringify(broker.getAuditEvents())).not.toContain(secretValue); }); - it.each([ - "--opaque=value", - "hunter2", - "shortvalue", - "123456", - "654321", - "correct horse battery staple", - "abcdefghijklmnopqrstuvwxyz0123456789", - "https://example.test/path", - '{"unknown":"free-text"}', - ])("keeps unknown free-form value local-only: %s", (unknownValue) => { - const broker = configuredBroker(); - const unsafe = input(); - unsafe.requests = [{ approval: { ...approval, arguments: [unknownValue] } }]; - expect(broker.createChallenge(unsafe)).toBeNull(); - }); - - it("keeps an unknown switch value local-only even when both tokens look harmless", () => { - const broker = configuredBroker(); - const unsafe = input(); - unsafe.requests = [ - { approval: { ...approval, arguments: ["--opaque", "shortvalue"] } }, - ]; - expect(broker.createChallenge(unsafe)).toBeNull(); - }); - it("enforces deterministic request, argument, declaration, line, and total caps", () => { const broker = configuredBroker(); const tooManyArguments = input(); @@ -488,19 +506,21 @@ describe("WeChat remote approval broker", () => { }); it("renders truthful English and Chinese one-use option copy", () => { + const presentation = classifyWeixinRemoteApprovalPresentation([{ approval }], input().security); + expect(presentation).not.toBeNull(); for (const locale of ["en-US", "zh-CN"] as const) { const prompt = renderWeixinRemoteApprovalPrompt({ code: "23456789", expiresAtUnixMs: 123_000, - requests: [{ approval }], - security: input().security, + presentation: presentation!, locale, }); expect(prompt).toContain(`1 23456789`); expect(prompt).toContain(`2 23456789`); expect(prompt).toContain(`3 23456789`); - expect(prompt).toContain(approval.executableSha256); - expect(prompt).toContain(JSON.stringify(approval.canonicalCwd)); + expect(prompt).not.toContain(approval.executableSha256); + expect(prompt).not.toContain(approval.canonicalCwd); + expect(prompt).not.toContain(approval.sessionKey); expect(prompt).toMatch( locale === "zh-CN" ? /不会创建永久授权/ : /no durable or future approval/, ); diff --git a/desktop/src/windows-node-mxc-weixin-remote-approval.ts b/desktop/src/windows-node-mxc-weixin-remote-approval.ts index 24491ea..0f98ac1 100644 --- a/desktop/src/windows-node-mxc-weixin-remote-approval.ts +++ b/desktop/src/windows-node-mxc-weixin-remote-approval.ts @@ -10,38 +10,81 @@ export const WINDOWS_NODE_MXC_WEIXIN_REMOTE_APPROVAL_MAX_ATTEMPTS = 3; const CHALLENGE_ALPHABET = "23456789ABCDEFGHJKLMNPQRSTUVWXYZ"; const CHALLENGE_CODE_LENGTH = 8; const MAX_GLOBAL_ATTEMPTS_PER_MINUTE = 20; -const MAX_PROMPT_ARGUMENTS = 8; -const MAX_PROMPT_DECLARATIONS = 8; const MAX_PROMPT_REQUESTS = 8; const MAX_PROMPT_LINE = 160; const MAX_PROMPT_LENGTH = 6_000; -const DANGEROUS_PROMPT_UNICODE_PATTERN = - /[\p{Cc}\p{Cf}\p{Cs}\p{Zl}\p{Zp}\p{Default_Ignorable_Code_Point}]/u; -const SAFE_SIMPLE_ARGUMENT_PATTERN = /^[A-Za-z][A-Za-z0-9._-]{0,15}$/; -const SAFE_NUMERIC_ARGUMENT_PATTERN = /^-?(?:0|[1-9][0-9]{0,18})(?:\.[0-9]{1,8})?$/; -const SAFE_SWITCH_PATTERN = - /^(?:--?[A-Za-z][A-Za-z0-9-]{0,31}|\/[A-Za-z][A-Za-z0-9-]{0,31})$/; -const SAFE_RELATIVE_FILE_PATTERN = - /^[A-Za-z0-9][A-Za-z0-9._-]{0,63}\.[A-Za-z0-9]{1,8}$/; -const SAFE_STRUCTURAL_ID_PATTERN = /^[A-Za-z0-9][A-Za-z0-9:._/@-]{0,159}$/; -const SENSITIVE_SEMANTIC_PATTERN = - /(?:accountkey|authorization|auth|bearer|clientsecret|cookie|credential|key|passphrase|passwd|password|privatekey|proxyauthorization|sas|secret|setcookie|signature|sig|token)/i; -const SAFE_PATH_VALUE_SWITCHES = new Set([ - "-File", - "--file", - "--output", - "--project", - "--cwd", - "-o", -]); -const SAFE_ENUM_VALUE_SWITCHES = new Set([ - "--color", - "--configuration", - "--format", - "--mode", - "--target", -]); -const SAFE_LITERAL_ARGUMENTS = new Set(["false", "null", "true"]); +const REMOTE_APPROVAL_SCRATCH_CWD = "isolated-scratch:v1"; + +type WeixinRemoteApprovalProgram = + | "windows-hostname" + | "windows-identity" + | "windows-network" + | "windows-system-information"; +type WeixinRemoteApprovalOption = "none" | "user" | "groups" | "privileges" | "all"; +type WeixinRemoteApprovalEffectiveMode = + | "base-container" + | "appcontainer-bfs" + | "appcontainer-dacl" + | "limited-dacl" + | "full"; + +const REMOTE_APPROVAL_TUPLES: ReadonlyArray<{ + program: WeixinRemoteApprovalProgram; + option: WeixinRemoteApprovalOption; + executable: string; + executablePath: string; + arguments: readonly string[]; + command: string; +}> = [ + { + program: "windows-hostname", + option: "none", + executable: "hostname.exe", + executablePath: "C:\\Windows\\System32\\hostname.exe", + arguments: [], + command: "hostname.exe", + }, + { + program: "windows-identity", + option: "none", + executable: "whoami.exe", + executablePath: "C:\\Windows\\System32\\whoami.exe", + arguments: [], + command: "whoami.exe", + }, + ...(["user", "groups", "priv"] as const).map((option) => ({ + program: "windows-identity" as const, + option: option === "priv" ? ("privileges" as const) : option, + executable: "whoami.exe", + executablePath: "C:\\Windows\\System32\\whoami.exe", + arguments: [`/${option}`], + command: `whoami.exe /${option}`, + })), + { + program: "windows-network", + option: "none", + executable: "ipconfig.exe", + executablePath: "C:\\Windows\\System32\\ipconfig.exe", + arguments: [], + command: "ipconfig.exe", + }, + { + program: "windows-network", + option: "all", + executable: "ipconfig.exe", + executablePath: "C:\\Windows\\System32\\ipconfig.exe", + arguments: ["/all"], + command: "ipconfig.exe /all", + }, + { + program: "windows-system-information", + option: "none", + executable: "systeminfo.exe", + executablePath: "C:\\Windows\\System32\\systeminfo.exe", + arguments: [], + command: "systeminfo.exe", + }, +]; export interface WeixinRemoteApprovalOwnerBinding { contract: typeof WINDOWS_NODE_MXC_WEIXIN_REMOTE_APPROVAL_CONTRACT; @@ -114,6 +157,15 @@ export interface WeixinRemoteApprovalCreateInput { now?: number; } +export interface WeixinRemoteApprovalPresentation { + requests: Array<{ + program: WeixinRemoteApprovalProgram; + option: WeixinRemoteApprovalOption; + }>; + requestedMode: "limited-dacl" | "full"; + effectiveMode: WeixinRemoteApprovalEffectiveMode; +} + export interface WeixinRemoteApprovalDelivery { contract: typeof WINDOWS_NODE_MXC_WEIXIN_REMOTE_APPROVAL_CONTRACT; challengeId: string; @@ -321,225 +373,175 @@ function createCode(): string { return result; } -function exactPromptValue(value: string): string { +function exactStringArrayEquals(left: string[], right: readonly string[]): boolean { /* .replace(BEARER_PATTERN, "Bearer [REDACTED]") */ - return JSON.stringify(value); -} - -function isSafeAsciiDisplayValue(value: string): boolean { - return ( - value.length > 0 && - /^[\x20-\x7e]+$/.test(value) && - !DANGEROUS_PROMPT_UNICODE_PATTERN.test(value) - ); -} - -function hasSensitiveSemantics(value: string): boolean { - return SENSITIVE_SEMANTIC_PATTERN.test(value.replace(/[^A-Za-z0-9]/g, "")); -} - -function isSafeWindowsPath(value: string): boolean { - if ( - !isSafeAsciiDisplayValue(value) || - !/^[A-Za-z]:\\/.test(value) || - /["'<>|?*=;&]/.test(value) - ) { - return false; - } - const segments = value.slice(3).split("\\"); - return segments.every( - (segment) => - segment.length > 0 && - segment.length <= 80 && - !segment.endsWith(" ") && - !segment.endsWith(".") && - !hasSensitiveSemantics(segment), - ); + return left.length === right.length && left.every((value, index) => value === right[index]); } -function isSafeRelativeFile(value: string): boolean { +function isEffectiveMode(value: string): value is WeixinRemoteApprovalEffectiveMode { return ( - isSafeAsciiDisplayValue(value) && - SAFE_RELATIVE_FILE_PATTERN.test(value) && - !hasSensitiveSemantics(value) + value === "base-container" || + value === "appcontainer-bfs" || + value === "appcontainer-dacl" || + value === "limited-dacl" || + value === "full" ); } -function isSafeSimpleArgument(value: string): boolean { - return ( - isSafeAsciiDisplayValue(value) && - (SAFE_LITERAL_ARGUMENTS.has(value.toLowerCase()) || - SAFE_NUMERIC_ARGUMENT_PATTERN.test(value) || - (SAFE_SIMPLE_ARGUMENT_PATTERN.test(value) && !hasSensitiveSemantics(value))) +function findRemoteApprovalTuple(approval: WeixinRemoteApprovalBatchRequest["approval"]) { + return REMOTE_APPROVAL_TUPLES.find( + (candidate) => + approval.executable === candidate.executable && + approval.executablePath === candidate.executablePath && + approval.command === candidate.command && + exactStringArrayEquals(approval.arguments, candidate.arguments), ); } -function isSafeSwitch(value: string): boolean { - return ( - isSafeAsciiDisplayValue(value) && - SAFE_SWITCH_PATTERN.test(value) && - !hasSensitiveSemantics(value) - ); +function isPromptLineLengthValid(lines: string[]): boolean { + return lines.every((line) => line.length <= MAX_PROMPT_LINE); } -function argumentsCanBeSafelyRendered(values: string[]): boolean { - for (let index = 0; index < values.length; index += 1) { - const value = values[index]; - if (isSafeWindowsPath(value) || isSafeRelativeFile(value)) continue; - if (isSafeSwitch(value)) { - if (SAFE_PATH_VALUE_SWITCHES.has(value)) { - const pathValue = values[index + 1]; - if ( - pathValue === undefined || - (!isSafeWindowsPath(pathValue) && !isSafeRelativeFile(pathValue)) - ) { - return false; - } - index += 1; - } else if (SAFE_ENUM_VALUE_SWITCHES.has(value)) { - const enumValue = values[index + 1]; - if (enumValue === undefined || !isSafeSimpleArgument(enumValue)) return false; - index += 1; - } else if (values[index + 1] !== undefined && !isSafeSwitch(values[index + 1])) { - return false; - } - continue; - } - if (SAFE_LITERAL_ARGUMENTS.has(value.toLowerCase())) continue; - return false; - } - return true; -} - -function quoteWindowsArgument(value: string): string { - if (value.length > 0 && !/\s/.test(value) && !value.includes('"')) return value; - let output = '"'; - let slashes = 0; - for (const character of value) { - if (character === "\\") { - slashes += 1; - continue; - } - if (character === '"') { - output += `${"\\".repeat(slashes * 2 + 1)}"`; - slashes = 0; - continue; - } - output += `${"\\".repeat(slashes)}${character}`; - slashes = 0; +export function classifyWeixinRemoteApprovalPresentation( + requests: WeixinRemoteApprovalBatchRequest[], + security: WeixinRemoteApprovalSecurityBinding, +): WeixinRemoteApprovalPresentation | null { + if ( + requests.length === 0 || + requests.length > MAX_PROMPT_REQUESTS || + !isEffectiveMode(security.effectiveMode) + ) { + return null; } - return `${output}${"\\".repeat(slashes * 2)}"`; -} - -function exactWindowsCommand(values: string[]): string { - return values.map(quoteWindowsArgument).join(" "); -} - -function approvalHasSafeStructuredPresentation( - approval: WindowsNodeMxcGatewayApproval, -): boolean { - const canonicalCwdIsSafe = - approval.canonicalCwd === "isolated-scratch:v1" || isSafeWindowsPath(approval.canonicalCwd); - return ( - (isSafeRelativeFile(approval.executable) || isSafeWindowsPath(approval.executable)) && - isSafeWindowsPath(approval.executablePath) && - canonicalCwdIsSafe && - SAFE_STRUCTURAL_ID_PATTERN.test(approval.sessionKey) && - (approval.agent === null || SAFE_STRUCTURAL_ID_PATTERN.test(approval.agent)) && - argumentsCanBeSafelyRendered(approval.arguments) && - approval.command === exactWindowsCommand([approval.executable, ...approval.arguments]) && - approval.declaredAccess.every((entry) => isSafeWindowsPath(entry.path)) - ); -} - -function renderExactArguments(values: string[]): string { - return values.map(exactPromptValue).join(" ") || "(none)"; -} - -function renderExactDeclarations(values: Array<{ access: string; path: string }>): string { - return ( - values.map((entry) => `${entry.access}:${exactPromptValue(entry.path)}`).join(" ") || "(none)" - ); -} - -function promptCanRepresentRequests(requests: WeixinRemoteApprovalBatchRequest[]): boolean { - if (requests.length === 0 || requests.length > MAX_PROMPT_REQUESTS) return false; - return requests.every(({ approval }) => { + const presentationRequests: WeixinRemoteApprovalPresentation["requests"] = []; + for (const { approval } of requests) { if ( - approval.arguments.length > MAX_PROMPT_ARGUMENTS || - approval.declaredAccess.length > MAX_PROMPT_DECLARATIONS || - !/^[a-f0-9]{64}$/i.test(approval.executableSha256) || - !approvalHasSafeStructuredPresentation(approval) + approval.canonicalCwd !== REMOTE_APPROVAL_SCRATCH_CWD || + approval.declaredAccess.length !== 0 || + approval.hasMutableFileOperand || + !approval.allowedDecisions.includes("allow-once") || + !approval.allowedDecisions.includes("deny") || + !/^[a-f0-9]{64}$/i.test(approval.executableSha256) ) { - return false; + return null; } - return [ - exactPromptValue(approval.command), - exactPromptValue(approval.executablePath || approval.executable), - approval.executableSha256, - renderExactArguments(approval.arguments), - exactPromptValue(approval.canonicalCwd), - renderExactDeclarations(approval.declaredAccess), - exactPromptValue(approval.agent ?? ""), - exactPromptValue(approval.sessionKey), - String(approval.hasMutableFileOperand), - approval.allowedDecisions.join(","), - ].every((line) => line.length <= MAX_PROMPT_LINE); - }); + const tuple = findRemoteApprovalTuple(approval); + if (!tuple) return null; + presentationRequests.push({ program: tuple.program, option: tuple.option }); + } + return { + requests: presentationRequests, + requestedMode: security.requestedMode, + effectiveMode: security.effectiveMode, + }; } +const PROGRAM_LABELS: Record> = { + "windows-hostname": { + "en-US": "Windows host name", + "zh-CN": "Windows 主机名", + }, + "windows-identity": { + "en-US": "Current Windows identity", + "zh-CN": "当前 Windows 身份", + }, + "windows-network": { + "en-US": "Windows network configuration", + "zh-CN": "Windows 网络配置", + }, + "windows-system-information": { + "en-US": "Windows system information", + "zh-CN": "Windows 系统信息", + }, +}; + +const OPTION_LABELS: Record> = { + none: { "en-US": "none", "zh-CN": "无" }, + user: { "en-US": "user details", "zh-CN": "用户详情" }, + groups: { "en-US": "group memberships", "zh-CN": "组成员身份" }, + privileges: { "en-US": "privileges", "zh-CN": "权限列表" }, + all: { "en-US": "all details", "zh-CN": "全部详情" }, +}; + +const REQUESTED_MODE_LABELS: Record< + WeixinRemoteApprovalPresentation["requestedMode"], + Record<"en-US" | "zh-CN", string> +> = { + "limited-dacl": { + "en-US": "Limited containment (DACL)", + "zh-CN": "有限隔离(DACL)", + }, + full: { "en-US": "Full containment", "zh-CN": "完整隔离" }, +}; + +const EFFECTIVE_MODE_LABELS: Record< + WeixinRemoteApprovalEffectiveMode, + Record<"en-US" | "zh-CN", string> +> = { + "base-container": { "en-US": "Base Container", "zh-CN": "Base Container" }, + "appcontainer-bfs": { "en-US": "AppContainer BFS", "zh-CN": "AppContainer BFS" }, + "appcontainer-dacl": { + "en-US": "AppContainer DACL", + "zh-CN": "AppContainer DACL", + }, + "limited-dacl": { + "en-US": "Limited containment (DACL)", + "zh-CN": "有限隔离(DACL)", + }, + full: { "en-US": "Full containment", "zh-CN": "完整隔离" }, +}; + export function renderWeixinRemoteApprovalPrompt(params: { code: string; expiresAtUnixMs: number; - requests: WeixinRemoteApprovalBatchRequest[]; - security: WeixinRemoteApprovalSecurityBinding; + presentation: WeixinRemoteApprovalPresentation; locale: "en-US" | "zh-CN"; }): string { + const requestLines = params.presentation.requests.flatMap((request, index) => + params.locale === "zh-CN" + ? [ + `请求 ${index + 1}: ${PROGRAM_LABELS[request.program]["zh-CN"]}`, + `选项: ${OPTION_LABELS[request.option]["zh-CN"]}`, + ] + : [ + `Request ${index + 1}: ${PROGRAM_LABELS[request.program]["en-US"]}`, + `Options: ${OPTION_LABELS[request.option]["en-US"]}`, + ], + ); const lines = params.locale === "zh-CN" ? [ `[MicroClaw 安全审批 #${params.code}]`, "仅批准下列当前请求;不会创建永久授权,也不会批准未来请求。", - ...params.requests.flatMap(({ approval }, index) => [ - `请求 ${index + 1} 命令: ${exactPromptValue(approval.command)}`, - `可执行文件: ${exactPromptValue(approval.executablePath || approval.executable)}`, - `SHA-256: ${approval.executableSha256}`, - `参数: ${renderExactArguments(approval.arguments)}`, - `工作目录: ${exactPromptValue(approval.canonicalCwd)}`, - `文件访问: ${renderExactDeclarations(approval.declaredAccess)}`, - `代理/会话: ${exactPromptValue(approval.agent ?? "")} / ${exactPromptValue(approval.sessionKey)}`, - `可变文件操作数: ${approval.hasMutableFileOperand ? "是" : "否"}`, - `本地可用决策: ${approval.allowedDecisions.join(", ")}`, - ]), - `模式: ${params.security.requestedMode} / ${exactPromptValue(params.security.effectiveMode)}`, + "远程显示仅支持预定义、无不透明操作数的只读系统查询。", + ...requestLines, + `隔离模式: ${REQUESTED_MODE_LABELS[params.presentation.requestedMode]["zh-CN"]} / ${EFFECTIVE_MODE_LABELS[params.presentation.effectiveMode]["zh-CN"]}`, `过期时间: ${new Date(params.expiresAtUnixMs).toISOString()}`, `回复: 1 ${params.code}(仅批准此请求一次)`, ` 2 ${params.code}(拒绝)`, - ` 3 ${params.code}(仅批准本消息列出的全部 ${params.requests.length} 个请求一次)`, + ` 3 ${params.code}(仅批准本消息列出的全部 ${params.presentation.requests.length} 个请求一次)`, ] : [ `[MicroClaw Security Approval #${params.code}]`, "This approves only the current request(s); it creates no durable or future approval.", - ...params.requests.flatMap(({ approval }, index) => [ - `Request ${index + 1} command: ${exactPromptValue(approval.command)}`, - `Executable: ${exactPromptValue(approval.executablePath || approval.executable)}`, - `SHA-256: ${approval.executableSha256}`, - `Args: ${renderExactArguments(approval.arguments)}`, - `CWD: ${exactPromptValue(approval.canonicalCwd)}`, - `File access: ${renderExactDeclarations(approval.declaredAccess)}`, - `Agent/session: ${exactPromptValue(approval.agent ?? "")} / ${exactPromptValue(approval.sessionKey)}`, - `Mutable file operand: ${approval.hasMutableFileOperand ? "yes" : "no"}`, - `Locally available decisions: ${approval.allowedDecisions.join(", ")}`, - ]), - `Mode: ${params.security.requestedMode} / ${exactPromptValue(params.security.effectiveMode)}`, + "Remote display supports only predefined read-only system queries with no opaque operands.", + ...requestLines, + `Containment: ${REQUESTED_MODE_LABELS[params.presentation.requestedMode]["en-US"]} / ${EFFECTIVE_MODE_LABELS[params.presentation.effectiveMode]["en-US"]}`, `Expires: ${new Date(params.expiresAtUnixMs).toISOString()}`, `Reply: 1 ${params.code} (approve this request once)`, ` 2 ${params.code} (deny)`, - ` 3 ${params.code} (approve all ${params.requests.length} listed request(s) once)`, + ` 3 ${params.code} (approve all ${params.presentation.requests.length} listed request(s) once)`, ]; - return lines.join("\n"); + if (!isPromptLineLengthValid(lines)) { + throw new Error("Remote approval prompt exceeded its fixed line limit"); + } + const prompt = lines.join("\n"); + if (prompt.length > MAX_PROMPT_LENGTH) { + throw new Error("Remote approval prompt exceeded its fixed total limit"); + } + return prompt; } function acknowledgement(locale: "en-US" | "zh-CN", result: string): string { @@ -602,6 +604,7 @@ export class WindowsNodeMxcWeixinRemoteApprovalBroker { createChallenge(input: WeixinRemoteApprovalCreateInput): WeixinRemoteApprovalDelivery | null { const now = input.now ?? Date.now(); const owner = this.configuration.owner; + const presentation = classifyWeixinRemoteApprovalPresentation(input.requests, input.security); if ( !this.configuration.enabled || !owner || @@ -613,8 +616,7 @@ export class WindowsNodeMxcWeixinRemoteApprovalBroker { normalizeOpaqueId(input.source.conversationId) !== normalizeOpaqueId(input.source.senderId) || !Number.isSafeInteger(input.source.cursor) || input.source.cursor < 0 || - !promptCanRepresentRequests(input.requests) || - !SAFE_STRUCTURAL_ID_PATTERN.test(input.security.effectiveMode) + !presentation ) { return null; } @@ -663,16 +665,9 @@ export class WindowsNodeMxcWeixinRemoteApprovalBroker { const text = renderWeixinRemoteApprovalPrompt({ code, expiresAtUnixMs, - requests: input.requests, - security: input.security, + presentation, locale: input.locale, }); - if ( - text.length > MAX_PROMPT_LENGTH || - text.split("\n").some((line) => line.length > MAX_PROMPT_LINE) - ) { - return null; - } this.challengesByChat.set(chatKey, challenge); return { contract: WINDOWS_NODE_MXC_WEIXIN_REMOTE_APPROVAL_CONTRACT, diff --git a/docs/experimental-windows-node-mxc.md b/docs/experimental-windows-node-mxc.md index d256064..039506d 100644 --- a/docs/experimental-windows-node-mxc.md +++ b/docs/experimental-windows-node-mxc.md @@ -208,25 +208,25 @@ so a crash replays rather than drops them. The reserved plain-text prompt begins `[MicroClaw Security Approval #CODE]`. The code is eight characters from an unambiguous 32-symbol alphabet (40 bits), generated with `randomBytes`, valid for -two minutes, one-use, and limited to three wrong attempts plus a global attempt window. The prompt -renders authorization-relevant strings exactly and reversibly with JSON string quoting. It does not -trim or NFC-normalize, rewrite user-profile paths, parse and reserialize JSON, convert numeric text, -collapse duplicate keys, redact partial values, or otherwise change code points. It includes the -complete command text, executable canonical path and hash, argv, canonical CWD, declared file -access, agent/session, requested/effective MXC mode, expiry, and every request covered by option 3. -If those exact fields exceed deterministic request/argument/declaration/line/total limits, contain -control, C0/C1, surrogate, bidi/format/default-ignorable, line/paragraph separator, zero-width, -variation-selector, or similar concealment characters, or appear secret-bearing or ambiguously -redacted, MicroClaw creates no remote challenge and leaves the normal local approval pending. -OpenClaw 2026.10.2 does not carry reliable sensitive-value provenance for every command display -field, so Phase 1 additionally uses a fail-closed structured presentation allowlist. The full -command must exactly reconstruct from the signed argv; executable/CWD/declaration values must be -canonical local paths or fixed structural bindings; and arguments are limited to bounded -parameterless switches, explicitly classified path/enum switch values, boolean literals, and short -relative filenames. Arbitrary positional text or numbers, inline option values, URLs, long opaque -values, credential-semantic names, and every other unknown form remain local-only. -This is intentionally conservative: a non-secret command may still require local approval when its -display sensitivity cannot be proven from the current execution contract. +two minutes, one-use, and limited to three wrong attempts plus a global attempt window. The current +execution approval contract does not carry trustworthy field-level sensitivity provenance, so +remote eligibility is deliberately fail-closed rather than based on a finite secret denylist. +Electron derives a separate presentation DTO only for exact built-in, read-only Windows query +tuples with scratch CWD, no declared file access, no mutable file operand, recognized containment, +and both `allow-once` and `deny` available: + +- `C:\Windows\System32\hostname.exe` with no arguments +- `C:\Windows\System32\whoami.exe` with no arguments or exactly `/user`, `/groups`, or `/priv` +- `C:\Windows\System32\ipconfig.exe` with no arguments or exactly `/all` +- `C:\Windows\System32\systeminfo.exe` with no arguments + +The WeChat prompt is rendered exclusively from constant localized labels and recognized enums. It +does not send raw command text, executable paths or hashes, argv, CWD, declarations, agent/session, +or other free text to WeChat. Unknown executables or operands, free-form commands, declarations, +non-scratch CWDs, mutable operands, unknown containment modes, secret-bearing requests, and any +future tuple not explicitly added and reviewed remain local-only. The complete unmodified approval +still remains in Electron memory and is integrity-bound below; the presentation DTO never weakens +that exact binding. Fixed request/line/total caps apply to the final localized prompt. - `1 CODE` approves the exact first/current request once. - `2 CODE` denies the exact request. From 0ee87a45725c4ee7a4d27c4352a9ea44b312e637 Mon Sep 17 00:00:00 2001 From: Copilot App <223556219+Copilot@users.noreply.github.com> Date: Thu, 15 Oct 2026 16:50:19 +0800 Subject: [PATCH 2/3] test: cover unknown approval operands Keep arbitrary free-form and structurally unknown values local-only even when they do not match known credential shapes. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- ...ws-node-mxc-weixin-remote-approval.test.ts | 23 +++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/desktop/src/windows-node-mxc-weixin-remote-approval.test.ts b/desktop/src/windows-node-mxc-weixin-remote-approval.test.ts index 78eee64..62af515 100644 --- a/desktop/src/windows-node-mxc-weixin-remote-approval.test.ts +++ b/desktop/src/windows-node-mxc-weixin-remote-approval.test.ts @@ -467,6 +467,29 @@ describe("WeChat remote approval broker", () => { expect(JSON.stringify(broker.getAuditEvents())).not.toContain(secretValue); }); + it.each([ + "hunter2", + "shortvalue", + "123456", + "654321", + "correct horse battery staple", + "abcdefghijklmnopqrstuvwxyz0123456789", + "https://example.test/path", + '{"unknown":"free-text"}', + ])("keeps unknown free-form value local-only: %s", (unknownValue) => { + const broker = configuredBroker(); + const unsafe = input(); + unsafe.requests = [{ approval: { ...approval, arguments: [unknownValue] } }]; + expect(broker.createChallenge(unsafe)).toBeNull(); + }); + + it("keeps an unknown switch value local-only even when both tokens look harmless", () => { + const broker = configuredBroker(); + const unsafe = input(); + unsafe.requests = [{ approval: { ...approval, arguments: ["--opaque", "shortvalue"] } }]; + expect(broker.createChallenge(unsafe)).toBeNull(); + }); + it("enforces deterministic request, argument, declaration, line, and total caps", () => { const broker = configuredBroker(); const tooManyArguments = input(); From b84463af8a60e387e9b44a244de35fa6598152dd Mon Sep 17 00:00:00 2001 From: Copilot App <223556219+Copilot@users.noreply.github.com> Date: Thu, 15 Oct 2026 16:52:46 +0800 Subject: [PATCH 3/3] test: align remote approval risk copy Validate the final English and Chinese local-only disclosure after rebasing onto the merged feature. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- desktop/renderer/src/views/SettingsView.test.ts | 2 -- 1 file changed, 2 deletions(-) diff --git a/desktop/renderer/src/views/SettingsView.test.ts b/desktop/renderer/src/views/SettingsView.test.ts index 3472b29..92f69ae 100644 --- a/desktop/renderer/src/views/SettingsView.test.ts +++ b/desktop/renderer/src/views/SettingsView.test.ts @@ -360,7 +360,6 @@ describe("SettingsView", () => { expect(card.text()).toContain("allowlisted openclaw-weixin plugin and Gateway"); expect(card.text()).toContain("Free-form, unknown, or secret-bearing requests stay local-only"); expect(card.text()).toContain("never create durable approvals"); - expect(card.text()).toContain("unclassified free-text command fields stay local-only"); await wrapper.find('[data-testid="weixin-remote-approval-enable"]').trigger("click"); await flushPromises(); expect(setRemoteApprovalEnabled).toHaveBeenCalledWith(true); @@ -379,7 +378,6 @@ describe("SettingsView", () => { expect(card.text()).toContain("白名单 openclaw-weixin 插件和 Gateway"); expect(card.text()).toContain("自由文本、未知或含密请求仅能在本机审批"); expect(card.text()).toContain("绝不会创建永久审批"); - expect(card.text()).toContain("无法分类的自由文本命令字段仅允许本地审批"); }); it("keeps technical MXC details collapsed on a fresh Settings view", async () => {