From ad7fe948cfb51ab6a70b7bb68dee2c529b003073 Mon Sep 17 00:00:00 2001 From: QAyong Date: Mon, 28 Sep 2026 15:38:42 +0800 Subject: [PATCH 1/2] fix(windows): allow read-only ACL grants on private directories --- .../__tests__/private_security_windows.rs | 46 +++++++------------ .../private_directories/windows/security.rs | 19 ++++---- ...0928-windows-private-directory-read-acl.md | 46 +++++++++++++++++++ 3 files changed, 71 insertions(+), 40 deletions(-) create mode 100644 docs/bugs/bug-20260928-windows-private-directory-read-acl.md diff --git a/apps/buddy/native/host/__tests__/private_security_windows.rs b/apps/buddy/native/host/__tests__/private_security_windows.rs index 6e454e96..75cd4961 100644 --- a/apps/buddy/native/host/__tests__/private_security_windows.rs +++ b/apps/buddy/native/host/__tests__/private_security_windows.rs @@ -12,8 +12,6 @@ fn rejects_null_dacl_untrusted_owner_and_allow_ace_variants_not_in_contract() { for sddl in [ "O:SYD:NO_ACCESS_CONTROL", "O:WDD:P(A;;FA;;;SY)", - "O:SYD:P(A;;FR;;;WD)", - "O:SYD:P(A;OICIIO;FR;;;WD)", "O:SYD:P(A;OICI;FA;;;CO)", "O:SYD:P(A;OICIIO;FA;;;CG)", "O:COD:P(A;OICIIO;FA;;;CO)", @@ -55,7 +53,7 @@ fn empty_acl_and_deny_entries_do_not_grant_untrusted_access() { } #[test] -fn metadata_only_grants_do_not_expose_directory_contents_or_allow_changes() { +fn read_only_grants_do_not_allow_changes() { let security = PrivateSecurity::new().unwrap(); for sid in ["WD", "BU", "AC", "S-1-5-21-1-2-3-1001"] { for mask in [ @@ -64,6 +62,9 @@ fn metadata_only_grants_do_not_expose_directory_contents_or_allow_changes() { READ_CONTROL, SYNCHRONIZE, METADATA_READ_ACCESS, + 0x120089, + 0x81, + READ_ONLY_ACCESS, ] { for flags in ["", "OICI", "OICIIO", "OICIID"] { let sddl = format!("O:SYD:P(A;OICI;FA;;;SY)(A;{flags};{mask:#x};;;{sid})"); @@ -78,18 +79,15 @@ fn metadata_only_grants_do_not_expose_directory_contents_or_allow_changes() { } #[test] -fn metadata_grants_do_not_hide_data_access_changes_or_unknown_rights() { +fn read_only_grants_do_not_hide_write_changes_or_unknown_rights() { let security = PrivateSecurity::new().unwrap(); for bit in 0..32 { let access = 1_u32 << bit; - if access & METADATA_READ_ACCESS != 0 { + if access & READ_ONLY_ACCESS != 0 { continue; } for flags in ["", "OICIIO"] { - if access == FILE_TRAVERSE && flags.is_empty() { - continue; - } - let mask = access | METADATA_READ_ACCESS; + let mask = access | READ_ONLY_ACCESS; let sddl = format!("O:SYD:P(A;OICI;FA;;;SY)(A;{flags};{mask:#x};;;WD)"); assert_eq!( security.validate_descriptor(&from_sddl(&sddl).unwrap()), @@ -108,26 +106,14 @@ fn metadata_grants_do_not_hide_data_access_changes_or_unknown_rights() { } #[test] -fn traverse_grants_are_allowed_only_when_they_cannot_be_inherited_by_files() { +fn inheritable_read_execute_grants_are_allowed() { let security = PrivateSecurity::new().unwrap(); - for flags in ["", "CI", "CIIO", "CIID"] { - let sddl = format!("O:SYD:P(A;{flags};0x1200a0;;;WD)"); - assert_eq!( - security.validate_descriptor(&from_sddl(&sddl).unwrap()), - Ok(()), - "{sddl}" - ); - } - for flags in ["OI", "OICI", "OICIIO", "OICIID"] { - let sddl = format!("O:SYD:P(A;{flags};0x1200a0;;;WD)"); - let failure = security - .validate_descriptor(&from_sddl(&sddl).unwrap()) - .unwrap_err(); - assert_eq!( - failure.acl.unwrap().reason, - DirectoryAclReason::UntrustedAccess - ); - } + let sddl = "O:SYD:P(A;OICI;FA;;;SY)(A;OICI;0x1200a9;;;WD)"; + assert_eq!( + security.validate_descriptor(&from_sddl(sddl).unwrap()), + Ok(()), + "{sddl}" + ); } #[test] @@ -141,13 +127,13 @@ fn rejection_diagnostics_classify_principals_without_serializing_sids() { ("CO", "creator_owner"), ("S-1-5-21-1-2-3-1001", "other"), ] { - let descriptor = from_sddl(&format!("O:SYD:P(A;OICIID;0x81;;;{sid})")).unwrap(); + let descriptor = from_sddl(&format!("O:SYD:P(A;OICIID;0x83;;;{sid})")).unwrap(); let failure = security.validate_descriptor(&descriptor).unwrap_err(); assert_eq!( serde_json::to_value(&failure).unwrap()["acl"], serde_json::json!({ "reason": "untrusted_access", "aceIndex": 0, "aceType": 0, - "aceFlags": 19, "accessMask": 129, "principal": principal, + "aceFlags": 19, "accessMask": 131, "principal": principal, }) ); assert!(!serde_json::to_string(&failure).unwrap().contains("S-1-")); diff --git a/apps/buddy/native/host/src/private_directories/windows/security.rs b/apps/buddy/native/host/src/private_directories/windows/security.rs index 6a82f670..949c6ce3 100644 --- a/apps/buddy/native/host/src/private_directories/windows/security.rs +++ b/apps/buddy/native/host/src/private_directories/windows/security.rs @@ -9,12 +9,13 @@ use windows_sys::Win32::{ GetSecurityInfo, SDDL_REVISION_1, SE_FILE_OBJECT, }, DACL_SECURITY_INFORMATION, GetAce, GetSecurityDescriptorDacl, GetSecurityDescriptorOwner, - INHERIT_ONLY_ACE, IsValidAcl, OBJECT_INHERIT_ACE, OWNER_SECURITY_INFORMATION, - PSECURITY_DESCRIPTOR, WinAuthenticatedUserSid, WinBuiltinAdministratorsSid, - WinBuiltinAnyPackageSid, WinBuiltinUsersSid, WinCreatorOwnerSid, WinLocalSystemSid, - WinWorldSid, + INHERIT_ONLY_ACE, IsValidAcl, OWNER_SECURITY_INFORMATION, PSECURITY_DESCRIPTOR, + WinAuthenticatedUserSid, WinBuiltinAdministratorsSid, WinBuiltinAnyPackageSid, + WinBuiltinUsersSid, WinCreatorOwnerSid, WinLocalSystemSid, WinWorldSid, + }, + Storage::FileSystem::{ + FILE_EXECUTE, FILE_READ_ATTRIBUTES, FILE_READ_DATA, FILE_READ_EA, READ_CONTROL, SYNCHRONIZE, }, - Storage::FileSystem::{FILE_READ_ATTRIBUTES, FILE_TRAVERSE, READ_CONTROL, SYNCHRONIZE}, System::{ SystemServices::{ACCESS_ALLOWED_ACE_TYPE, ACCESS_DENIED_ACE_TYPE}, Threading::GetCurrentProcess, @@ -26,6 +27,7 @@ use super::{DirectoryError, DirectoryFailure, DirectoryOperation, SystemErrorDom use crate::windows_security::{Sid, process_user_sid}; const METADATA_READ_ACCESS: u32 = FILE_READ_ATTRIBUTES | READ_CONTROL | SYNCHRONIZE; +const READ_ONLY_ACCESS: u32 = METADATA_READ_ACCESS | FILE_READ_DATA | FILE_READ_EA | FILE_EXECUTE; struct LocalMemory(*mut c_void); @@ -243,11 +245,8 @@ impl PrivateSecurity { && sid == self.creator_owner; // SAFETY: The validated standard allow ACE includes its fixed access mask. let mask = unsafe { (*ace.cast::()).Mask }; - let directory_only = u32::from(header.AceFlags) & OBJECT_INHERIT_ACE == 0; - let harmless_access = - METADATA_READ_ACCESS | if directory_only { FILE_TRAVERSE } else { 0 }; - let metadata_only = mask & !harmless_access == 0; - if !self.trusted.contains(&sid) && !owner_template && !metadata_only { + let read_only = mask & !READ_ONLY_ACCESS == 0; + if !self.trusted.contains(&sid) && !owner_template && !read_only { return Err(DirectoryFailure::acl(DirectoryAclFailure { access_mask: Some(mask), principal: Some(self.principal(&sid)), diff --git a/docs/bugs/bug-20260928-windows-private-directory-read-acl.md b/docs/bugs/bug-20260928-windows-private-directory-read-acl.md new file mode 100644 index 00000000..69a4ca38 --- /dev/null +++ b/docs/bugs/bug-20260928-windows-private-directory-read-acl.md @@ -0,0 +1,46 @@ +# Bug:Windows 私有目录拒绝 Agent 的只读权限 + +**日期:** 2026-09-28
+**优先级:** 中 +**状态:** 已修复 + +## 复现步骤 + +1. 在 Windows 安装并启动 Lexora Buddy。 +2. 使用 Codex Windows 沙盒,让其为用户配置目录下的 `.lexora` 添加沙盒用户读取权限。 +3. 启动 Lexora Buddy;移除该权限后,Codex 沙盒再次补回权限时,问题会重现。 + +## 实际结果 + +应用启动失败并提示 `PRIVATE_DIRECTORIES_UNSAFE`,失败步骤为 `validate_acl / lexora_home`。本机诊断中的 ACL 为:`principal=other`、`mask=0x1200a9`、`flags=0x3`。这条允许读取和遍历目录的权限被当成不安全权限拒绝。 + +## 预期结果 + +仅有读取、列目录、读取属性和遍历权限的额外主体不应阻止应用启动。额外主体仍不得通过 ACL 获得写入、删除或其他修改能力。 + +## 影响范围 + +Windows 桌面版启动时对 `lexora_home`(Lexora 用户数据根目录)的 ACL 检查。任何 Agent 或其他工具为该目录添加只读权限时,都可能触发原问题。 + +## 初步判断 + +`CodexSandboxUsers` 是 Codex Windows 沙盒使用的主体。Codex 为用户配置目录补充读取权限后,Lexora 原先只接受元数据读取权限,并拒绝可读取目录内容的权限,因此两种安全策略发生冲突。ACL 表示该主体具备读取能力,不代表它实际读取过目录内容。 + +| 名称 | 含义 | +|---|---| +| `lexora_home` | Lexora 保存用户数据的根目录 | +| `validate_acl` | 检查 Windows 目录访问控制列表的启动步骤 | +| `CodexSandboxUsers` | Codex Windows 沙盒使用的本地组 | +| `mask=0x1200a9` | 该权限允许读取目录内容、读取属性、遍历目录等,不含写入权限 | +| `flags=0x3` | 权限可继承给子文件和子目录 | + +## 处理方式 + +Windows ACL 校验现在允许额外主体拥有只读、列目录、读取属性和执行/遍历权限;仍拒绝含写入等修改权限的 ACL。规则按权限类型生效,不专门信任 Codex 组。 + +**安全影响:** 获得这些只读权限的主体可以读取 `.lexora` 中的文件。此修复没有迁移或另行保护目录中的数据。 + +**验收记录:** Windows 原生测试 43 项通过;Rust 格式检查通过;Windows 安装包已重新生成。 + +相关实现:`apps/buddy/native/host/src/private_directories/windows/security.rs`。 +相关测试:`apps/buddy/native/host/__tests__/private_security_windows.rs`。 From de706a92ab9bfa33309528043cbd2533c1546b97 Mon Sep 17 00:00:00 2001 From: shanyuhai123 <864299347@qq.com> Date: Mon, 28 Sep 2026 17:46:35 +0800 Subject: [PATCH 2/2] =?UTF-8?q?test(windows):=20=E5=AE=8C=E5=96=84?= =?UTF-8?q?=E7=9B=AE=E5=BD=95=E6=9D=83=E9=99=90=E4=B8=8E=E5=90=AF=E5=8A=A8?= =?UTF-8?q?=E5=9B=9E=E5=BD=92=E6=B5=8B=E8=AF=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .playwright/scripts/__tests__/desktop.e2e.mjs | 34 ++++ .../windowsDesktopStorage.native.spec.ts | 170 ++++++++++++++++ .../windowsDesktopStorageAcceptance.ts | 127 ------------ .../__tests__/private_security_windows.rs | 10 +- .../private_directories/windows/security.rs | 1 + .../windowsPrivateDirectories.native.spec.ts | 191 ++++++++++++++++++ .../windowsPrivateDirectoriesAcceptance.ts | 167 --------------- .../windows/__tests__/host.native.spec.ts | 23 +-- ...0928-windows-private-directory-read-acl.md | 46 ----- 9 files changed, 404 insertions(+), 365 deletions(-) create mode 100644 apps/buddy/electron/main/app/__tests__/windowsDesktopStorage.native.spec.ts delete mode 100644 apps/buddy/electron/main/app/__tests__/windowsDesktopStorageAcceptance.ts create mode 100644 apps/buddy/platform/filesystem/__tests__/windowsPrivateDirectories.native.spec.ts delete mode 100644 apps/buddy/platform/filesystem/__tests__/windowsPrivateDirectoriesAcceptance.ts delete mode 100644 docs/bugs/bug-20260928-windows-private-directory-read-acl.md diff --git a/.playwright/scripts/__tests__/desktop.e2e.mjs b/.playwright/scripts/__tests__/desktop.e2e.mjs index be70cbce..3506dce6 100644 --- a/.playwright/scripts/__tests__/desktop.e2e.mjs +++ b/.playwright/scripts/__tests__/desktop.e2e.mjs @@ -3,6 +3,7 @@ import fs from 'node:fs/promises' import { createRequire } from 'node:module' import path from 'node:path' import process from 'node:process' +import { inspectWindowsPrivateDirectory as inspect } from '../../../apps/buddy/platform/filesystem/__tests__/windowsPrivateDirectoryFixture.ts' import { expect, test } from '../fixtures/electron.mjs' test('three concurrent instances isolate their data and survive another instance crashing', async ({ buddy }) => { @@ -54,6 +55,39 @@ test('graceful restart preserves the same instance configuration and browser sta expect(await restarted.page.evaluate(() => localStorage.getItem('restart-marker'))).toBe('persisted') }) +test.describe('Windows storage permissions', () => { + test.skip(process.platform !== 'win32', 'Windows ACL integration') + const privateAcl = 'D:P(A;OICI;FA;;;CURRENT)(A;OICI;FA;;;SY)(A;OICI;FA;;;BA)' + + test('read-only grants allow application startup without changing the ACL', async ({ buddy }) => { + const instance = await buddy.createInstance('read-only-storage') + const before = inspect(instance.home, `${privateAcl}(A;OICI;0x1200a9;;;BU)`) + const { app, page, diagnostics } = await instance.launch() + expect(await (await app.browserWindow(page)).evaluate(window => window.isVisible())).toBe(true) + await expect.poll(async () => (await page.evaluate(() => window.lexoraDesktop.localChat.runtime.getStatus())).status).toBe('ready') + expect(diagnostics.console.filter(item => item.type === 'pageerror')).toEqual([]) + await instance.stop() + expect(inspect(instance.home).sddl).toBe(before.sddl) + }) + + test('write grants stop startup before loading product data', async ({ buddy }) => { + const instance = await buddy.createInstance('writable-storage') + const before = inspect(instance.home, `${privateAcl}(A;OICI;0x1200ab;;;BU)`) + const configPath = path.join(instance.home, 'config.toml') + const config = await fs.readFile(configPath, 'utf8') + await expect(instance.launch()).rejects.toThrow('Application failed to start') + expect(inspect(instance.home).sddl).toBe(before.sddl) + expect(await fs.readFile(configPath, 'utf8')).toBe(config) + await expect(fs.access(path.join(instance.home, 'buddy/buddy.sqlite3'))).rejects.toMatchObject({ code: 'ENOENT' }) + const records = (await fs.readFile(path.join(instance.home, '.runtime/state/logs/application.jsonl'), 'utf8')) + .trim() + .split('\n') + .map(line => JSON.parse(line)) + expect(records.some(record => record.errorCode === 'PRIVATE_DIRECTORIES_UNSAFE' && record.failure?.directoryRole === 'lexora_home')).toBe(true) + expect(records.some(record => record.event === 'app.ready')).toBe(false) + }) +}) + test('renderer crashes recover once and then stop without a reload loop', async ({ buddy }) => { const instance = await buddy.createInstance('renderer-recovery') const { app, page } = await instance.launch() diff --git a/apps/buddy/electron/main/app/__tests__/windowsDesktopStorage.native.spec.ts b/apps/buddy/electron/main/app/__tests__/windowsDesktopStorage.native.spec.ts new file mode 100644 index 00000000..ee4e8b25 --- /dev/null +++ b/apps/buddy/electron/main/app/__tests__/windowsDesktopStorage.native.spec.ts @@ -0,0 +1,170 @@ +import assert from 'node:assert/strict' +import { mkdir, mkdtemp, readdir, readFile, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import process from 'node:process' +import { fileURLToPath } from 'node:url' +import { afterEach, beforeAll, beforeEach, describe, it } from 'vitest' +import { inspectWindowsPrivateDirectory as inspect } from '../../../../platform/filesystem/__tests__/windowsPrivateDirectoryFixture' +import { resolveBuddyPrivateDirectories } from '../../../../platform/native/nativeHost' +import { PrivateDirectoryError } from '../../../../platform/windows/privateDirectories' +import { readDiagnosticError } from '../../../../shared/diagnostics/applicationDiagnostic' +import { checkDesktopDirectories, prepareDesktopPrivateStorage } from '../desktopStorage' + +const privateAcl = 'D:P(A;OICI;FA;;;CURRENT)(A;OICI;FA;;;SY)(A;OICI;FA;;;BA)' +const additionalPrincipal = 'S-1-5-21-111111111-222222222-333333333-1001' +const inheritedAcl = `${privateAcl}(A;OICI;0x1200a9;;;${additionalPrincipal})` + +describe.skipIf(process.platform !== 'win32')('windows desktop storage', () => { + let helper: string + let root: string + + beforeAll(() => { + const executable = resolveBuddyPrivateDirectories({ appPath: fileURLToPath(new URL('../../../../', import.meta.url)), isPackaged: false, resourcesPath: '' }) + assert.ok(executable, 'Build the Windows native helpers before running runtime tests') + helper = executable + }) + beforeEach(async () => { + root = await mkdtemp(join(tmpdir(), 'lexora-desktop-storage-')) + }) + afterEach(async () => { + await rm(root, { recursive: true }) + }) + + it('preserves inherited read-execute grants, files and ACLs across repeated startup checks', async () => { + const parent = join(root, 'inherited-parent') + await mkdir(parent) + const parentSecurity = inspect(parent, inheritedAcl) + const home = join(parent, 'ordinary-mkdir-home') + const userData = join(home, '.runtime', 'electron') + await mkdir(userData, { recursive: true }) + const before = inspect(home) + assert.ok(before.allows.includes(additionalPrincipal)) + assert.ok(before.inherited.every(Boolean)) + const sentinel = join(home, 'preserved.txt') + await writeFile(sentinel, 'existing product data') + const fileSecurity = inspect(sentinel) + for (let launch = 0; launch < 2; launch++) { + prepareDesktopPrivateStorage(home, helper) + await checkDesktopDirectories({ lexora_home: home, user_data: userData }, helper) + } + assert.equal(inspect(parent).sddl, parentSecurity.sddl) + assert.equal(inspect(home).sddl, before.sddl) + assert.equal(inspect(sentinel).sddl, fileSecurity.sddl) + assert.equal(await readFile(sentinel, 'utf8'), 'existing product data') + assert.deepEqual((await readdir(home)).sort(), ['.runtime', 'preserved.txt']) + assert.deepEqual(await readdir(userData), []) + }, 60_000) + + it('creates a protected product root before nested Electron directories without changing the parent', async () => { + const parent = join(root, 'inherited-parent') + await mkdir(parent) + const parentSecurity = inspect(parent, inheritedAcl) + const home = join(parent, 'private-product-home') + prepareDesktopPrivateStorage(home, helper) + const security = inspect(home) + assert.equal(security.protected, true) + assert.deepEqual(security.allows.sort(), [security.user, 'S-1-5-18', 'S-1-5-32-544'].sort()) + const userData = join(home, '.runtime', 'electron') + await checkDesktopDirectories({ lexora_home: home, user_data: userData }, helper) + assert.ok(!inspect(userData).allows.includes(additionalPrincipal)) + assert.equal(inspect(home).sddl, security.sddl) + assert.equal(inspect(parent).sddl, parentSecurity.sddl) + }, 60_000) + + it('preserves inherited runtime directory ACLs and files without leaving probe files', async () => { + const parent = join(root, 'inherited-parent') + await mkdir(parent) + inspect(parent, inheritedAcl) + const runtime = { + user_data: join(parent, 'electron'), + session_data: join(parent, 'chromium'), + window_state: join(parent, 'state'), + } + const before = new Map() + for (const directory of Object.values(runtime)) { + await mkdir(directory) + const security = inspect(directory) + before.set(directory, security.sddl) + assert.ok(security.allows.includes(additionalPrincipal)) + await writeFile(join(directory, 'preserved.txt'), 'existing runtime data') + } + for (let launch = 0; launch < 2; launch++) + await checkDesktopDirectories(runtime, helper) + for (const directory of Object.values(runtime)) { + assert.equal(inspect(directory).sddl, before.get(directory)) + assert.equal(await readFile(join(directory, 'preserved.txt'), 'utf8'), 'existing runtime data') + assert.deepEqual(await readdir(directory), ['preserved.txt']) + } + }, 60_000) + + it('accepts read-execute grants added to existing product storage without ACL repair', async () => { + const home = join(root, 'private-product-home') + prepareDesktopPrivateStorage(home, helper) + const sentinel = join(home, 'preserved.txt') + await writeFile(sentinel, 'existing product data') + const before = inspect(home, inheritedAcl) + for (let launch = 0; launch < 2; launch++) { + prepareDesktopPrivateStorage(home, helper) + await checkDesktopDirectories({ lexora_home: home }, helper) + } + assert.equal(inspect(home).sddl, before.sddl) + assert.equal(await readFile(sentinel, 'utf8'), 'existing product data') + }, 60_000) + + it('rejects read-execute plus write before loading without ACL repair or diagnostic identity disclosure', async () => { + const home = join(root, 'private-product-home') + prepareDesktopPrivateStorage(home, helper) + const sentinel = join(home, 'preserved.txt') + await writeFile(sentinel, 'existing product data') + const before = inspect(home, `${privateAcl}(A;OICI;0x1200ab;;;${additionalPrincipal})`) + const isPrivateFailure = (error: unknown) => { + assert.ok(error instanceof PrivateDirectoryError) + assert.equal(error.code, 'PRIVATE_DIRECTORIES_UNSAFE') + assert.equal(error.failure.directoryRole, 'lexora_home') + assert.equal(error.failure.acl?.accessMask, 0x1200AB) + const diagnostic = JSON.stringify(readDiagnosticError(error)) + assert.ok(!diagnostic.includes(additionalPrincipal)) + assert.ok(!diagnostic.includes(root)) + return true + } + assert.throws(() => prepareDesktopPrivateStorage(home, helper), isPrivateFailure) + await assert.rejects(checkDesktopDirectories({ lexora_home: home, user_data: join(root, 'not-created') }, helper), isPrivateFailure) + assert.ok(!(await readdir(root)).includes('not-created')) + assert.equal(inspect(home).sddl, before.sddl) + assert.equal(await readFile(sentinel, 'utf8'), 'existing product data') + }, 60_000) + + it('reports actual file-creation denial without overwriting existing runtime data', async () => { + const userData = join(root, 'electron') + await mkdir(userData) + const sentinel = join(userData, 'preserved.txt') + await writeFile(sentinel, 'existing runtime data') + const before = inspect(userData) + const denied = inspect(userData, 'D:P(D;;0x2;;;CURRENT)(A;OICI;FA;;;CURRENT)(A;OICI;FA;;;SY)(A;OICI;FA;;;BA)') + try { + await assert.rejects(checkDesktopDirectories({ user_data: userData }), (error: unknown) => { + const diagnostic = readDiagnosticError(error) + assert.equal(diagnostic.errorCode, 'DESKTOP_BOOTSTRAP_FAILED') + assert.equal(diagnostic.failure?.kind, 'desktop_bootstrap') + if (diagnostic.failure?.kind !== 'desktop_bootstrap') + return false + assert.equal(diagnostic.failure.operation, 'probe_directory') + assert.equal(diagnostic.failure.directoryRole, 'user_data') + assert.ok(['EACCES', 'EPERM'].includes(diagnostic.failure.systemCode ?? '')) + return true + }) + assert.equal(inspect(userData).sddl, denied.sddl) + assert.equal(await readFile(sentinel, 'utf8'), 'existing runtime data') + } + finally { + inspect(userData, before.sddl) + } + }, 60_000) + + it('fails before creating product storage if the private-directory helper is missing', async () => { + const unavailable = join(root, 'missing-helper-home') + assert.throws(() => prepareDesktopPrivateStorage(unavailable), { code: 'PRIVATE_DIRECTORIES_UNAVAILABLE' }) + assert.deepEqual(await readdir(root), []) + }) +}) diff --git a/apps/buddy/electron/main/app/__tests__/windowsDesktopStorageAcceptance.ts b/apps/buddy/electron/main/app/__tests__/windowsDesktopStorageAcceptance.ts deleted file mode 100644 index 14c189f3..00000000 --- a/apps/buddy/electron/main/app/__tests__/windowsDesktopStorageAcceptance.ts +++ /dev/null @@ -1,127 +0,0 @@ -import assert from 'node:assert/strict' -import { createHash } from 'node:crypto' -import { mkdir, mkdtemp, readdir, readFile, rm, writeFile } from 'node:fs/promises' -import { tmpdir } from 'node:os' -import { join, resolve } from 'node:path' -import process from 'node:process' -import { inspectWindowsPrivateDirectory as inspect } from '../../../../platform/filesystem/__tests__/windowsPrivateDirectoryFixture' -import { ensurePrivateDirectories } from '../../../../platform/filesystem/privateDirectories' -import { PrivateDirectoryError } from '../../../../platform/windows/privateDirectories' -import { readDiagnosticError } from '../../../../shared/diagnostics/applicationDiagnostic' -import { checkDesktopDirectories, prepareDesktopPrivateStorage } from '../desktopStorage' - -assert.equal(process.platform, 'win32') -const [helperArgument, resultPath] = process.argv.slice(2) -assert.ok(helperArgument && resultPath) -const helper = resolve(helperArgument) -const root = await mkdtemp(join(tmpdir(), 'lexora-desktop-storage-')) -const checks: string[] = [] -const privateAcl = 'D:P(A;OICI;FA;;;CURRENT)(A;OICI;FA;;;SY)(A;OICI;FA;;;BA)' -const additionalPrincipal = 'S-1-5-21-111111111-222222222-333333333-1001' -const inheritedAcl = `${privateAcl}(A;OICI;0x1200a9;;;${additionalPrincipal})` - -try { - const parent = join(root, 'inherited-parent') - await mkdir(parent) - const parentSecurity = inspect(parent, inheritedAcl) - const legacyHome = join(parent, 'ordinary-mkdir-home') - await mkdir(join(legacyHome, '.runtime', 'electron'), { recursive: true }) - const legacySecurity = inspect(legacyHome) - assert.ok(legacySecurity.allows.includes(additionalPrincipal)) - await assert.rejects(ensurePrivateDirectories([legacyHome], helper), { code: 'PRIVATE_DIRECTORIES_UNSAFE' }) - assert.equal(inspect(legacyHome).sddl, legacySecurity.sddl) - checks.push('ordinary recursive mkdir reproduces inherited ACL rejection without changing the existing directory') - - const home = join(parent, 'private-product-home') - prepareDesktopPrivateStorage(home, helper) - const homeSecurity = inspect(home) - assert.equal(homeSecurity.protected, true) - assert.deepEqual(homeSecurity.allows.sort(), [homeSecurity.user, 'S-1-5-18', 'S-1-5-32-544'].sort()) - const sentinel = join(home, 'preserved.txt') - await writeFile(sentinel, 'existing product data') - await checkDesktopDirectories({ lexora_home: home, user_data: join(home, '.runtime', 'electron') }, helper) - assert.ok(!inspect(join(home, '.runtime', 'electron')).allows.includes(additionalPrincipal)) - assert.equal(inspect(parent).sddl, parentSecurity.sddl) - checks.push('private product root is created before nested Electron directories without inheriting extra grants or modifying the parent') - - const runtime = { - user_data: join(parent, 'electron'), - session_data: join(parent, 'chromium'), - window_state: join(parent, 'state'), - } - const before = new Map() - for (const directory of Object.values(runtime)) { - await mkdir(directory) - before.set(directory, inspect(directory).sddl) - assert.ok(inspect(directory).allows.includes(additionalPrincipal)) - await writeFile(join(directory, 'preserved.txt'), 'existing runtime data') - } - for (let launch = 0; launch < 2; launch++) { - prepareDesktopPrivateStorage(home, helper) - await checkDesktopDirectories({ lexora_home: home, ...runtime }, helper) - } - for (const directory of Object.values(runtime)) { - assert.equal(inspect(directory).sddl, before.get(directory)) - assert.equal(await readFile(join(directory, 'preserved.txt'), 'utf8'), 'existing runtime data') - assert.deepEqual(await readdir(directory), ['preserved.txt']) - } - assert.equal(inspect(home).sddl, homeSecurity.sddl) - assert.equal(await readFile(sentinel, 'utf8'), 'existing product data') - checks.push('Electron session and state directories accept inherited read grants across repeated checks while preserving ACLs and data') - - const unsafe = inspect(home, inheritedAcl) - const isPrivateFailure = (error: unknown) => { - assert.ok(error instanceof PrivateDirectoryError) - assert.equal(error.code, 'PRIVATE_DIRECTORIES_UNSAFE') - assert.equal(error.failure.directoryRole, 'lexora_home') - assert.equal(error.failure.acl?.accessMask, 0x1200A9) - const diagnostic = JSON.stringify(readDiagnosticError(error)) - assert.ok(!diagnostic.includes(additionalPrincipal)) - assert.ok(!diagnostic.includes(root)) - return true - } - try { - assert.throws(() => prepareDesktopPrivateStorage(home, helper), isPrivateFailure) - await assert.rejects(checkDesktopDirectories({ lexora_home: home, user_data: join(root, 'not-created') }, helper), isPrivateFailure) - assert.ok(!(await readdir(root)).includes('not-created')) - assert.equal(inspect(home).sddl, unsafe.sddl) - assert.equal(await readFile(sentinel, 'utf8'), 'existing product data') - } - finally { - inspect(home, homeSecurity.sddl) - } - checks.push('the same grant on product storage still blocks loading without ACL repair, file changes or diagnostic identity disclosure') - - const denied = inspect(runtime.user_data, 'D:P(D;;0x2;;;CURRENT)(A;OICI;FA;;;CURRENT)(A;OICI;FA;;;SY)(A;OICI;FA;;;BA)') - try { - await assert.rejects(checkDesktopDirectories({ user_data: runtime.user_data }), (error: unknown) => { - const diagnostic = readDiagnosticError(error) - assert.equal(diagnostic.errorCode, 'DESKTOP_BOOTSTRAP_FAILED') - assert.equal(diagnostic.failure?.kind, 'desktop_bootstrap') - if (diagnostic.failure?.kind !== 'desktop_bootstrap') - return false - assert.equal(diagnostic.failure.operation, 'probe_directory') - assert.equal(diagnostic.failure.directoryRole, 'user_data') - assert.ok(['EACCES', 'EPERM'].includes(diagnostic.failure.systemCode ?? '')) - return true - }) - assert.equal(inspect(runtime.user_data).sddl, denied.sddl) - assert.equal(await readFile(join(runtime.user_data, 'preserved.txt'), 'utf8'), 'existing runtime data') - } - finally { - inspect(runtime.user_data, before.get(runtime.user_data)) - } - checks.push('actual file-creation denial still fails the runtime directory probe without overwriting data') - - const unavailable = join(root, 'missing-helper-home') - assert.throws(() => prepareDesktopPrivateStorage(unavailable), { code: 'PRIVATE_DIRECTORIES_UNAVAILABLE' }) - assert.ok(!(await readdir(root)).includes('missing-helper-home')) - checks.push('missing private-directory helper fails before creating product storage') -} -finally { - await rm(root, { recursive: true }) -} - -const result = { passed: true, helperSha256: createHash('sha256').update(await readFile(helper)).digest('hex'), checks, fixturesRemoved: true } -await writeFile(resultPath, JSON.stringify(result, null, 2)) -process.stdout.write(`${JSON.stringify(result, null, 2)}\n`) diff --git a/apps/buddy/native/host/__tests__/private_security_windows.rs b/apps/buddy/native/host/__tests__/private_security_windows.rs index 75cd4961..63aae431 100644 --- a/apps/buddy/native/host/__tests__/private_security_windows.rs +++ b/apps/buddy/native/host/__tests__/private_security_windows.rs @@ -53,9 +53,9 @@ fn empty_acl_and_deny_entries_do_not_grant_untrusted_access() { } #[test] -fn read_only_grants_do_not_allow_changes() { +fn existing_read_only_grants_are_accepted_for_any_principal() { let security = PrivateSecurity::new().unwrap(); - for sid in ["WD", "BU", "AC", "S-1-5-21-1-2-3-1001"] { + for sid in ["WD", "BU", "AU", "AC", "S-1-5-21-1-2-3-1001"] { for mask in [ 0, FILE_READ_ATTRIBUTES, @@ -83,10 +83,10 @@ fn read_only_grants_do_not_hide_write_changes_or_unknown_rights() { let security = PrivateSecurity::new().unwrap(); for bit in 0..32 { let access = 1_u32 << bit; - if access & READ_ONLY_ACCESS != 0 { + if access & 0x1200a9 != 0 { continue; } - for flags in ["", "OICIIO"] { + for (flags, ace_flags) in [("", 0), ("OICI", 3), ("OICIIO", 11), ("OICIID", 19)] { let mask = access | READ_ONLY_ACCESS; let sddl = format!("O:SYD:P(A;OICI;FA;;;SY)(A;{flags};{mask:#x};;;WD)"); assert_eq!( @@ -95,7 +95,7 @@ fn read_only_grants_do_not_hide_write_changes_or_unknown_rights() { reason: DirectoryAclReason::UntrustedAccess, ace_index: Some(1), ace_type: Some(0), - ace_flags: Some(if flags.is_empty() { 0 } else { 11 }), + ace_flags: Some(ace_flags), access_mask: Some(mask), principal: Some(DirectoryPrincipal::Everyone), })), diff --git a/apps/buddy/native/host/src/private_directories/windows/security.rs b/apps/buddy/native/host/src/private_directories/windows/security.rs index 949c6ce3..5ded5d98 100644 --- a/apps/buddy/native/host/src/private_directories/windows/security.rs +++ b/apps/buddy/native/host/src/private_directories/windows/security.rs @@ -27,6 +27,7 @@ use super::{DirectoryError, DirectoryFailure, DirectoryOperation, SystemErrorDom use crate::windows_security::{Sid, process_user_sid}; const METADATA_READ_ACCESS: u32 = FILE_READ_ATTRIBUTES | READ_CONTROL | SYNCHRONIZE; +// Existing read grants are preserved, not a guarantee of exclusive access to stored data. const READ_ONLY_ACCESS: u32 = METADATA_READ_ACCESS | FILE_READ_DATA | FILE_READ_EA | FILE_EXECUTE; struct LocalMemory(*mut c_void); diff --git a/apps/buddy/platform/filesystem/__tests__/windowsPrivateDirectories.native.spec.ts b/apps/buddy/platform/filesystem/__tests__/windowsPrivateDirectories.native.spec.ts new file mode 100644 index 00000000..1a9db1d2 --- /dev/null +++ b/apps/buddy/platform/filesystem/__tests__/windowsPrivateDirectories.native.spec.ts @@ -0,0 +1,191 @@ +import assert from 'node:assert/strict' +import { access, mkdir, mkdtemp, readFile, rm, symlink, unlink, writeFile } from 'node:fs/promises' +import { homedir, tmpdir } from 'node:os' +import { join, parse } from 'node:path' +import process from 'node:process' +import { fileURLToPath } from 'node:url' +import { afterEach, beforeAll, beforeEach, describe, it } from 'vitest' +import { resolveBuddyPrivateDirectories } from '../../native/nativeHost' +import { PrivateDirectoryError } from '../../windows/privateDirectories' +import { ensurePrivateDirectories } from '../privateDirectories' +import { inspectWindowsPrivateDirectory as inspect, setWindowsPrivateDirectoryAcl } from './windowsPrivateDirectoryFixture' + +async function missing(path: string) { + await assert.rejects(access(path), { code: 'ENOENT' }) +} + +describe.skipIf(process.platform !== 'win32')('windows private directories', () => { + let helper: string + let directory: string + const junctions: string[] = [] + + beforeAll(() => { + const executable = resolveBuddyPrivateDirectories({ appPath: fileURLToPath(new URL('../../../', import.meta.url)), isPackaged: false, resourcesPath: '' }) + assert.ok(executable, 'Build the Windows native helpers before running platform tests') + helper = executable + }) + beforeEach(async () => { + directory = await mkdtemp(join(tmpdir(), 'buddy-private-contract-')) + }) + afterEach(async () => { + for (const junction of junctions.splice(0)) + await unlink(junction) + await rm(directory, { recursive: true }) + }) + + it('creates protected Unicode directories and preserves inherited file ACLs on repeated checks', async () => { + const privatePath = join(directory, '示例', 'private') + await ensurePrivateDirectories([privatePath], helper) + await access(privatePath) + const security = inspect(privatePath) + assert.equal(security.owner, security.user) + assert.equal(security.protected, true) + assert.deepEqual(security.allows.sort(), [security.user, 'S-1-5-18', 'S-1-5-32-544'].sort()) + const file = join(privatePath, 'preserved.txt') + await writeFile(file, 'fixture') + const fileSecurity = inspect(file) + assert.deepEqual(fileSecurity.allows.sort(), security.allows) + assert.ok(fileSecurity.inherited.every(Boolean)) + await ensurePrivateDirectories([privatePath, privatePath], helper) + await ensurePrivateDirectories([privatePath.toUpperCase()], helper) + assert.equal(inspect(privatePath).sddl, security.sddl) + assert.equal(await readFile(file, 'utf8'), 'fixture') + }, 60_000) + + it('preserves existing inherited CREATOR OWNER templates and data', async () => { + const inheritedParent = join(directory, 'creator-owner-parent') + await mkdir(inheritedParent) + const parentSecurity = inspect(inheritedParent, 'O:CURRENTD:P(A;OICI;FA;;;CURRENT)(A;OICI;FA;;;SY)(A;OICI;FA;;;BA)(A;OICIIO;FA;;;CO)') + const inheritedDirectory = join(inheritedParent, 'existing', 'session-data') + await mkdir(inheritedDirectory, { recursive: true }) + const inheritedSecurity = inspect(inheritedDirectory) + assert.ok(inheritedSecurity.allows.includes('S-1-3-0')) + const preserved = join(inheritedDirectory, 'preserved.txt') + await writeFile(preserved, 'existing-user-data') + await ensurePrivateDirectories([inheritedDirectory], helper) + await ensurePrivateDirectories([inheritedDirectory], helper) + assert.equal(inspect(inheritedParent).sddl, parentSecurity.sddl) + assert.equal(inspect(inheritedDirectory).sddl, inheritedSecurity.sddl) + assert.equal(await readFile(preserved, 'utf8'), 'existing-user-data') + assert.ok(!inspect(preserved).allows.includes('S-1-3-0')) + }, 60_000) + + it('accepts existing metadata, read and execute grants without changing directory or file ACLs', async () => { + for (const [name, grant] of [ + ['users-attributes', '(A;;0x80;;;BU)'], + ['everyone-metadata', '(A;OICI;0x120080;;;WD)'], + ['app-packages-metadata', '(A;OICIIO;0x120080;;;AC)'], + ['everyone-traverse', '(A;;0x20;;;WD)'], + ['directory-traverse', '(A;CI;0x1200a0;;;BU)'], + ['file-execute-inheritance', '(A;OICI;0x20;;;WD)'], + ['attributes-and-content', '(A;;0x81;;;BU)'], + ['everyone-read', '(A;OICI;FR;;;WD)'], + ['everyone-read-execute', '(A;;0x1200a9;;;WD)'], + ['other-read-execute', '(A;OICI;0x1200a9;;;S-1-5-21-1-2-3-1001)'], + ['capability-read-execute', '(A;OICI;0x1200a9;;;S-1-15-3-1024-1-2-3-4-5-6-7-8)'], + ]) { + const path = join(directory, name!) + await ensurePrivateDirectories([path], helper) + const before = inspect(path, `O:CURRENTD:P(A;OICI;FA;;;CURRENT)(A;OICI;FA;;;SY)(A;OICI;FA;;;BA)${grant}`) + const sentinel = join(path, 'preserved.txt') + await writeFile(sentinel, 'existing-user-data') + const sentinelSecurity = inspect(sentinel) + await ensurePrivateDirectories([path], helper) + await ensurePrivateDirectories([path], helper) + assert.equal(inspect(path).sddl, before.sddl) + assert.equal(inspect(sentinel).sddl, sentinelSecurity.sddl) + assert.equal(await readFile(sentinel, 'utf8'), 'existing-user-data') + } + }, 60_000) + + it('rejects write, delete, owner and DACL changes, including inherit-only grants, without modifying data', async () => { + for (const [name, grant] of [ + ['unknown-capability', '(A;OICI;FA;;;S-1-15-3-1024-1-2-3-4-5-6-7-8)'], + ['read-and-write', '(A;OICI;0x1200ab;;;WD)'], + ['read-and-append', '(A;OICI;0x1200ad;;;WD)'], + ['read-and-delete', '(A;OICI;0x1300a9;;;WD)'], + ['read-and-delete-child', '(A;OICI;0x1200e9;;;WD)'], + ['read-and-write-dacl', '(A;OICI;0x1600a9;;;WD)'], + ['read-and-write-owner', '(A;OICI;0x1a00a9;;;WD)'], + ['inherit-only-write', '(A;OICIIO;0x1200ab;;;WD)'], + ]) { + const path = join(directory, name!) + await ensurePrivateDirectories([path], helper) + const sentinel = join(path, 'preserved.txt') + await writeFile(sentinel, 'existing-user-data') + const before = inspect(path, `O:CURRENTD:P(A;OICI;FA;;;CURRENT)(A;OICI;FA;;;SY)(A;OICI;FA;;;BA)${grant}`) + await assert.rejects(ensurePrivateDirectories([path], helper), { code: 'PRIVATE_DIRECTORIES_UNSAFE' }) + assert.equal(inspect(path).sddl, before.sddl) + assert.equal(await readFile(sentinel, 'utf8'), 'existing-user-data') + } + }, 60_000) + + it('validates private children without listing or reading the ACL of existing parents', async () => { + const restrictedParent = join(directory, 'restricted-parent') + const accessibleChild = join(restrictedParent, 'private') + await ensurePrivateDirectories([accessibleChild], helper) + const parentBefore = inspect(restrictedParent, 'O:CURRENTD:P(A;OICI;FA;;;CURRENT)(A;OICI;FA;;;SY)(A;OICI;FA;;;BA)') + try { + setWindowsPrivateDirectoryAcl(restrictedParent, 'O:CURRENTD:P(D;;0x20000;;;OW)(D;;0x1;;;CURRENT)(A;;FA;;;CURRENT)(A;;FA;;;SY)(A;;FA;;;BA)') + await assert.rejects(ensurePrivateDirectories([restrictedParent], helper), (error: unknown) => error instanceof PrivateDirectoryError && error.failure.operation === 'open_directory' && error.failure.systemError?.code === 0xC0000022) + await ensurePrivateDirectories([accessibleChild], helper) + } + finally { + setWindowsPrivateDirectoryAcl(restrictedParent, parentBefore.sddl) + assert.equal(inspect(restrictedParent).sddl.replace('D:PAI', 'D:P'), parentBefore.sddl.replace('D:PAI', 'D:P')) + } + }, 60_000) + + it('reports structured write-grant failures and rejects NULL DACL without repair', async () => { + const insecure = join(directory, 'insecure') + await ensurePrivateDirectories([insecure], helper) + const broad = inspect(insecure, 'O:CURRENTD:P(A;OICI;FA;;;CURRENT)(A;OICI;FA;;;SY)(A;OICI;FA;;;BA)(A;OICI;0x1200ab;;;WD)') + await assert.rejects(ensurePrivateDirectories([insecure], helper), (error: unknown) => { + assert.ok(error instanceof PrivateDirectoryError) + assert.equal(error.code, 'PRIVATE_DIRECTORIES_UNSAFE') + assert.deepEqual(error.failure, { kind: 'private_directories', operation: 'validate_acl', directoryIndex: 0, exitCode: 1, acl: { reason: 'untrusted_access', aceIndex: broad.allows.indexOf('S-1-1-0'), aceType: 0, aceFlags: 3, accessMask: 0x1200AB, principal: 'everyone' } }) + return true + }) + assert.equal(inspect(insecure).sddl, broad.sddl) + const nullDacl = inspect(insecure, 'O:CURRENTD:NO_ACCESS_CONTROL') + await assert.rejects(ensurePrivateDirectories([insecure], helper)) + assert.equal(inspect(insecure).sddl, nullDacl.sddl) + }, 60_000) + + it('rejects leaf and ancestor junctions without touching their destination', async () => { + const target = join(directory, 'junction-target') + const junction = join(directory, 'junction') + await mkdir(target) + const targetSecurity = inspect(target) + await symlink(target, junction, 'junction') + junctions.push(junction) + await assert.rejects(ensurePrivateDirectories([junction], helper)) + await assert.rejects(ensurePrivateDirectories([join(junction, 'escaped')], helper)) + await missing(join(target, 'escaped')) + assert.equal(inspect(target).sddl, targetSecurity.sddl) + }, 60_000) + + it('rejects files, user home, volume roots and raw stream or device aliases', async () => { + const file = join(directory, 'preserved.txt') + await writeFile(file, 'fixture') + await assert.rejects(ensurePrivateDirectories([file], helper), (error: unknown) => { + assert.ok(error instanceof PrivateDirectoryError) + assert.equal(error.code, 'PRIVATE_DIRECTORIES_FAILED') + assert.equal(error.failure.operation, 'open_directory') + assert.equal(error.failure.systemError?.domain, 'ntstatus') + assert.equal(error.failure.directoryIndex, 0) + return true + }) + assert.equal(await readFile(file, 'utf8'), 'fixture') + for (const forbidden of [homedir(), homedir().toUpperCase(), parse(directory).root, `${directory}\\bad:stream\\..\\escaped`, `${directory}\\NUL\\..\\escaped`]) + await assert.rejects(ensurePrivateDirectories([forbidden], helper)) + await missing(join(directory, 'escaped')) + }, 60_000) + + it('fails closed without the helper and leaves empty batches untouched', async () => { + const absentHelperTarget = join(directory, 'absent-helper') + await assert.rejects(ensurePrivateDirectories([absentHelperTarget])) + await missing(absentHelperTarget) + await ensurePrivateDirectories([], helper) + }, 60_000) +}) diff --git a/apps/buddy/platform/filesystem/__tests__/windowsPrivateDirectoriesAcceptance.ts b/apps/buddy/platform/filesystem/__tests__/windowsPrivateDirectoriesAcceptance.ts deleted file mode 100644 index f0cdcaeb..00000000 --- a/apps/buddy/platform/filesystem/__tests__/windowsPrivateDirectoriesAcceptance.ts +++ /dev/null @@ -1,167 +0,0 @@ -import assert from 'node:assert/strict' -import { createHash } from 'node:crypto' -import { access, mkdir, mkdtemp, readdir, readFile, rm, symlink, unlink, writeFile } from 'node:fs/promises' -import { homedir, tmpdir } from 'node:os' -import { dirname, join, parse, resolve } from 'node:path' -import process from 'node:process' -import { PrivateDirectoryError } from '../../windows/privateDirectories' -import { readBoundedFile } from '../boundedFile' -import { ensurePrivateDirectories } from '../privateDirectories' -import { inspectWindowsPrivateDirectory as inspect, setWindowsPrivateDirectoryAcl } from './windowsPrivateDirectoryFixture' - -assert.equal(process.platform, 'win32') -const [helperArgument, resultPath] = process.argv.slice(2) -assert.ok(helperArgument && resultPath) -const helper = resolve(helperArgument) -const directory = await mkdtemp(join(tmpdir(), 'buddy-private-contract-')) -const checks: string[] = [] -const junctions: string[] = [] - -async function missing(path: string) { - await assert.rejects(access(path), { code: 'ENOENT' }) -} - -try { - const privatePath = join(directory, '示例', 'private') - await ensurePrivateDirectories([privatePath], helper) - await access(privatePath) - const security = inspect(privatePath) - assert.equal(security.owner, security.user) - assert.equal(security.protected, true) - assert.deepEqual(security.allows.sort(), [security.user, 'S-1-5-18', 'S-1-5-32-544'].sort()) - const file = join(privatePath, 'preserved.txt') - await writeFile(file, 'fixture') - const fileSecurity = inspect(file) - assert.deepEqual(fileSecurity.allows.sort(), security.allows) - assert.ok(fileSecurity.inherited.every(Boolean)) - await ensurePrivateDirectories([privatePath, privatePath], helper) - await ensurePrivateDirectories([privatePath.toUpperCase()], helper) - assert.equal(inspect(privatePath).sddl, security.sddl) - assert.equal(await readFile(file, 'utf8'), 'fixture') - checks.push('Unicode nested creation, protected private ACL, file inheritance and repeat startup') - - const inheritedParent = join(directory, 'creator-owner-parent') - await mkdir(inheritedParent) - const parentSecurity = inspect(inheritedParent, 'O:CURRENTD:P(A;OICI;FA;;;CURRENT)(A;OICI;FA;;;SY)(A;OICI;FA;;;BA)(A;OICIIO;FA;;;CO)') - const inheritedDirectory = join(inheritedParent, 'existing', 'session-data') - await mkdir(inheritedDirectory, { recursive: true }) - const inheritedSecurity = inspect(inheritedDirectory) - assert.ok(inheritedSecurity.allows.includes('S-1-3-0')) - const preserved = join(inheritedDirectory, 'preserved.txt') - await writeFile(preserved, 'existing-user-data') - await ensurePrivateDirectories([inheritedDirectory], helper) - await ensurePrivateDirectories([inheritedDirectory], helper) - assert.equal(inspect(inheritedParent).sddl, parentSecurity.sddl) - assert.equal(inspect(inheritedDirectory).sddl, inheritedSecurity.sddl) - assert.equal(await readFile(preserved, 'utf8'), 'existing-user-data') - assert.ok(!inspect(preserved).allows.includes('S-1-3-0')) - checks.push('existing inherited CREATOR OWNER templates are accepted without changing ACLs or data') - - for (const [name, grant] of [ - ['users-attributes', '(A;;0x80;;;BU)'], - ['everyone-metadata', '(A;OICI;0x120080;;;WD)'], - ['app-packages-metadata', '(A;OICIIO;0x120080;;;AC)'], - ['everyone-traverse', '(A;;0x20;;;WD)'], - ['directory-traverse', '(A;CI;0x1200a0;;;BU)'], - ]) { - const path = join(directory, name!) - await ensurePrivateDirectories([path], helper) - const before = inspect(path, `O:CURRENTD:P(A;OICI;FA;;;CURRENT)(A;OICI;FA;;;SY)(A;OICI;FA;;;BA)${grant}`) - const sentinel = join(path, 'preserved.txt') - await writeFile(sentinel, 'existing-user-data') - await ensurePrivateDirectories([path], helper) - await ensurePrivateDirectories([path], helper) - assert.equal(inspect(path).sddl, before.sddl) - assert.equal(await readFile(sentinel, 'utf8'), 'existing-user-data') - } - checks.push('metadata-only and directory-only traverse grants are accepted without changing ACLs or existing files') - - for (const [name, grant] of [ - ['file-execute-inheritance', '(A;OICI;0x20;;;WD)'], - ['unknown-capability', '(A;OICI;FA;;;S-1-15-3-1024-1-2-3-4-5-6-7-8)'], - ['attributes-and-content', '(A;;0x81;;;BU)'], - ]) { - const path = join(directory, name!) - await ensurePrivateDirectories([path], helper) - const before = inspect(path, `O:CURRENTD:P(A;OICI;FA;;;CURRENT)(A;OICI;FA;;;SY)(A;OICI;FA;;;BA)${grant}`) - await assert.rejects(ensurePrivateDirectories([path], helper), { code: 'PRIVATE_DIRECTORIES_UNSAFE' }) - assert.equal(inspect(path).sddl, before.sddl) - } - checks.push('file-execute inheritance, unknown capability grants and 0x81 content access remain blocked') - - const restrictedParent = join(directory, 'restricted-parent') - const accessibleChild = join(restrictedParent, 'private') - await ensurePrivateDirectories([accessibleChild], helper) - const parentBefore = inspect(restrictedParent, 'O:CURRENTD:P(A;OICI;FA;;;CURRENT)(A;OICI;FA;;;SY)(A;OICI;FA;;;BA)') - try { - setWindowsPrivateDirectoryAcl(restrictedParent, 'O:CURRENTD:P(D;;0x20000;;;OW)(D;;0x1;;;CURRENT)(A;;FA;;;CURRENT)(A;;FA;;;SY)(A;;FA;;;BA)') - await assert.rejects(ensurePrivateDirectories([restrictedParent], helper), (error: unknown) => error instanceof PrivateDirectoryError && error.failure.operation === 'open_directory' && error.failure.systemError?.code === 0xC0000022) - await assert.rejects(readdir(restrictedParent)) - await ensurePrivateDirectories([accessibleChild], helper) - checks.push('private child validation does not require listing or READ_CONTROL on existing parent directories') - } - finally { - setWindowsPrivateDirectoryAcl(restrictedParent, parentBefore.sddl) - assert.equal(inspect(restrictedParent).sddl.replace('D:PAI', 'D:P'), parentBefore.sddl.replace('D:PAI', 'D:P')) - } - - const insecure = join(directory, 'insecure') - await ensurePrivateDirectories([insecure], helper) - const broad = inspect(insecure, 'O:CURRENTD:P(A;OICI;FA;;;CURRENT)(A;OICI;FA;;;SY)(A;OICI;FA;;;BA)(A;OICI;FR;;;WD)') - await assert.rejects(ensurePrivateDirectories([insecure], helper), (error: unknown) => { - assert.ok(error instanceof PrivateDirectoryError) - assert.equal(error.code, 'PRIVATE_DIRECTORIES_UNSAFE') - assert.deepEqual(error.failure, { kind: 'private_directories', operation: 'validate_acl', directoryIndex: 0, exitCode: 1, acl: { reason: 'untrusted_access', aceIndex: broad.allows.indexOf('S-1-1-0'), aceType: 0, aceFlags: 3, accessMask: 0x120089, principal: 'everyone' } }) - return true - }) - assert.equal(inspect(insecure).sddl, broad.sddl) - const nullDacl = inspect(insecure, 'O:CURRENTD:NO_ACCESS_CONTROL') - await assert.rejects(ensurePrivateDirectories([insecure], helper)) - assert.equal(inspect(insecure).sddl, nullDacl.sddl) - checks.push('existing broad or NULL DACL is rejected without ACL repair') - - const target = join(directory, 'junction-target') - const junction = join(directory, 'junction') - await mkdir(target) - const targetSecurity = inspect(target) - await symlink(target, junction, 'junction') - junctions.push(junction) - await assert.rejects(ensurePrivateDirectories([junction], helper)) - await assert.rejects(ensurePrivateDirectories([join(junction, 'escaped')], helper)) - await missing(join(target, 'escaped')) - assert.equal(inspect(target).sddl, targetSecurity.sddl) - checks.push('leaf and ancestor junctions fail without touching the destination') - - await assert.rejects(ensurePrivateDirectories([file], helper), (error: unknown) => { - assert.ok(error instanceof PrivateDirectoryError) - assert.equal(error.code, 'PRIVATE_DIRECTORIES_FAILED') - assert.equal(error.failure.operation, 'open_directory') - assert.equal(error.failure.systemError?.domain, 'ntstatus') - assert.equal(error.failure.directoryIndex, 0) - return true - }) - assert.equal(await readFile(file, 'utf8'), 'fixture') - for (const forbidden of [homedir(), homedir().toUpperCase(), parse(directory).root, `${directory}\\bad:stream\\..\\escaped`, `${directory}\\NUL\\..\\escaped`]) - await assert.rejects(ensurePrivateDirectories([forbidden], helper)) - await missing(join(directory, 'escaped')) - checks.push('files, user home, volume roots and raw stream/device aliases are rejected') - - const absentHelperTarget = join(directory, 'absent-helper') - await assert.rejects(ensurePrivateDirectories([absentHelperTarget])) - await missing(absentHelperTarget) - await ensurePrivateDirectories([], helper) - checks.push('missing helper fails closed and empty batches are a no-op') - - process.env.LEXORA_BUDDY_FILE_READER = join(dirname(helper), 'lexora-buddy-file-reader.exe') - assert.equal((await readBoundedFile(privatePath, file)).toString(), 'fixture') - await assert.rejects(readBoundedFile(privatePath, file, 1), { code: 'BOUNDED_FILE_OUTPUT_LIMIT' }) - checks.push('shared native path validation preserves bounded file reading') -} -finally { - for (const junction of junctions) - await unlink(junction) - await rm(directory, { recursive: true }) -} -const result = { passed: true, helperSha256: createHash('sha256').update(await readFile(helper)).digest('hex'), checks, fixturesRemoved: true } -await writeFile(resultPath, JSON.stringify(result, null, 2)) -process.stdout.write(`${JSON.stringify(result, null, 2)}\n`) diff --git a/apps/buddy/platform/windows/__tests__/host.native.spec.ts b/apps/buddy/platform/windows/__tests__/host.native.spec.ts index 8a2facba..6638a1ea 100644 --- a/apps/buddy/platform/windows/__tests__/host.native.spec.ts +++ b/apps/buddy/platform/windows/__tests__/host.native.spec.ts @@ -1,20 +1,12 @@ -import { execFileSync, spawn } from 'node:child_process' +import { spawn } from 'node:child_process' import { once } from 'node:events' -import { mkdtemp, rm } from 'node:fs/promises' -import { tmpdir } from 'node:os' -import { join } from 'node:path' import process from 'node:process' import { fileURLToPath } from 'node:url' -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { beforeEach, describe, expect, it, vi } from 'vitest' import { WindowsSystemHost } from '../../../service/src/system/adapters/windows/WindowsSystemHost' -import { createBuddyNativeEnvironment, resolveBuddyPrivateDirectories } from '../../native/nativeHost' -import { ensureWindowsPrivateDirectories } from '../privateDirectories' +import { createBuddyNativeEnvironment } from '../../native/nativeHost' -const roots: string[] = [] const nativePaths = { appPath: fileURLToPath(new URL('../../../', import.meta.url)), resourcesPath: '', isPackaged: false } -afterEach(async () => { - await Promise.all(roots.splice(0).map(root => rm(root, { recursive: true, force: true }))) -}) describe.skipIf(process.platform !== 'win32')('windows native host', () => { beforeEach(() => { @@ -44,13 +36,4 @@ describe.skipIf(process.platform !== 'win32')('windows native host', () => { await exited } }, 60_000) - - it('fails closed on an existing broadly accessible storage directory', async () => { - const root = await mkdtemp(join(tmpdir(), 'buddy-windows-private-')) - roots.push(root) - const helper = resolveBuddyPrivateDirectories(nativePaths) - await expect(ensureWindowsPrivateDirectories([join(root, 'private')], helper)).resolves.toBeUndefined() - execFileSync(join(process.env.SystemRoot!, 'System32', 'icacls.exe'), [join(root, 'private'), '/grant', '*S-1-1-0:(RX)']) - await expect(ensureWindowsPrivateDirectories([join(root, 'private')], helper)).rejects.toThrow('private storage') - }, 30_000) }) diff --git a/docs/bugs/bug-20260928-windows-private-directory-read-acl.md b/docs/bugs/bug-20260928-windows-private-directory-read-acl.md deleted file mode 100644 index 69a4ca38..00000000 --- a/docs/bugs/bug-20260928-windows-private-directory-read-acl.md +++ /dev/null @@ -1,46 +0,0 @@ -# Bug:Windows 私有目录拒绝 Agent 的只读权限 - -**日期:** 2026-09-28
-**优先级:** 中 -**状态:** 已修复 - -## 复现步骤 - -1. 在 Windows 安装并启动 Lexora Buddy。 -2. 使用 Codex Windows 沙盒,让其为用户配置目录下的 `.lexora` 添加沙盒用户读取权限。 -3. 启动 Lexora Buddy;移除该权限后,Codex 沙盒再次补回权限时,问题会重现。 - -## 实际结果 - -应用启动失败并提示 `PRIVATE_DIRECTORIES_UNSAFE`,失败步骤为 `validate_acl / lexora_home`。本机诊断中的 ACL 为:`principal=other`、`mask=0x1200a9`、`flags=0x3`。这条允许读取和遍历目录的权限被当成不安全权限拒绝。 - -## 预期结果 - -仅有读取、列目录、读取属性和遍历权限的额外主体不应阻止应用启动。额外主体仍不得通过 ACL 获得写入、删除或其他修改能力。 - -## 影响范围 - -Windows 桌面版启动时对 `lexora_home`(Lexora 用户数据根目录)的 ACL 检查。任何 Agent 或其他工具为该目录添加只读权限时,都可能触发原问题。 - -## 初步判断 - -`CodexSandboxUsers` 是 Codex Windows 沙盒使用的主体。Codex 为用户配置目录补充读取权限后,Lexora 原先只接受元数据读取权限,并拒绝可读取目录内容的权限,因此两种安全策略发生冲突。ACL 表示该主体具备读取能力,不代表它实际读取过目录内容。 - -| 名称 | 含义 | -|---|---| -| `lexora_home` | Lexora 保存用户数据的根目录 | -| `validate_acl` | 检查 Windows 目录访问控制列表的启动步骤 | -| `CodexSandboxUsers` | Codex Windows 沙盒使用的本地组 | -| `mask=0x1200a9` | 该权限允许读取目录内容、读取属性、遍历目录等,不含写入权限 | -| `flags=0x3` | 权限可继承给子文件和子目录 | - -## 处理方式 - -Windows ACL 校验现在允许额外主体拥有只读、列目录、读取属性和执行/遍历权限;仍拒绝含写入等修改权限的 ACL。规则按权限类型生效,不专门信任 Codex 组。 - -**安全影响:** 获得这些只读权限的主体可以读取 `.lexora` 中的文件。此修复没有迁移或另行保护目录中的数据。 - -**验收记录:** Windows 原生测试 43 项通过;Rust 格式检查通过;Windows 安装包已重新生成。 - -相关实现:`apps/buddy/native/host/src/private_directories/windows/security.rs`。 -相关测试:`apps/buddy/native/host/__tests__/private_security_windows.rs`。