From 949df79d846042ba0ea037cdc115baf9f47384cf Mon Sep 17 00:00:00 2001 From: "yelong.hu" Date: Thu, 27 Aug 2026 01:06:37 +0800 Subject: [PATCH 1/9] fix(windows): skip nested junctions for read ACL grants Generated-by: OpenAI Codex --- .../launcher/src/acl_ledger.rs | 31 +++-- .../launcher/src/acl_ledger_tests.rs | 106 ++++++++++++++++++ .../filesystem-worker-windows-smoke.test.ts | 42 ++++++- .../src/filesystem-worker/sandbox-paths.ts | 13 ++- 4 files changed, 176 insertions(+), 16 deletions(-) diff --git a/experiments/windows-sandbox/launcher/src/acl_ledger.rs b/experiments/windows-sandbox/launcher/src/acl_ledger.rs index 607ced68a7..2a4185919f 100644 --- a/experiments/windows-sandbox/launcher/src/acl_ledger.rs +++ b/experiments/windows-sandbox/launcher/src/acl_ledger.rs @@ -431,13 +431,19 @@ pub(crate) fn collect_roots(request: &LaunchRequest) -> Result, // Only a recursive grant extends into the tree, so only a recursive // grant requires the tree to be alias-free. Exact roots (e.g. the // cwd metadata anchor) may legitimately contain junctions deeper in - // the workspace that the sandbox never grants. + // the workspace that the sandbox never grants. A read-only recursive + // grant may also stop at nested reparse points: icacls `/L /T` updates + // the link object without following its target, leaving that target + // outside the AppContainer's authority. Recursive writes retain the + // stricter whole-tree rejection. let recursive_read = contains_path(&request.read_roots, path) && !contains_path(&request.exact_read_roots, path); let recursive_write = contains_path(&request.write_roots, path) && !contains_path(&request.exact_write_roots, path); - if metadata.is_dir() && (recursive_read || recursive_write) { - reject_aliased_entries(Path::new(path))?; + if metadata.is_dir() && recursive_write { + reject_aliased_entries(Path::new(path), false)?; + } else if metadata.is_dir() && recursive_read { + reject_aliased_entries(Path::new(path), true)?; } roots.push(LedgerRoot { path: path.clone(), @@ -455,14 +461,21 @@ fn contains_path(paths: &[String], path: &str) -> bool { paths.iter().any(|entry| entry.eq_ignore_ascii_case(path)) } -/// Rejects reparse points and multi-link files anywhere in a recursively -/// granted tree. An `(OI)(CI)` grant propagates inherited ACEs onto the -/// existing children at grant time, so a file inside the tree that also has a -/// hard link outside it would carry the grant past the declared root. -fn reject_aliased_entries(path: &Path) -> Result { +/// Rejects multi-link files anywhere in a recursively granted tree. Nested +/// reparse points may instead form a traversal boundary for read-only grants: +/// `icacls /L /T` grants the link object but does not follow it to the target. +/// An `(OI)(CI)` grant still propagates onto ordinary existing children, so a +/// file inside the tree that also has a hard link outside it must fail closed. +fn reject_aliased_entries( + path: &Path, + skip_nested_reparse_points: bool, +) -> Result { let metadata = fs::symlink_metadata(path) .map_err(|error| format!("inspect ACL root {} failed: {error}", path.display()))?; if metadata.file_attributes() & FILE_ATTRIBUTE_REPARSE_POINT != 0 { + if skip_nested_reparse_points { + return Ok(metadata); + } return Err(format!( "ACL root contains a reparse point: {}", path.display() @@ -473,7 +486,7 @@ fn reject_aliased_entries(path: &Path) -> Result { .map_err(|error| format!("scan ACL root {} failed: {error}", path.display()))? { let entry = entry.map_err(|error| format!("scan ACL root failed: {error}"))?; - reject_aliased_entries(&entry.path())?; + reject_aliased_entries(&entry.path(), skip_nested_reparse_points)?; } } else { reject_multi_link_file(path)?; diff --git a/experiments/windows-sandbox/launcher/src/acl_ledger_tests.rs b/experiments/windows-sandbox/launcher/src/acl_ledger_tests.rs index e03b99532a..aa6b982c16 100644 --- a/experiments/windows-sandbox/launcher/src/acl_ledger_tests.rs +++ b/experiments/windows-sandbox/launcher/src/acl_ledger_tests.rs @@ -107,6 +107,20 @@ mod tests { icacls(&[path.to_str().expect("path")]).contains(sid) } + fn create_junction(path: &Path, target: &Path) { + let output = Command::new("cmd.exe") + .args(["/d", "/c", "mklink", "/J"]) + .arg(path) + .arg(target) + .output() + .expect("run mklink"); + assert!( + output.status.success(), + "mklink failed: {}", + String::from_utf8_lossy(&output.stderr) + ); + } + fn user_sid() -> String { crate::windows_launcher::current_user_sid_string().expect("current user SID") } @@ -415,6 +429,98 @@ mod tests { assert!(!roots[0].read_recursive); } + #[test] + fn recursive_read_grant_stops_at_nested_junction() { + let fixture = Fixture::new("nested-junction-read"); + let junction_target = fixture + .target + .parent() + .expect("fixture base") + .join("junction-target"); + fs::create_dir_all(&junction_target).expect("create junction target"); + fs::write(junction_target.join("secret.txt"), "not granted").expect("seed target"); + let junction = fixture.target.join("nested-junction"); + create_junction(&junction, &junction_target); + + let mut request = launch_request( + vec![fixture.target_str()], + Vec::new(), + Vec::new(), + Vec::new(), + ); + request.request_id = format!("nested-junction-read-{}", std::process::id()); + + let mut observed_grants = None; + with_acl_grants(&request, APP_SID, || -> Result<(), LaunchFailure> { + observed_grants = Some([ + sid_listed(&fixture.target, APP_SID), + sid_listed(&fixture.target.join("child").join("file.txt"), APP_SID), + sid_listed(&junction_target, APP_SID), + sid_listed(&junction_target.join("secret.txt"), APP_SID), + ]); + Ok(()) + }) + .expect("nested junction admits for recursive reads"); + + let [ + root_granted, + ordinary_file_granted, + target_granted, + target_file_granted, + ] = observed_grants.expect("grant observations captured"); + assert!(root_granted); + assert!(ordinary_file_granted); + assert!( + !target_granted, + "junction target must not receive the recursive read grant" + ); + assert!( + !target_file_granted, + "junction descendants must remain ungranted" + ); + assert!(!sid_listed(&fixture.target, APP_SID)); + assert!(!sid_listed(&junction_target, APP_SID)); + } + + #[test] + fn recursive_write_grant_still_rejects_nested_junction() { + let fixture = Fixture::new("nested-junction-write"); + let junction_target = fixture + .target + .parent() + .expect("fixture base") + .join("junction-target"); + fs::create_dir_all(&junction_target).expect("create junction target"); + create_junction(&fixture.target.join("nested-junction"), &junction_target); + + let request = launch_request( + Vec::new(), + Vec::new(), + vec![fixture.target_str()], + Vec::new(), + ); + let error = collect_roots(&request).expect_err("recursive write must fail closed"); + + assert!(error.contains("reparse point"), "unexpected error: {error}"); + } + + #[test] + fn reparse_point_root_still_fails_closed() { + let fixture = Fixture::new("junction-root"); + let junction = fixture + .target + .parent() + .expect("fixture base") + .join("root-junction"); + create_junction(&junction, &fixture.target); + let root = junction.to_string_lossy().into_owned(); + let request = launch_request(vec![root], Vec::new(), Vec::new(), Vec::new()); + + let error = collect_roots(&request).expect_err("junction root must fail closed"); + + assert!(error.contains("reparse point"), "unexpected error: {error}"); + } + fn shared_ledger_dir() -> PathBuf { std::env::temp_dir().join("maka-sandbox-acl-ledgers") } diff --git a/packages/runtime/src/__tests__/filesystem-worker-windows-smoke.test.ts b/packages/runtime/src/__tests__/filesystem-worker-windows-smoke.test.ts index 1bb6dd2ff1..3775f4908c 100644 --- a/packages/runtime/src/__tests__/filesystem-worker-windows-smoke.test.ts +++ b/packages/runtime/src/__tests__/filesystem-worker-windows-smoke.test.ts @@ -19,7 +19,16 @@ import assert from 'node:assert/strict'; import { existsSync } from 'node:fs'; -import { copyFile, mkdir, mkdtemp, readFile, realpath, rm, writeFile } from 'node:fs/promises'; +import { + copyFile, + mkdir, + mkdtemp, + readFile, + realpath, + rm, + symlink, + writeFile, +} from 'node:fs/promises'; import { homedir, tmpdir } from 'node:os'; import { dirname, join, resolve } from 'node:path'; import { after, before, describe, test } from 'node:test'; @@ -188,6 +197,37 @@ describe('Windows filesystem worker smoke', { skip: !enabled }, () => { ); }); + test('glob skips a nested junction without granting its target', async () => { + const sdkDirectory = join(workspace, 'sdk', 'javascript'); + const storyDirectory = join(workspace, 'examples', 'terminal-story'); + const junction = join(storyDirectory, 'node_modules', '@sunrioa', 'rin-sdk'); + await mkdir(sdkDirectory, { recursive: true }); + await mkdir(dirname(junction), { recursive: true }); + await writeFile(join(sdkDirectory, 'package.json'), '{}\n'); + await writeFile(join(storyDirectory, 'main.go'), 'package main\n'); + await symlink(sdkDirectory, junction, 'junction'); + + const ordinary = await client.execute({ + operation: { kind: 'glob', path: storyDirectory, pattern: 'main.go' }, + cwd: workspace, + mode: 'ask', + expectedIdentity: 'unchecked', + }); + assert.deepEqual(ordinary, { kind: 'glob', files: ['main.go'] }); + + const junctionDescendants = await client.execute({ + operation: { + kind: 'glob', + path: storyDirectory, + pattern: 'node_modules/@sunrioa/rin-sdk/**/*', + }, + cwd: workspace, + mode: 'ask', + expectedIdentity: 'unchecked', + }); + assert.deepEqual(junctionDescendants, { kind: 'glob', files: [] }); + }); + test('fails closed for unapproved outside paths', async () => { await assert.rejects( client.execute({ diff --git a/packages/runtime/src/filesystem-worker/sandbox-paths.ts b/packages/runtime/src/filesystem-worker/sandbox-paths.ts index 715e80b137..030e93b844 100644 --- a/packages/runtime/src/filesystem-worker/sandbox-paths.ts +++ b/packages/runtime/src/filesystem-worker/sandbox-paths.ts @@ -35,12 +35,13 @@ import { * Inside the Windows AppContainer realpath is unavailable — both node's JS * implementation (lstat of every ancestor up to the volume root) and the * native one (GetFinalPathNameByHandle) are denied by the LowBox token. The - * Windows variant therefore resolves lexically and REJECTS reparse points - * outright instead of following them. That is sound because request paths are - * canonicalised by the client before launch, the broker refuses to grant any - * tree containing a reparse point, and the ACL grants themselves are the - * kernel-side enforcement: a link created after grant time points at an - * ungranted target the worker cannot touch anyway. + * Windows variant therefore resolves lexically and REJECTS requested reparse + * points instead of following them. That is sound because request paths are + * canonicalised by the client before launch, the broker rejects reparse-point + * roots and applies recursive grants with link traversal disabled, and the ACL + * grants themselves are the kernel-side enforcement: a nested link or a link + * created after grant time points at an ungranted target the worker cannot + * touch anyway. */ export interface SandboxPathApi { realpath(path: string): Promise; From 41275c234182f7094775bae75bf315af2060927f Mon Sep 17 00:00:00 2001 From: sunrioa <178722768+sunrioa@users.noreply.github.com> Date: Wed, 26 Aug 2026 16:44:01 +0800 Subject: [PATCH 2/9] fix(windows): let Glob skip nested junctions Generated-by: OpenAI Codex --- docs/architecture/windows-sandbox-rfc-v1.md | 18 +- .../windows-sandbox-rfc-v1.zh-CN.md | 15 +- experiments/windows-sandbox/README.md | 8 +- .../launcher/src/acl_ledger.rs | 206 ++++++++++++++++-- .../launcher/src/acl_ledger_tests.rs | 198 ++++++++++------- .../src/broker_authorization_tests.rs | 15 ++ .../windows-sandbox/launcher/src/protocol.rs | 24 +- .../launcher/src/protocol_tests.rs | 42 ++++ .../launcher/src/windows_launcher_tests.rs | 1 + .../filesystem-worker-client.test.ts | 89 +++++++- .../filesystem-worker-windows-smoke.test.ts | 50 ++--- .../__tests__/windows-sandbox-profile.test.ts | 44 ++++ .../src/__tests__/windows-sandbox.test.ts | 50 +++++ .../runtime/src/filesystem-worker/client.ts | 8 + .../src/filesystem-worker/operations.ts | 2 +- packages/runtime/src/sandbox/types.ts | 6 + .../runtime/src/sandbox/windows-profile.ts | 25 +++ .../runtime/src/sandbox/windows-sandbox.ts | 7 +- scripts/verify-windows-sandbox-e2e.mjs | 28 ++- 19 files changed, 681 insertions(+), 155 deletions(-) diff --git a/docs/architecture/windows-sandbox-rfc-v1.md b/docs/architecture/windows-sandbox-rfc-v1.md index afd0433c98..74244515ac 100644 --- a/docs/architecture/windows-sandbox-rfc-v1.md +++ b/docs/architecture/windows-sandbox-rfc-v1.md @@ -82,7 +82,9 @@ launch policy, then launches the target with layered Windows controls: the tree on close; - handle inheritance disabled; - AppContainer ACEs for only the compiled read/write roots, with a persisted recovery ledger; -- recursive reparse-point rejection before ACL mutation; +- recursive reparse-point rejection before ACL mutation by default; the W1 filesystem worker may + explicitly mark one read-only Glob root for non-following decomposition, where the root itself + stays strict and nested reparse entries receive no grant; - a closed, sorted environment from the normalized command; - bounded local named-pipe framing protected to SYSTEM and the current user. @@ -252,7 +254,9 @@ designed but explicitly deferred as later gates, tracked by Phase 4 in Enforced (merged in #2961 unless tagged with a follow-up PR): - default-deny filesystem with distinct read/write roots compiled from the exact profile (§6.1); -- recursive reparse-point rejection and multi-hard-link rejection before ACL mutation (§5, §6.1); +- recursive reparse-point rejection and multi-hard-link rejection before ACL mutation (§5, §6.1), + with one explicit read-only W1 Glob exception that decomposes the admitted root around nested + reparse entries without granting or traversing them; - a fresh request-derived AppContainer SID, per-launch ACL grants in a versioned recovery ledger, and stale-ledger reconciliation at startup (§6.1, §7.1); - an AppContainer token with no network capabilities (§6.2); @@ -386,8 +390,12 @@ sequenceDiagram The first implementation needs no elevated setup. Windows creates a request-derived Maka AppContainer profile, and the packaged native binary grants its unique SID only the roots admitted -for the current launch. Before mutation it recursively rejects `FILE_ATTRIBUTE_REPARSE_POINT`, persists a -versioned ledger with `create_new` and `sync_all`, and reconciles every stale ledger before accepting +for the current launch. Before mutation it recursively rejects `FILE_ATTRIBUTE_REPARSE_POINT` by +default. A manifest produced specifically for the read-only W1 Glob operation may mark its single +recursive root as non-following: the broker then records an exact grant for directories containing a +nested reparse entry, recursive grants for clean child directories, and no grant for the reparse +entry or its target. The marked root itself and every multi-hard-link file still fail closed. The +broker persists a versioned ledger with `create_new` and `sync_all`, and reconciles every stale ledger before accepting a new request. A global kernel mutex covers only ledger/ACL mutation; each launch holds a separate request-specific kernel lease through child settlement, so recovery skips live ledgers while disjoint launches execute concurrently. Normal settlement removes the SID ACE and then deletes the ledger. @@ -487,7 +495,7 @@ For the W1 preview, the packaged verifier maps the supported attack surface to e | Category | Packaged evidence | | --- | --- | -| Filesystem aliases | outside denial plus recursive junction and multi-hard-link admission refusal | +| Filesystem aliases | outside denial, raw recursive junction and multi-hard-link admission refusal, plus a product Glob that succeeds beside a nested junction without following it | | Network channels | TCP connect denial without network capabilities | | IPC | host named-pipe denial and an explicit inherited-handle list | | Descendants | child creation is denied fail-closed, or a created descendant retains the AppContainer token and kill-on-close Job | diff --git a/docs/architecture/windows-sandbox-rfc-v1.zh-CN.md b/docs/architecture/windows-sandbox-rfc-v1.zh-CN.md index e525a8cebc..c4e409e555 100644 --- a/docs/architecture/windows-sandbox-rfc-v1.zh-CN.md +++ b/docs/architecture/windows-sandbox-rfc-v1.zh-CN.md @@ -71,7 +71,8 @@ policy 的 SHA-256,再叠加以下 Windows 控制: - 通过 `PROC_THREAD_ATTRIBUTE_JOB_LIST` 在创建时原子附加、close 时杀整棵树的 Job Object; - 禁止 handle inheritance; - 只给编译后的 read/write root 添加 AppContainer ACE,并使用持久 recovery ledger; -- ACL 修改前递归拒绝 reparse point; +- ACL 修改前默认递归拒绝 reparse point;W1 filesystem worker 可以为一个只读 Glob root + 显式启用不跟随分解,但 root 自身仍严格拒绝 reparse,嵌套 reparse entry 不获得 grant; - 从规范化 command 构造封闭、排序后的环境; - 只允许 SYSTEM 和当前用户的本地命名管道,以及有长度上限的 frame。 @@ -169,7 +170,8 @@ Maka 外已失陷的同用户进程。sandboxed code 从第一条指令开始按 **已强制(未标注者由 #2961 合并强制):** - 默认拒绝文件系统,读/写 grant 分离(§6.1); -- ACL 修改前拒绝 reparse point 与多硬链接对象(§5/§6.1); +- ACL 修改前拒绝 reparse point 与多硬链接对象(§5/§6.1);唯一例外是 W1 Glob 可为一个 + 只读 root 显式启用分解,绕开嵌套 reparse entry,但不会授权或遍历它们; - 每次启动使用 request-derived 独立 AppContainer SID + 版本化 ledger + startup reconcile(§6.1/§7.1); - 不授予网络 capability 的 AppContainer token(§6.2); - 创建时原子附加、close 时杀整棵树的 kill-on-close Job(§6.3); @@ -237,8 +239,11 @@ sequenceDiagram ### 7.1 Setup 与持久状态 首个实现不需要 elevated setup。Windows 为每次 launch 创建 request-derived Maka AppContainer profile,打包 -native binary 只给当前 launch 允许的 root 授予其独立 SID。修改前递归拒绝 `FILE_ATTRIBUTE_REPARSE_POINT`,用 `create_new` 和 -`sync_all` 持久化版本化 ledger,并在接收新请求前 reconcile 全部遗留 ledger。正常结束先移除 SID ACE,再 +native binary 只给当前 launch 允许的 root 授予其独立 SID。修改前默认递归拒绝 +`FILE_ATTRIBUTE_REPARSE_POINT`。只有只读 W1 Glob 生成的 manifest 可以把它唯一的递归 root 标记为 +不跟随:Broker 对含嵌套 reparse entry 的目录使用 exact grant,对干净子目录保留 recursive grant, +并且不给 reparse entry 或其 target 授权。被标记的 root 自身以及任何多硬链接文件仍然 fail closed。随后用 +`create_new` 和 `sync_all` 持久化版本化 ledger,并在接收新请求前 reconcile 全部遗留 ledger。正常结束先移除 SID ACE,再 删除 ledger。全局 kernel mutex 只覆盖 ledger/ACL 修改;每个 launch 在 child settlement 完成前持有独立的 request-specific kernel lease,因此 recovery 会跳过仍在使用的 ledger,同时不同 launch 仍可并发执行。 @@ -330,7 +335,7 @@ Windows sandbox job 必须运行真实 child-process 正反测试: | 类别 | 打包证据 | | --- | --- | -| 文件别名 | outside 拒绝,加递归 junction 与多硬链接准入拒绝 | +| 文件别名 | outside 拒绝、raw 递归 junction 与多硬链接准入拒绝,以及产品 Glob 在嵌套 junction 旁成功且不跟随它 | | 网络通道 | 无网络 capability 时拒绝 TCP connect | | IPC | 拒绝宿主 named pipe,并只继承显式 handle 列表 | | descendant | child 创建被 fail-closed 拒绝,或已创建 descendant 仍持有 AppContainer token 与 kill-on-close Job | diff --git a/experiments/windows-sandbox/README.md b/experiments/windows-sandbox/README.md index c7c02736cb..b2b654c46f 100644 --- a/experiments/windows-sandbox/README.md +++ b/experiments/windows-sandbox/README.md @@ -80,8 +80,12 @@ non-zero, fail-closed outcome. `launcher --appcontainer ` is the isolated-identity candidate. It creates a fresh request-derived AppContainer identity, combines its token with the same atomic Job attribute, and supplies no network capabilities. Before -launch, the broker persists an ACL recovery ledger, rejects reparse points, and -grants that per-launch SID only the requested roots. A short-lived global mutex +launch, the broker persists an ACL recovery ledger, rejects reparse points by +default, and grants that per-launch SID only the requested roots. The W1 +filesystem worker can explicitly mark one read-only Glob root for non-following +decomposition: nested reparse entries are omitted while clean child directories +receive narrower recursive grants; the root itself and hard links remain +fail-closed. A short-lived global mutex serializes ACL mutation, while a request-specific kernel lease distinguishes live ledgers from abandoned ones without serializing child execution. The smoke proves allowed read/write access, denial of a user-readable sibling file and diff --git a/experiments/windows-sandbox/launcher/src/acl_ledger.rs b/experiments/windows-sandbox/launcher/src/acl_ledger.rs index 2a4185919f..56b70ee2ed 100644 --- a/experiments/windows-sandbox/launcher/src/acl_ledger.rs +++ b/experiments/windows-sandbox/launcher/src/acl_ledger.rs @@ -53,6 +53,7 @@ use crate::windows_launcher::{appcontainer_profile_name, current_user_sid_string pub(crate) const LEDGER_VERSION: u8 = 2; const ACL_MUTEX_TIMEOUT_MS: u32 = 30_000; +const MAX_NON_FOLLOWING_READ_GRANTS: usize = 4_096; /// The ledger directory, the icacls grants and the AppContainer profiles are /// all shared across every session of the user, so the locks that arbitrate @@ -398,11 +399,28 @@ impl Drop for LedgerLock { pub(crate) fn collect_roots(request: &LaunchRequest) -> Result, String> { let mut roots = Vec::new(); + if let Some(non_following_root) = request.non_following_read_root.as_deref() { + if !contains_path(&request.read_roots, non_following_root) + || contains_path(&request.exact_read_roots, non_following_root) + { + return Err( + "nonFollowingReadRoot must name a declared recursive readRoot".to_owned(), + ); + } + if !request.write_roots.is_empty() || !request.exact_write_roots.is_empty() { + return Err("nonFollowingReadRoot requires a read-only launch".to_owned()); + } + } for path in request.read_roots.iter().chain(&request.write_roots) { - if roots - .iter() - .any(|entry: &LedgerRoot| entry.path.eq_ignore_ascii_case(path)) + if request + .non_following_read_root + .as_deref() + .is_some_and(|root| root.eq_ignore_ascii_case(path)) { + let partitioned = partition_non_following_read_root(Path::new(path))?; + for root in partitioned { + upsert_ledger_root(&mut roots, root); + } continue; } // A root that does not exist yet (e.g. the exact target of a write @@ -431,21 +449,15 @@ pub(crate) fn collect_roots(request: &LaunchRequest) -> Result, // Only a recursive grant extends into the tree, so only a recursive // grant requires the tree to be alias-free. Exact roots (e.g. the // cwd metadata anchor) may legitimately contain junctions deeper in - // the workspace that the sandbox never grants. A read-only recursive - // grant may also stop at nested reparse points: icacls `/L /T` updates - // the link object without following its target, leaving that target - // outside the AppContainer's authority. Recursive writes retain the - // stricter whole-tree rejection. + // the workspace that the sandbox never grants. let recursive_read = contains_path(&request.read_roots, path) && !contains_path(&request.exact_read_roots, path); let recursive_write = contains_path(&request.write_roots, path) && !contains_path(&request.exact_write_roots, path); - if metadata.is_dir() && recursive_write { - reject_aliased_entries(Path::new(path), false)?; - } else if metadata.is_dir() && recursive_read { - reject_aliased_entries(Path::new(path), true)?; + if metadata.is_dir() && (recursive_read || recursive_write) { + reject_aliased_entries(Path::new(path))?; } - roots.push(LedgerRoot { + upsert_ledger_root(&mut roots, LedgerRoot { path: path.clone(), read: contains_path(&request.read_roots, path), write: contains_path(&request.write_roots, path), @@ -461,21 +473,167 @@ fn contains_path(paths: &[String], path: &str) -> bool { paths.iter().any(|entry| entry.eq_ignore_ascii_case(path)) } -/// Rejects multi-link files anywhere in a recursively granted tree. Nested -/// reparse points may instead form a traversal boundary for read-only grants: -/// `icacls /L /T` grants the link object but does not follow it to the target. -/// An `(OI)(CI)` grant still propagates onto ordinary existing children, so a -/// file inside the tree that also has a hard link outside it must fail closed. -fn reject_aliased_entries( +fn upsert_ledger_root(roots: &mut Vec, root: LedgerRoot) { + if let Some(existing) = roots + .iter_mut() + .find(|entry| entry.path.eq_ignore_ascii_case(&root.path)) + { + existing.read |= root.read; + existing.write |= root.write; + existing.read_recursive |= root.read_recursive; + existing.write_recursive |= root.write_recursive; + return; + } + roots.push(root); +} + +struct DirectoryReadPlan { + clean: bool, + roots: Vec, +} + +/// Decomposes one read-only recursive root into physical grants that let a +/// non-following operation enumerate ordinary entries without granting or +/// traversing nested Windows reparse points. The root itself remains strict: +/// a reparse root is rejected instead of silently changing its meaning. +fn partition_non_following_read_root(path: &Path) -> Result, String> { + partition_non_following_read_root_with_limit(path, MAX_NON_FOLLOWING_READ_GRANTS) +} + +pub(crate) fn partition_non_following_read_root_with_limit( + path: &Path, + max_grants: usize, +) -> Result, String> { + match fs::symlink_metadata(path) { + Ok(metadata) => { + if metadata.file_attributes() & FILE_ATTRIBUTE_REPARSE_POINT != 0 { + return Err(format!( + "ACL root contains a reparse point: {}", + path.display() + )); + } + if !metadata.is_dir() { + return Err(format!( + "nonFollowingReadRoot must be a directory: {}", + path.display() + )); + } + } + Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(Vec::new()), + Err(error) => { + return Err(format!( + "inspect ACL root {} failed: {error}", + path.display() + )); + } + } + Ok(plan_non_following_directory(path, max_grants)?.roots) +} + +fn plan_non_following_directory( path: &Path, - skip_nested_reparse_points: bool, -) -> Result { + max_grants: usize, +) -> Result { let metadata = fs::symlink_metadata(path) .map_err(|error| format!("inspect ACL root {} failed: {error}", path.display()))?; if metadata.file_attributes() & FILE_ATTRIBUTE_REPARSE_POINT != 0 { - if skip_nested_reparse_points { - return Ok(metadata); + return Ok(DirectoryReadPlan { + clean: false, + roots: Vec::new(), + }); + } + if !metadata.is_dir() { + return Err(format!( + "expected a directory while partitioning ACL root: {}", + path.display() + )); + } + + let mut entries = fs::read_dir(path) + .map_err(|error| format!("scan ACL root {} failed: {error}", path.display()))? + .collect::, _>>() + .map_err(|error| format!("scan ACL root failed: {error}"))?; + entries.sort_by_key(|entry| entry.file_name()); + + let mut clean = true; + let mut directory_plans = Vec::new(); + for entry in entries { + let child = entry.path(); + let child_metadata = fs::symlink_metadata(&child) + .map_err(|error| format!("inspect ACL root {} failed: {error}", child.display()))?; + if child_metadata.file_attributes() & FILE_ATTRIBUTE_REPARSE_POINT != 0 { + clean = false; + continue; + } + if child_metadata.is_dir() { + let child_plan = plan_non_following_directory(&child, max_grants)?; + clean &= child_plan.clean; + directory_plans.push(child_plan); + continue; } + if child_metadata.is_file() { + reject_multi_link_file(&child)?; + continue; + } + return Err(format!( + "ACL root contains an unsupported filesystem entry: {}", + child.display() + )); + } + + if clean { + return Ok(DirectoryReadPlan { + clean: true, + roots: vec![read_root(path, true)?], + }); + } + + let mut roots = vec![read_root(path, false)?]; + for child_plan in directory_plans { + append_partitioned_roots(&mut roots, child_plan.roots, max_grants)?; + } + Ok(DirectoryReadPlan { + clean: false, + roots, + }) +} + +fn append_partitioned_roots( + roots: &mut Vec, + additions: Vec, + max_grants: usize, +) -> Result<(), String> { + if roots.len().saturating_add(additions.len()) > max_grants { + return Err(format!( + "nonFollowingReadRoot exceeds the safe limit of {max_grants} physical ACL grants" + )); + } + roots.extend(additions); + Ok(()) +} + +fn read_root(path: &Path, recursive: bool) -> Result { + let path = path + .to_str() + .ok_or_else(|| format!("ACL root path is not valid Unicode: {}", path.display()))?; + Ok(LedgerRoot { + path: path.to_owned(), + read: true, + write: false, + read_recursive: recursive, + write_recursive: false, + backup_path: None, + }) +} + +/// Rejects reparse points and multi-link files anywhere in a recursively +/// granted tree. An `(OI)(CI)` grant propagates inherited ACEs onto the +/// existing children at grant time, so a file inside the tree that also has a +/// hard link outside it would carry the grant past the declared root. +fn reject_aliased_entries(path: &Path) -> Result { + let metadata = fs::symlink_metadata(path) + .map_err(|error| format!("inspect ACL root {} failed: {error}", path.display()))?; + if metadata.file_attributes() & FILE_ATTRIBUTE_REPARSE_POINT != 0 { return Err(format!( "ACL root contains a reparse point: {}", path.display() @@ -486,7 +644,7 @@ fn reject_aliased_entries( .map_err(|error| format!("scan ACL root {} failed: {error}", path.display()))? { let entry = entry.map_err(|error| format!("scan ACL root failed: {error}"))?; - reject_aliased_entries(&entry.path(), skip_nested_reparse_points)?; + reject_aliased_entries(&entry.path())?; } } else { reject_multi_link_file(path)?; diff --git a/experiments/windows-sandbox/launcher/src/acl_ledger_tests.rs b/experiments/windows-sandbox/launcher/src/acl_ledger_tests.rs index aa6b982c16..657c270b4f 100644 --- a/experiments/windows-sandbox/launcher/src/acl_ledger_tests.rs +++ b/experiments/windows-sandbox/launcher/src/acl_ledger_tests.rs @@ -28,7 +28,7 @@ mod tests { use crate::acl_ledger::{ LEDGER_VERSION, LaunchFailure, Ledger, LedgerRoot, collect_roots, recover_stale, - with_acl_grants, write_ledger, + partition_non_following_read_root_with_limit, with_acl_grants, write_ledger, }; use crate::protocol::{LaunchRequest, NetworkMode}; @@ -107,20 +107,6 @@ mod tests { icacls(&[path.to_str().expect("path")]).contains(sid) } - fn create_junction(path: &Path, target: &Path) { - let output = Command::new("cmd.exe") - .args(["/d", "/c", "mklink", "/J"]) - .arg(path) - .arg(target) - .output() - .expect("run mklink"); - assert!( - output.status.success(), - "mklink failed: {}", - String::from_utf8_lossy(&output.stderr) - ); - } - fn user_sid() -> String { crate::windows_launcher::current_user_sid_string().expect("current user SID") } @@ -352,6 +338,7 @@ mod tests { network: NetworkMode::Restricted, environment: BTreeMap::new(), timeout_ms: None, + non_following_read_root: None, } } @@ -430,95 +417,152 @@ mod tests { } #[test] - fn recursive_read_grant_stops_at_nested_junction() { - let fixture = Fixture::new("nested-junction-read"); - let junction_target = fixture - .target - .parent() - .expect("fixture base") - .join("junction-target"); - fs::create_dir_all(&junction_target).expect("create junction target"); - fs::write(junction_target.join("secret.txt"), "not granted").expect("seed target"); - let junction = fixture.target.join("nested-junction"); - create_junction(&junction, &junction_target); - + fn clean_non_following_tree_compresses_to_one_recursive_root() { + let fixture = Fixture::new("non-following-clean"); + let root = fixture.target_str(); let mut request = launch_request( - vec![fixture.target_str()], + vec![root.clone()], Vec::new(), Vec::new(), Vec::new(), ); - request.request_id = format!("nested-junction-read-{}", std::process::id()); + request.non_following_read_root = Some(root.clone()); - let mut observed_grants = None; - with_acl_grants(&request, APP_SID, || -> Result<(), LaunchFailure> { - observed_grants = Some([ - sid_listed(&fixture.target, APP_SID), - sid_listed(&fixture.target.join("child").join("file.txt"), APP_SID), - sid_listed(&junction_target, APP_SID), - sid_listed(&junction_target.join("secret.txt"), APP_SID), - ]); - Ok(()) - }) - .expect("nested junction admits for recursive reads"); - - let [ - root_granted, - ordinary_file_granted, - target_granted, - target_file_granted, - ] = observed_grants.expect("grant observations captured"); - assert!(root_granted); - assert!(ordinary_file_granted); + let roots = collect_roots(&request).expect("clean tree admits"); + + assert_eq!(roots.len(), 1); + assert!(roots[0].path.eq_ignore_ascii_case(&root)); + assert!(roots[0].read_recursive); + } + + #[test] + fn non_following_read_root_prunes_nested_reparse_points() { + let fixture = Fixture::new("non-following-junction"); + let base = fixture.target.parent().expect("fixture base"); + let outside = base.join("outside"); + let junction_parent = fixture.target.join("node_modules").join("@scope"); + let junction = junction_parent.join("dependency"); + fs::create_dir_all(&outside).expect("create outside target"); + fs::write(outside.join("secret.ts"), "secret").expect("seed outside target"); + fs::create_dir_all(&junction_parent).expect("create junction parent"); + create_junction(&junction, &outside); + let target = fixture.target_str(); + let raw_request = launch_request( + vec![target.clone()], + Vec::new(), + Vec::new(), + Vec::new(), + ); + let raw_error = collect_roots(&raw_request).expect_err("raw recursive root must stay strict"); assert!( - !target_granted, - "junction target must not receive the recursive read grant" + raw_error.contains("reparse point"), + "unexpected raw error: {raw_error}" ); + let mut request = raw_request; + request.non_following_read_root = Some(target.clone()); + + let roots = collect_roots(&request).expect("partition non-following read root"); + + let project = roots + .iter() + .find(|root| root.path.eq_ignore_ascii_case(&target)) + .expect("project exact root"); + assert!(project.read); + assert!(!project.read_recursive); + let child = fixture.target.join("child").to_string_lossy().into_owned(); assert!( - !target_file_granted, - "junction descendants must remain ungranted" + roots + .iter() + .any(|root| root.path.eq_ignore_ascii_case(&child) && root.read_recursive) ); - assert!(!sid_listed(&fixture.target, APP_SID)); - assert!(!sid_listed(&junction_target, APP_SID)); + for exact_directory in [fixture.target.join("node_modules"), junction_parent] { + let exact_directory = exact_directory.to_string_lossy(); + assert!(roots.iter().any(|root| { + root.path.eq_ignore_ascii_case(&exact_directory) && !root.read_recursive + })); + } + let junction = junction.to_string_lossy(); + let outside = outside.to_string_lossy(); + assert!(!roots.iter().any(|root| { + root.path.eq_ignore_ascii_case(&junction) + || root.path.eq_ignore_ascii_case(&outside) + })); } #[test] - fn recursive_write_grant_still_rejects_nested_junction() { - let fixture = Fixture::new("nested-junction-write"); - let junction_target = fixture - .target - .parent() - .expect("fixture base") - .join("junction-target"); - fs::create_dir_all(&junction_target).expect("create junction target"); - create_junction(&fixture.target.join("nested-junction"), &junction_target); - - let request = launch_request( + fn non_following_root_itself_still_fails_closed() { + let fixture = Fixture::new("non-following-root-junction"); + let base = fixture.target.parent().expect("fixture base"); + let junction = base.join("root-junction"); + create_junction(&junction, &fixture.target); + let root = junction.to_string_lossy().into_owned(); + let mut request = launch_request( + vec![root.clone()], Vec::new(), Vec::new(), - vec![fixture.target_str()], Vec::new(), ); - let error = collect_roots(&request).expect_err("recursive write must fail closed"); + request.non_following_read_root = Some(root); + + let error = collect_roots(&request).expect_err("reparse root must fail closed"); assert!(error.contains("reparse point"), "unexpected error: {error}"); } #[test] - fn reparse_point_root_still_fails_closed() { - let fixture = Fixture::new("junction-root"); - let junction = fixture + fn non_following_read_root_still_rejects_multi_link_files() { + let fixture = Fixture::new("non-following-hardlink"); + let outside = fixture .target .parent() .expect("fixture base") - .join("root-junction"); - create_junction(&junction, &fixture.target); - let root = junction.to_string_lossy().into_owned(); - let request = launch_request(vec![root], Vec::new(), Vec::new(), Vec::new()); + .join("outside-hardlink.txt"); + fs::write(&outside, "outside payload").expect("seed outside file"); + fs::hard_link(&outside, fixture.target.join("child").join("linked.txt")) + .expect("create hard link into tree"); + let root = fixture.target_str(); + let mut request = launch_request( + vec![root.clone()], + Vec::new(), + Vec::new(), + Vec::new(), + ); + request.non_following_read_root = Some(root); - let error = collect_roots(&request).expect_err("junction root must fail closed"); + let error = collect_roots(&request).expect_err("multi-link file must fail closed"); - assert!(error.contains("reparse point"), "unexpected error: {error}"); + assert!(error.contains("multi-link"), "unexpected error: {error}"); + } + + #[test] + fn non_following_read_root_bounds_physical_grant_expansion() { + let fixture = Fixture::new("non-following-limit"); + let base = fixture.target.parent().expect("fixture base"); + let outside = base.join("limit-outside"); + fs::create_dir_all(&outside).expect("create outside target"); + for name in ["safe-a", "safe-b"] { + fs::create_dir_all(fixture.target.join(name)).expect("create safe directory"); + } + create_junction(&fixture.target.join("junction"), &outside); + + let error = partition_non_following_read_root_with_limit(&fixture.target, 2) + .expect_err("expanded grant plan must be bounded"); + + assert!(error.contains("safe limit of 2"), "unexpected error: {error}"); + } + + fn create_junction(path: &Path, target: &Path) { + let output = Command::new("cmd.exe") + .args(["/d", "/c", "mklink", "/J"]) + .arg(path) + .arg(target) + .output() + .expect("run mklink"); + assert!( + output.status.success(), + "mklink /J failed: {}", + String::from_utf8_lossy(&output.stderr) + ); } fn shared_ledger_dir() -> PathBuf { diff --git a/experiments/windows-sandbox/launcher/src/broker_authorization_tests.rs b/experiments/windows-sandbox/launcher/src/broker_authorization_tests.rs index b7b40aca24..721aaaf9a1 100644 --- a/experiments/windows-sandbox/launcher/src/broker_authorization_tests.rs +++ b/experiments/windows-sandbox/launcher/src/broker_authorization_tests.rs @@ -105,4 +105,19 @@ mod tests { Err(BrokerAuthorizationError::ProfileDigestMismatch) ); } + + #[test] + fn rejects_a_non_following_root_added_after_digest_approval() { + let mut value = request("abcdef0123456789abcdef0123456789"); + value.launch.read_roots = vec!["C:\\work".to_owned()]; + value.profile_digest = launch_digest(&value.launch).expect("launch digest"); + let approved = value.profile_digest.clone(); + value.launch.non_following_read_root = Some("C:\\work".to_owned()); + let mut authorizer = BrokerAuthorizer::new([approved]); + + assert_eq!( + authorizer.authorize(&value, 42), + Err(BrokerAuthorizationError::ProfileDigestMismatch) + ); + } } diff --git a/experiments/windows-sandbox/launcher/src/protocol.rs b/experiments/windows-sandbox/launcher/src/protocol.rs index 8372c739e4..5a36163d1d 100644 --- a/experiments/windows-sandbox/launcher/src/protocol.rs +++ b/experiments/windows-sandbox/launcher/src/protocol.rs @@ -40,10 +40,14 @@ pub struct LaunchRequest { pub exact_write_roots: Vec, pub network: NetworkMode, pub environment: BTreeMap, - /// Optional child-wait deadline. Appended last and skipped when absent so - /// manifests written before this field keep an identical launch digest. + /// Optional child-wait deadline. Kept in its historical position and + /// skipped when absent so older manifests retain an identical digest. #[serde(default, skip_serializing_if = "Option::is_none")] pub timeout_ms: Option, + /// One read-only recursive root whose operation does not follow nested + /// reparse points. The broker may decompose it into narrower ACL grants. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub non_following_read_root: Option, } pub const MIN_LAUNCH_TIMEOUT_MS: u64 = 1_000; @@ -131,6 +135,18 @@ impl LaunchRequest { validate_roots(&self.write_roots, "writeRoots")?; validate_roots(&self.exact_read_roots, "exactReadRoots")?; validate_roots(&self.exact_write_roots, "exactWriteRoots")?; + if let Some(root) = self.non_following_read_root.as_deref() { + validate_path(root, "nonFollowingReadRoot")?; + if !contains_path(&self.read_roots, root) { + return Err("nonFollowingReadRoot must name a declared readRoot".to_owned()); + } + if contains_path(&self.exact_read_roots, root) { + return Err("nonFollowingReadRoot must name a recursive readRoot".to_owned()); + } + if !self.write_roots.is_empty() || !self.exact_write_roots.is_empty() { + return Err("nonFollowingReadRoot requires a read-only launch".to_owned()); + } + } if let Some(timeout_ms) = self.timeout_ms { if !(MIN_LAUNCH_TIMEOUT_MS..=MAX_LAUNCH_TIMEOUT_MS).contains(&timeout_ms) { return Err(format!( @@ -204,6 +220,10 @@ fn validate_roots(roots: &[String], field: &str) -> Result<(), String> { Ok(()) } +fn contains_path(paths: &[String], path: &str) -> bool { + paths.iter().any(|entry| entry.eq_ignore_ascii_case(path)) +} + fn validate_path(value: &str, field: &str) -> Result<(), String> { let path = Path::new(value); if !path.is_absolute() { diff --git a/experiments/windows-sandbox/launcher/src/protocol_tests.rs b/experiments/windows-sandbox/launcher/src/protocol_tests.rs index b1964829ca..0fc8f62404 100644 --- a/experiments/windows-sandbox/launcher/src/protocol_tests.rs +++ b/experiments/windows-sandbox/launcher/src/protocol_tests.rs @@ -89,8 +89,10 @@ mod tests { // existed — otherwise old manifests hit profile_digest_mismatch. let value = request(); assert!(value.launch.timeout_ms.is_none()); + assert!(value.launch.non_following_read_root.is_none()); let serialized = serde_json::to_string(&value.launch).expect("serialize launch"); assert!(!serialized.contains("timeoutMs")); + assert!(!serialized.contains("nonFollowingReadRoot")); let reparsed = serde_json::from_str::(&serialized) .expect("reparse launch"); assert_eq!( @@ -99,6 +101,46 @@ mod tests { ); } + #[test] + fn validates_non_following_read_root_as_a_recursive_read_only_root() { + let root = "C:\\work\\repo".to_owned(); + let mut value = request(); + value.launch.read_roots = vec![root.clone()]; + value.launch.non_following_read_root = Some(root.clone()); + assert!(value.launch.validate().is_ok()); + + let mut missing = value.launch.clone(); + missing.non_following_read_root = Some("C:\\outside".to_owned()); + assert_eq!( + missing.validate().unwrap_err(), + "nonFollowingReadRoot must name a declared readRoot" + ); + + let mut exact = value.launch.clone(); + exact.exact_read_roots = vec![root.clone()]; + assert_eq!( + exact.validate().unwrap_err(), + "nonFollowingReadRoot must name a recursive readRoot" + ); + + let mut writable = value.launch.clone(); + writable.write_roots = vec![root]; + assert_eq!( + writable.validate().unwrap_err(), + "nonFollowingReadRoot requires a read-only launch" + ); + } + + #[test] + fn non_following_read_root_is_bound_into_the_launch_digest() { + let mut value = request(); + value.launch.read_roots = vec!["C:\\work\\repo".to_owned()]; + let original = launch_digest(&value.launch).expect("original digest"); + value.launch.non_following_read_root = Some("C:\\work\\repo".to_owned()); + + assert_ne!(launch_digest(&value.launch).expect("marked digest"), original); + } + #[test] fn accepts_real_windows_env_names_and_rejects_block_breaking_ones() { // `CommonProgramFiles(x86)` is a standard Windows variable; the diff --git a/experiments/windows-sandbox/launcher/src/windows_launcher_tests.rs b/experiments/windows-sandbox/launcher/src/windows_launcher_tests.rs index 0416c6322c..b29e72f9b1 100644 --- a/experiments/windows-sandbox/launcher/src/windows_launcher_tests.rs +++ b/experiments/windows-sandbox/launcher/src/windows_launcher_tests.rs @@ -42,6 +42,7 @@ mod tests { network: NetworkMode::Restricted, environment: BTreeMap::new(), timeout_ms: None, + non_following_read_root: None, } } diff --git a/packages/runtime/src/__tests__/filesystem-worker-client.test.ts b/packages/runtime/src/__tests__/filesystem-worker-client.test.ts index 40607d023e..dbc148c967 100644 --- a/packages/runtime/src/__tests__/filesystem-worker-client.test.ts +++ b/packages/runtime/src/__tests__/filesystem-worker-client.test.ts @@ -487,6 +487,53 @@ describe('filesystem worker Linux path context', () => { }); }); +describe('filesystem worker Windows Glob path context', () => { + test('marks only the approved Glob subtree for non-following broker admission', async () => { + const workspace = await temporaryDirectory('maka-windows-worker-glob-'); + const { client, transforms } = fakeClient({ platform: 'win32' }); + + await client.execute({ + operation: { kind: 'glob', path: workspace, pattern: '**/*.ts' }, + cwd: workspace, + mode: 'ask', + expectedIdentity: 'unchecked', + }); + + assert.equal(transforms[0]?.command.pathContext.windowsNonFollowingReadRoot, workspace); + }); + + test('does not mark other operations or a non-Windows Glob', async () => { + const workspace = await temporaryDirectory('maka-worker-non-following-scope-'); + const file = join(workspace, 'main.ts'); + await writeFile(file, 'export const value = true;\n'); + const windows = fakeClient({ platform: 'win32' }); + + await windows.client.execute({ + operation: { kind: 'read', path: file }, + cwd: workspace, + mode: 'ask', + expectedIdentity: 'unchecked', + }); + await windows.client.execute({ + operation: grepOperation(workspace), + cwd: workspace, + mode: 'ask', + expectedIdentity: 'unchecked', + }); + assert.equal(windows.transforms[0]?.command.pathContext.windowsNonFollowingReadRoot, undefined); + assert.equal(windows.transforms[1]?.command.pathContext.windowsNonFollowingReadRoot, undefined); + + const mac = fakeClient({ platform: 'darwin' }); + await mac.client.execute({ + operation: { kind: 'glob', path: workspace, pattern: '**/*.ts' }, + cwd: workspace, + mode: 'ask', + expectedIdentity: 'unchecked', + }); + assert.equal(mac.transforms[0]?.command.pathContext.windowsNonFollowingReadRoot, undefined); + }); +}); + function fakeClient( options: { operationErrorCode?: FilesystemWorkerErrorCode; @@ -502,22 +549,27 @@ function fakeClient( const requests: FilesystemWorkerRequest[] = []; const transforms: SandboxTransformRequest[] = []; const platform = options.platform ?? 'darwin'; - const sandboxManager = + const sandboxManager: SandboxManager = platform === 'linux' ? new SandboxManager([ new LinuxBubblewrapBackend({ capability: { available: true, bwrapPath: '/usr/bin/bwrap' }, }), ]) - : new SandboxManager([new MacosSeatbeltBackend()]); + : platform === 'win32' + ? windowsRecordingSandboxManager(transforms) + : new SandboxManager([new MacosSeatbeltBackend()]); const processInputs: FilesystemWorkerProcessRunInput[] = []; const client = new FilesystemWorkerClient({ - sandboxManager: Object.assign(Object.create(sandboxManager), { - transform(request: SandboxTransformRequest): SandboxTransformResult { - transforms.push(request); - return sandboxManager.transform(request); - }, - }) as SandboxManager, + sandboxManager: + platform === 'win32' + ? sandboxManager + : (Object.assign(Object.create(sandboxManager), { + transform(request: SandboxTransformRequest): SandboxTransformResult { + transforms.push(request); + return sandboxManager.transform(request); + }, + }) as SandboxManager), platform, newId: () => `request-${requests.length + 1}`, getLaunchSpec: async () => { @@ -568,6 +620,27 @@ function fakeClient( return { client, requests, transforms, processInputs }; } +function windowsRecordingSandboxManager(transforms: SandboxTransformRequest[]): SandboxManager { + return { + transform(request: SandboxTransformRequest): SandboxTransformResult { + transforms.push(request); + return { + ok: true, + exec: { + argv: ['maka-windows-sandbox.exe'], + cwd: request.command.cwd, + env: request.command.env, + sandboxType: 'windows', + effectiveProfile: request.command.profile, + }, + sandboxType: 'windows', + requiresSandbox: true, + preference: request.preference ?? 'auto', + }; + }, + } as unknown as SandboxManager; +} + function fakeResult(request: FilesystemWorkerRequest): FilesystemWorkerResult { switch (request.operation.kind) { case 'read': diff --git a/packages/runtime/src/__tests__/filesystem-worker-windows-smoke.test.ts b/packages/runtime/src/__tests__/filesystem-worker-windows-smoke.test.ts index 3775f4908c..9e55669394 100644 --- a/packages/runtime/src/__tests__/filesystem-worker-windows-smoke.test.ts +++ b/packages/runtime/src/__tests__/filesystem-worker-windows-smoke.test.ts @@ -197,35 +197,35 @@ describe('Windows filesystem worker smoke', { skip: !enabled }, () => { ); }); - test('glob skips a nested junction without granting its target', async () => { - const sdkDirectory = join(workspace, 'sdk', 'javascript'); - const storyDirectory = join(workspace, 'examples', 'terminal-story'); - const junction = join(storyDirectory, 'node_modules', '@sunrioa', 'rin-sdk'); - await mkdir(sdkDirectory, { recursive: true }); - await mkdir(dirname(junction), { recursive: true }); - await writeFile(join(sdkDirectory, 'package.json'), '{}\n'); - await writeFile(join(storyDirectory, 'main.go'), 'package main\n'); - await symlink(sdkDirectory, junction, 'junction'); - - const ordinary = await client.execute({ - operation: { kind: 'glob', path: storyDirectory, pattern: 'main.go' }, - cwd: workspace, - mode: 'ask', - expectedIdentity: 'unchecked', - }); - assert.deepEqual(ordinary, { kind: 'glob', files: ['main.go'] }); - - const junctionDescendants = await client.execute({ - operation: { - kind: 'glob', - path: storyDirectory, - pattern: 'node_modules/@sunrioa/rin-sdk/**/*', - }, + test('runs Glob beside a nested junction without granting or traversing its target', async () => { + const project = join(workspace, 'junction-project'); + const sourceDirectory = join(project, 'src'); + const junctionParent = join(project, 'node_modules', '@sunrioa'); + await mkdir(sourceDirectory, { recursive: true }); + await mkdir(junctionParent, { recursive: true }); + await writeFile(join(project, 'root.ts'), 'export const root = true;\n'); + await writeFile(join(sourceDirectory, 'main.ts'), 'export const main = true;\n'); + await writeFile(join(outside, 'secret.ts'), 'export const secret = true;\n'); + await symlink(outside, join(junctionParent, 'rin-sdk'), 'junction'); + + const result = await client.execute({ + operation: { kind: 'glob', path: project, pattern: '**/*.ts' }, cwd: workspace, mode: 'ask', expectedIdentity: 'unchecked', }); - assert.deepEqual(junctionDescendants, { kind: 'glob', files: [] }); + + assert.equal(result.kind, 'glob'); + if (result.kind === 'glob') { + assert.deepEqual(result.files.map((file) => file.replaceAll('\\', '/')).sort(), [ + 'root.ts', + 'src/main.ts', + ]); + } + assert.equal( + await readFile(join(outside, 'secret.ts'), 'utf8'), + 'export const secret = true;\n', + ); }); test('fails closed for unapproved outside paths', async () => { diff --git a/packages/runtime/src/__tests__/windows-sandbox-profile.test.ts b/packages/runtime/src/__tests__/windows-sandbox-profile.test.ts index e10cc01b32..e1d8faff1e 100644 --- a/packages/runtime/src/__tests__/windows-sandbox-profile.test.ts +++ b/packages/runtime/src/__tests__/windows-sandbox-profile.test.ts @@ -121,6 +121,50 @@ test('compiles an exact file grant as a non-recursive broker root', () => { assert.deepEqual(policy.exactWriteRoots, []); }); +test('admits one recursive read root for non-following broker decomposition', () => { + const recursiveRead: PermissionProfileManaged = { + ...createReadOnlyPermissionProfile(), + fileSystem: { + kind: 'restricted', + entries: [{ kind: 'path', access: 'read', path: String.raw`C:\work\repo`, match: 'subtree' }], + }, + }; + const input = command(recursiveRead); + input.pathContext.windowsNonFollowingReadRoot = String.raw`C:\work\repo`; + + const policy = compileWindowsSandboxPolicy(input); + + assert.equal(policy.nonFollowingReadRoot, String.raw`C:\work\repo`); +}); + +test('rejects invalid non-following read-root combinations', () => { + const recursiveRead: PermissionProfileManaged = { + ...createReadOnlyPermissionProfile(), + fileSystem: { + kind: 'restricted', + entries: [{ kind: 'path', access: 'read', path: String.raw`C:\work\repo`, match: 'subtree' }], + }, + }; + const outside = command(recursiveRead); + outside.pathContext.windowsNonFollowingReadRoot = String.raw`C:\outside`; + assert.throws(() => compileWindowsSandboxPolicy(outside), /not a declared read root/); + + const exact: PermissionProfileManaged = { + ...recursiveRead, + fileSystem: { + kind: 'restricted', + entries: [{ kind: 'path', access: 'read', path: String.raw`C:\work\repo`, match: 'exact' }], + }, + }; + const exactInput = command(exact); + exactInput.pathContext.windowsNonFollowingReadRoot = String.raw`C:\work\repo`; + assert.throws(() => compileWindowsSandboxPolicy(exactInput), /must be recursive/); + + const writable = command(createWorkspaceWritePermissionProfile()); + writable.pathContext.windowsNonFollowingReadRoot = String.raw`C:\work\repo`; + assert.throws(() => compileWindowsSandboxPolicy(writable), /read-only sandbox profile/); +}); + test('rejects noncanonical paths and case-insensitive duplicate environment names', () => { const invalidPath = command(createWorkspaceWritePermissionProfile()); invalidPath.pathContext = { workspaceRoots: ['C:/work/repo'] }; diff --git a/packages/runtime/src/__tests__/windows-sandbox.test.ts b/packages/runtime/src/__tests__/windows-sandbox.test.ts index 6f2530624b..a8519627df 100644 --- a/packages/runtime/src/__tests__/windows-sandbox.test.ts +++ b/packages/runtime/src/__tests__/windows-sandbox.test.ts @@ -176,6 +176,56 @@ test('transforms a Windows managed profile into a broker-client invocation', () assert.equal(result.exec.sandboxType, 'windows'); }); +test('binds a non-following read root into the broker manifest digest', () => { + let written: WindowsBrokerManifest | undefined; + const backend = new WindowsBrokerSandboxBackend({ + clientPath: String.raw`C:\Program Files\Maka\maka-windows-sandbox.exe`, + nonce: () => 'c'.repeat(32), + requestId: () => 'glob-request', + writeManifest: (manifest) => { + written = manifest; + return String.raw`C:\Users\user\AppData\Local\Temp\glob-request.json`; + }, + }); + const profile = createWorkspaceWritePermissionProfile(); + const result = backend.transform({ + platform: 'win32', + command: { + program: String.raw`C:\Windows\System32\cmd.exe`, + args: [], + cwd: String.raw`C:\work\repo`, + env: {}, + profile: { + ...profile, + fileSystem: { + kind: 'restricted', + entries: [ + { + kind: 'path', + access: 'read', + path: String.raw`C:\work\repo`, + match: 'subtree', + }, + ], + }, + }, + pathContext: { + workspaceRoots: [String.raw`C:\work\repo`], + windowsNonFollowingReadRoot: String.raw`C:\work\repo`, + }, + }, + }); + + assert.equal(result.ok, true); + const launch = written?.launch; + assert.equal(launch?.nonFollowingReadRoot, String.raw`C:\work\repo`); + if (!written || !launch) return; + assert.equal( + written.profileDigest, + createHash('sha256').update(JSON.stringify(launch)).digest('hex'), + ); +}); + test('rejects a request id with characters that are unsafe in a manifest filename', () => { // NTFS interprets ':' in a filename as an alternate-data-stream separator, // and the request id is embedded in the temporary manifest filename. diff --git a/packages/runtime/src/filesystem-worker/client.ts b/packages/runtime/src/filesystem-worker/client.ts index a370a512cf..6a28ec6214 100644 --- a/packages/runtime/src/filesystem-worker/client.ts +++ b/packages/runtime/src/filesystem-worker/client.ts @@ -376,6 +376,13 @@ export class FilesystemWorkerClient { const launch = await this.input.getLaunchSpec(); if (!launch.ok) throw clientError(launch.reason, 'launch', requestId, launch.message); const workerProfile = deriveWorkerProfile(effectiveProfile, operationBoundary); + const windowsNonFollowingReadRoot = + platform === 'win32' && + operation.kind === 'glob' && + target.scope === 'subtree' && + target.targetType === 'directory' + ? target.enforcementPath + : undefined; const pinnedTarget = platform === 'linux' && !entryMode && target.targetType !== 'missing' ? (() => { @@ -453,6 +460,7 @@ export class FilesystemWorkerClient { ...pathContext, runtimeReadableRoots: launch.spec.runtimeReadableRoots, executableRoots: launch.spec.executableRoots, + ...(windowsNonFollowingReadRoot ? { windowsNonFollowingReadRoot } : {}), ...(pinnedTarget ? { pinnedProfilePaths: [ diff --git a/packages/runtime/src/filesystem-worker/operations.ts b/packages/runtime/src/filesystem-worker/operations.ts index 40f956aeeb..1286fd6734 100644 --- a/packages/runtime/src/filesystem-worker/operations.ts +++ b/packages/runtime/src/filesystem-worker/operations.ts @@ -380,7 +380,7 @@ export async function executeFilesystemOperation( ); const files: string[] = []; const limit = operation.limit ?? DEFAULT_GLOB_LIMIT; - for await (const file of nodeGlob(operation.pattern, { cwd: path })) { + for await (const file of nodeGlob(operation.pattern, { cwd: path, followSymlinks: false })) { files.push(typeof file === 'string' ? file : (file as { name: string }).name); if (files.length >= limit) break; } diff --git a/packages/runtime/src/sandbox/types.ts b/packages/runtime/src/sandbox/types.ts index e4120b280e..fad97a7b80 100644 --- a/packages/runtime/src/sandbox/types.ts +++ b/packages/runtime/src/sandbox/types.ts @@ -55,6 +55,12 @@ export interface SandboxPathContext { }[]; /** Profile roots observed as unavailable while preparing this invocation. */ unavailableProfilePaths?: readonly string[]; + /** + * Windows-only recursive read root whose operation contract does not + * follow reparse points. The broker may split this root into narrower + * physical ACL grants while omitting nested reparse entries. + */ + windowsNonFollowingReadRoot?: string; /** Profile roots pinned by open host descriptors until sandbox launch. */ pinnedProfilePaths?: readonly { path: string; diff --git a/packages/runtime/src/sandbox/windows-profile.ts b/packages/runtime/src/sandbox/windows-profile.ts index 16949cd032..b08c81840b 100644 --- a/packages/runtime/src/sandbox/windows-profile.ts +++ b/packages/runtime/src/sandbox/windows-profile.ts @@ -31,6 +31,7 @@ export interface WindowsSandboxPolicy { readonly exactWriteRoots: readonly string[]; readonly network: 'restricted' | 'enabled'; readonly environment: Readonly>; + readonly nonFollowingReadRoot?: string; } export function compileWindowsSandboxPolicy(command: SandboxCommand): WindowsSandboxPolicy { @@ -97,6 +98,25 @@ export function compileWindowsSandboxPolicy(command: SandboxCommand): WindowsSan exactReadRoots.push(canonicalCwd); } + const nonFollowingReadRoot = pathContext.windowsNonFollowingReadRoot + ? canonicalWindowsPath(pathContext.windowsNonFollowingReadRoot) + : undefined; + if (nonFollowingReadRoot && writeRoots.length > 0) { + throw new Error('Windows non-following read roots require a read-only sandbox profile.'); + } + if (nonFollowingReadRoot) { + if (!containsPath(readRoots, nonFollowingReadRoot)) { + throw new Error( + `Windows non-following root is not a declared read root: ${nonFollowingReadRoot}`, + ); + } + if (containsPath(exactReadRoots, nonFollowingReadRoot)) { + throw new Error( + `Windows non-following root must be recursive, not exact: ${nonFollowingReadRoot}`, + ); + } + } + return { readRoots, writeRoots, @@ -104,6 +124,7 @@ export function compileWindowsSandboxPolicy(command: SandboxCommand): WindowsSan exactWriteRoots, network: profile.network.kind, environment: windowsEnvironment(command.env), + ...(nonFollowingReadRoot ? { nonFollowingReadRoot } : {}), }; } @@ -141,6 +162,10 @@ function addUnique(target: string[], path: string): void { } } +function containsPath(paths: readonly string[], path: string): boolean { + return paths.some((existing) => existing.toLowerCase() === path.toLowerCase()); +} + function isValidWindowsEnvironmentName(name: string): boolean { // The CreateProcess environment block is `name=value\0...\0\0`, so a name // may not be empty, contain '=' or a control character (NUL is < 0x20 and diff --git a/packages/runtime/src/sandbox/windows-sandbox.ts b/packages/runtime/src/sandbox/windows-sandbox.ts index 90556a7579..5fa9da086d 100644 --- a/packages/runtime/src/sandbox/windows-sandbox.ts +++ b/packages/runtime/src/sandbox/windows-sandbox.ts @@ -69,8 +69,10 @@ export interface WindowsBrokerManifest { readonly exactWriteRoots: readonly string[]; readonly network: 'restricted' | 'enabled'; readonly environment: Readonly>; - /** Serialized last so manifests without it keep their historical digest. */ + /** Kept in its historical position and omitted only by older producers. */ readonly timeoutMs: number; + /** Optional W1 Glob admission mode; serialized last only when requested. */ + readonly nonFollowingReadRoot?: string; }; } @@ -198,6 +200,9 @@ export class WindowsBrokerSandboxBackend implements SandboxBackend { MAKA_WINDOWS_SANDBOX: '1', }), timeoutMs: this.options.timeoutMs ?? DEFAULT_WINDOWS_BROKER_TIMEOUT_MS, + ...(plan.policy.nonFollowingReadRoot + ? { nonFollowingReadRoot: plan.policy.nonFollowingReadRoot } + : {}), }; manifestPath = this.options.writeManifest({ version: 1, diff --git a/scripts/verify-windows-sandbox-e2e.mjs b/scripts/verify-windows-sandbox-e2e.mjs index c4c9a08766..8969e5f288 100644 --- a/scripts/verify-windows-sandbox-e2e.mjs +++ b/scripts/verify-windows-sandbox-e2e.mjs @@ -20,7 +20,17 @@ import { execFile, spawn } from 'node:child_process'; import { randomBytes } from 'node:crypto'; import { existsSync } from 'node:fs'; -import { mkdir, mkdtemp, readFile, readdir, realpath, rm, stat, writeFile } from 'node:fs/promises'; +import { + mkdir, + mkdtemp, + readFile, + readdir, + realpath, + rm, + stat, + symlink, + writeFile, +} from 'node:fs/promises'; import { homedir, tmpdir } from 'node:os'; import { basename, dirname, join, resolve } from 'node:path'; import { promisify } from 'node:util'; @@ -248,17 +258,25 @@ export async function verifyWindowsSandboxWorkerE2E(appDirectoryPath) { await verifyPackagedAdversarialMatrix(sandboxExecutable); console.log('[verify-windows-sandbox] packaged adversarial matrix verified'); - const sourceDirectory = join(workspace, 'src'); + const projectDirectory = join(workspace, 'junction-project'); + const sourceDirectory = join(projectDirectory, 'src'); + const junctionParent = join(projectDirectory, 'node_modules', '@sunrioa'); await mkdir(sourceDirectory, { recursive: true }); + await mkdir(junctionParent, { recursive: true }); + await writeFile(join(projectDirectory, 'root.ts'), 'export const rootSignal = true;\n'); await writeFile(join(sourceDirectory, 'health.ts'), 'export const healthSignal = true;\n'); + await writeFile(join(outside, 'secret.ts'), 'export const secretSignal = true;\n'); + await symlink(outside, join(junctionParent, 'rin-sdk'), 'junction'); const globResult = await execute({ kind: 'glob', - path: sourceDirectory, + path: projectDirectory, pattern: '**/*.ts', }); assertCondition( - globResult.kind === 'glob' && globResult.files.length === 1, - 'Sandboxed glob did not find the expected file.', + globResult.kind === 'glob' && + JSON.stringify(globResult.files.map((file) => file.replaceAll('\\', '/')).sort()) === + JSON.stringify(['root.ts', 'src/health.ts']), + 'Sandboxed glob did not return exactly the safe project files beside a nested junction.', ); // The sandbox preview does not expose Grep (no in-process substitute // preserves the ripgrep contract); the worker must fail closed. From 697def7c5030880aadd9f9a92bcdc732db126cfe Mon Sep 17 00:00:00 2001 From: sunrioa <178722768+sunrioa@users.noreply.github.com> Date: Thu, 27 Aug 2026 01:37:17 +0800 Subject: [PATCH 3/9] fix(windows): preserve Glob semantics around junctions Generated-by: OpenAI Codex --- .../licenses/npm/THIRD_PARTY_NOTICES.txt | 128 ++++++++ docs/architecture/windows-sandbox-rfc-v1.md | 8 +- .../windows-sandbox-rfc-v1.zh-CN.md | 7 +- experiments/windows-sandbox/README.md | 3 +- .../launcher/src/acl_ledger.rs | 93 +++++- .../launcher/src/acl_ledger_tests.rs | 44 ++- package-lock.json | 4 +- packages/cli/THIRD_PARTY_NOTICES.txt | 128 ++++++++ packages/runtime/package.json | 1 + .../filesystem-worker-windows-smoke.test.ts | 33 ++ .../src/__tests__/filesystem-worker.test.ts | 219 +++++++++++++- .../src/filesystem-worker/operations.ts | 284 +++++++++++++++++- .../src/filesystem-worker/sandbox-paths.ts | 13 +- scripts/verify-windows-sandbox-e2e.mjs | 29 ++ 14 files changed, 965 insertions(+), 29 deletions(-) diff --git a/apps/desktop/resources/licenses/npm/THIRD_PARTY_NOTICES.txt b/apps/desktop/resources/licenses/npm/THIRD_PARTY_NOTICES.txt index 71ec5e9c50..e407640f1d 100644 --- a/apps/desktop/resources/licenses/npm/THIRD_PARTY_NOTICES.txt +++ b/apps/desktop/resources/licenses/npm/THIRD_PARTY_NOTICES.txt @@ -4238,6 +4238,38 @@ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLI ================================================================================ +Package: balanced-match@4.0.4 +Declared license: MIT +Selected license: MIT +Repository: git://github.com/juliangruber/balanced-match.git + +--- LICENSE.md --- +(MIT) + +Original code Copyright Julian Gruber + +Port to TypeScript Copyright Isaac Z. Schlueter + +Permission is hereby granted, free of charge, to any person obtaining a copy of +this software and associated documentation files (the "Software"), to deal in +the Software without restriction, including without limitation the rights to +use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies +of the Software, and to permit persons to whom the Software is furnished to do +so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. + +================================================================================ + Package: bare-addon-resolve@1.10.1 Declared license: Apache-2.0 Selected license: Apache-2.0 @@ -4890,6 +4922,38 @@ OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. ================================================================================ +Package: brace-expansion@5.0.7 +Declared license: MIT +Selected license: MIT +Repository: git+https://github.com/juliangruber/brace-expansion.git + +--- LICENSE --- +MIT License + +Copyright Julian Gruber + +TypeScript port Copyright Isaac Z. Schlueter + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. + +================================================================================ + Package: builder-util-runtime@9.7.0 Declared license: MIT Selected license: MIT @@ -10211,6 +10275,70 @@ SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. ================================================================================ +Package: minimatch@10.2.5 +Declared license: BlueOak-1.0.0 +Selected license: BlueOak-1.0.0 +Repository: git@github.com:isaacs/minimatch + +--- LICENSE.md --- +# Blue Oak Model License + +Version 1.0.0 + +## Purpose + +This license gives everyone as much permission to work with +this software as possible, while protecting contributors +from liability. + +## Acceptance + +In order to receive this license, you must agree to its +rules. The rules of this license are both obligations +under that agreement and conditions to your license. +You must not do anything with this software that triggers +a rule that you cannot or will not follow. + +## Copyright + +Each contributor licenses you to do everything with this +software that would otherwise infringe that contributor's +copyright in it. + +## Notices + +You must ensure that everyone who gets a copy of +any part of this software from you, with or without +changes, also gets the text of this license or a link to +. + +## Excuse + +If anyone notifies you in writing that you have not +complied with [Notices](#notices), you can keep your +license by taking all practical steps to comply within 30 +days after the notice. If you do not do so, your license +ends immediately. + +## Patent + +Each contributor licenses you to do everything with this +software that would otherwise infringe any patent claims +they can license or become able to license. + +## Reliability + +No contributor can revoke this license. + +## No Liability + +**_As far as the law allows, this software comes as is, +without any warranty or condition, and no contributor +will be liable to anyone for any damages related to this +software or this license, under any kind of legal claim._** + +================================================================================ + Package: minisearch@7.2.0 Declared license: MIT Selected license: MIT diff --git a/docs/architecture/windows-sandbox-rfc-v1.md b/docs/architecture/windows-sandbox-rfc-v1.md index 74244515ac..a9a86c6ae4 100644 --- a/docs/architecture/windows-sandbox-rfc-v1.md +++ b/docs/architecture/windows-sandbox-rfc-v1.md @@ -378,7 +378,7 @@ sequenceDiagram M-->>H: native path + one-shot manifest H->>B: --broker-local manifest B->>B: delete manifest; bind PID, nonce, and launch digest - B->>B: recover ledger; reject reparse trees; grant SID ACEs + B->>B: recover ledger; reject or partition reparse trees; grant SID ACEs B->>J: create kill-on-close Job B->>C: create AppContainer process with atomic Job attribute C-->>B: bounded exit result @@ -395,8 +395,10 @@ default. A manifest produced specifically for the read-only W1 Glob operation ma recursive root as non-following: the broker then records an exact grant for directories containing a nested reparse entry, recursive grants for clean child directories, and no grant for the reparse entry or its target. The marked root itself and every multi-hard-link file still fail closed. The -broker persists a versioned ledger with `create_new` and `sync_all`, and reconciles every stale ledger before accepting -a new request. A global kernel mutex covers only ledger/ACL mutation; each launch holds a separate +decomposition fails closed above 4,096 physical grants, 100,000 inspected filesystem entries, or 256 +nested directory levels below the root. The broker persists a versioned ledger with `create_new` and `sync_all`, and +reconciles every stale ledger before accepting a new request. A global kernel mutex covers only +ledger/ACL mutation; each launch holds a separate request-specific kernel lease through child settlement, so recovery skips live ledgers while disjoint launches execute concurrently. Normal settlement removes the SID ACE and then deletes the ledger. diff --git a/docs/architecture/windows-sandbox-rfc-v1.zh-CN.md b/docs/architecture/windows-sandbox-rfc-v1.zh-CN.md index c4e409e555..e18be81a2c 100644 --- a/docs/architecture/windows-sandbox-rfc-v1.zh-CN.md +++ b/docs/architecture/windows-sandbox-rfc-v1.zh-CN.md @@ -228,7 +228,7 @@ sequenceDiagram M-->>H: native path + one-shot manifest H->>B: --broker-local manifest B->>B: delete manifest; bind PID, nonce, launch digest - B->>B: recover ledger; reject reparse tree; grant SID ACE + B->>B: recover ledger; reject or partition reparse tree; grant SID ACE B->>J: create kill-on-close Job B->>C: create AppContainer process with atomic Job attribute C-->>B: bounded exit result @@ -242,8 +242,9 @@ sequenceDiagram native binary 只给当前 launch 允许的 root 授予其独立 SID。修改前默认递归拒绝 `FILE_ATTRIBUTE_REPARSE_POINT`。只有只读 W1 Glob 生成的 manifest 可以把它唯一的递归 root 标记为 不跟随:Broker 对含嵌套 reparse entry 的目录使用 exact grant,对干净子目录保留 recursive grant, -并且不给 reparse entry 或其 target 授权。被标记的 root 自身以及任何多硬链接文件仍然 fail closed。随后用 -`create_new` 和 `sync_all` 持久化版本化 ledger,并在接收新请求前 reconcile 全部遗留 ledger。正常结束先移除 SID ACE,再 +并且不给 reparse entry 或其 target 授权。被标记的 root 自身以及任何多硬链接文件仍然 fail closed。 +该分解在超过 4,096 个物理授权、100,000 个文件系统条目或根目录以下 256 层嵌套目录时 fail closed。 +随后用 `create_new` 和 `sync_all` 持久化版本化 ledger,并在接收新请求前 reconcile 全部遗留 ledger。正常结束先移除 SID ACE,再 删除 ledger。全局 kernel mutex 只覆盖 ledger/ACL 修改;每个 launch 在 child settlement 完成前持有独立的 request-specific kernel lease,因此 recovery 会跳过仍在使用的 ledger,同时不同 launch 仍可并发执行。 diff --git a/experiments/windows-sandbox/README.md b/experiments/windows-sandbox/README.md index b2b654c46f..324cfcd14c 100644 --- a/experiments/windows-sandbox/README.md +++ b/experiments/windows-sandbox/README.md @@ -85,7 +85,8 @@ default, and grants that per-launch SID only the requested roots. The W1 filesystem worker can explicitly mark one read-only Glob root for non-following decomposition: nested reparse entries are omitted while clean child directories receive narrower recursive grants; the root itself and hard links remain -fail-closed. A short-lived global mutex +fail-closed. Planning is bounded to 4,096 physical grants, 100,000 inspected +filesystem entries, and 256 nested directory levels below the root. A short-lived global mutex serializes ACL mutation, while a request-specific kernel lease distinguishes live ledgers from abandoned ones without serializing child execution. The smoke proves allowed read/write access, denial of a user-readable sibling file and diff --git a/experiments/windows-sandbox/launcher/src/acl_ledger.rs b/experiments/windows-sandbox/launcher/src/acl_ledger.rs index 56b70ee2ed..aef478ece9 100644 --- a/experiments/windows-sandbox/launcher/src/acl_ledger.rs +++ b/experiments/windows-sandbox/launcher/src/acl_ledger.rs @@ -54,6 +54,8 @@ use crate::windows_launcher::{appcontainer_profile_name, current_user_sid_string pub(crate) const LEDGER_VERSION: u8 = 2; const ACL_MUTEX_TIMEOUT_MS: u32 = 30_000; const MAX_NON_FOLLOWING_READ_GRANTS: usize = 4_096; +const MAX_NON_FOLLOWING_READ_ENTRIES: usize = 100_000; +const MAX_NON_FOLLOWING_READ_DEPTH: usize = 256; /// The ledger directory, the icacls grants and the AppContainer profiles are /// all shared across every session of the user, so the locks that arbitrate @@ -492,6 +494,31 @@ struct DirectoryReadPlan { roots: Vec, } +struct NonFollowingScanBudget { + remaining: usize, + limit: usize, +} + +impl NonFollowingScanBudget { + fn new(limit: usize) -> Self { + Self { + remaining: limit, + limit, + } + } + + fn consume(&mut self) -> Result<(), String> { + if self.remaining == 0 { + return Err(format!( + "nonFollowingReadRoot exceeds the safe scan limit of {} filesystem entries", + self.limit + )); + } + self.remaining -= 1; + Ok(()) + } +} + /// Decomposes one read-only recursive root into physical grants that let a /// non-following operation enumerate ordinary entries without granting or /// traversing nested Windows reparse points. The root itself remains strict: @@ -503,6 +530,20 @@ fn partition_non_following_read_root(path: &Path) -> Result, Str pub(crate) fn partition_non_following_read_root_with_limit( path: &Path, max_grants: usize, +) -> Result, String> { + partition_non_following_read_root_with_limits( + path, + max_grants, + MAX_NON_FOLLOWING_READ_ENTRIES, + MAX_NON_FOLLOWING_READ_DEPTH, + ) +} + +pub(crate) fn partition_non_following_read_root_with_limits( + path: &Path, + max_grants: usize, + max_entries: usize, + max_depth: usize, ) -> Result, String> { match fs::symlink_metadata(path) { Ok(metadata) => { @@ -527,13 +568,25 @@ pub(crate) fn partition_non_following_read_root_with_limit( )); } } - Ok(plan_non_following_directory(path, max_grants)?.roots) + let mut scan_budget = NonFollowingScanBudget::new(max_entries); + let roots = + plan_non_following_directory(path, max_grants, &mut scan_budget, 0, max_depth)?.roots; + ensure_non_following_grant_limit(roots.len(), max_grants)?; + Ok(roots) } fn plan_non_following_directory( path: &Path, max_grants: usize, + scan_budget: &mut NonFollowingScanBudget, + depth: usize, + max_depth: usize, ) -> Result { + if depth > max_depth { + return Err(format!( + "nonFollowingReadRoot exceeds the safe nested-directory limit of {max_depth} below the root" + )); + } let metadata = fs::symlink_metadata(path) .map_err(|error| format!("inspect ACL root {} failed: {error}", path.display()))?; if metadata.file_attributes() & FILE_ATTRIBUTE_REPARSE_POINT != 0 { @@ -549,10 +602,15 @@ fn plan_non_following_directory( )); } - let mut entries = fs::read_dir(path) + let mut entries = Vec::new(); + for entry in fs::read_dir(path) .map_err(|error| format!("scan ACL root {} failed: {error}", path.display()))? - .collect::, _>>() - .map_err(|error| format!("scan ACL root failed: {error}"))?; + { + scan_budget.consume()?; + entries.push( + entry.map_err(|error| format!("scan ACL root {} failed: {error}", path.display()))?, + ); + } entries.sort_by_key(|entry| entry.file_name()); let mut clean = true; @@ -563,12 +621,28 @@ fn plan_non_following_directory( .map_err(|error| format!("inspect ACL root {} failed: {error}", child.display()))?; if child_metadata.file_attributes() & FILE_ATTRIBUTE_REPARSE_POINT != 0 { clean = false; + let child_grants = directory_plans + .iter() + .fold(0usize, |count, plan| count.saturating_add(plan.roots.len())); + ensure_non_following_grant_limit(1usize.saturating_add(child_grants), max_grants)?; continue; } if child_metadata.is_dir() { - let child_plan = plan_non_following_directory(&child, max_grants)?; + let child_plan = plan_non_following_directory( + &child, + max_grants, + scan_budget, + depth.saturating_add(1), + max_depth, + )?; clean &= child_plan.clean; directory_plans.push(child_plan); + if !clean { + let child_grants = directory_plans + .iter() + .fold(0usize, |count, plan| count.saturating_add(plan.roots.len())); + ensure_non_following_grant_limit(1usize.saturating_add(child_grants), max_grants)?; + } continue; } if child_metadata.is_file() { @@ -612,6 +686,15 @@ fn append_partitioned_roots( Ok(()) } +fn ensure_non_following_grant_limit(grants: usize, max_grants: usize) -> Result<(), String> { + if grants > max_grants { + return Err(format!( + "nonFollowingReadRoot exceeds the safe limit of {max_grants} physical ACL grants" + )); + } + Ok(()) +} + fn read_root(path: &Path, recursive: bool) -> Result { let path = path .to_str() diff --git a/experiments/windows-sandbox/launcher/src/acl_ledger_tests.rs b/experiments/windows-sandbox/launcher/src/acl_ledger_tests.rs index 657c270b4f..846be221ca 100644 --- a/experiments/windows-sandbox/launcher/src/acl_ledger_tests.rs +++ b/experiments/windows-sandbox/launcher/src/acl_ledger_tests.rs @@ -28,7 +28,8 @@ mod tests { use crate::acl_ledger::{ LEDGER_VERSION, LaunchFailure, Ledger, LedgerRoot, collect_roots, recover_stale, - partition_non_following_read_root_with_limit, with_acl_grants, write_ledger, + partition_non_following_read_root_with_limit, + partition_non_following_read_root_with_limits, with_acl_grants, write_ledger, }; use crate::protocol::{LaunchRequest, NetworkMode}; @@ -551,6 +552,47 @@ mod tests { assert!(error.contains("safe limit of 2"), "unexpected error: {error}"); } + #[test] + fn non_following_read_root_bounds_scan_work_before_planning_finishes() { + let fixture = Fixture::new("non-following-scan-limit"); + + let roots = partition_non_following_read_root_with_limits(&fixture.target, 4_096, 2, 256) + .expect("an exact scan-entry budget must admit the clean fixture"); + assert_eq!(roots.len(), 1); + + let error = partition_non_following_read_root_with_limits(&fixture.target, 4_096, 1, 256) + .expect_err("filesystem scan work must be bounded independently of final grants"); + + assert!( + error.contains("safe scan limit of 1 filesystem entries"), + "unexpected error: {error}" + ); + } + + #[test] + fn non_following_read_root_enforces_zero_grant_limit() { + let fixture = Fixture::new("non-following-zero-grants"); + + let error = partition_non_following_read_root_with_limit(&fixture.target, 0) + .expect_err("even one clean recursive root must respect the grant limit"); + + assert!(error.contains("safe limit of 0"), "unexpected error: {error}"); + } + + #[test] + fn non_following_read_root_bounds_directory_depth() { + let fixture = Fixture::new("non-following-depth-limit"); + fs::create_dir_all(fixture.target.join("nested")).expect("create nested directory"); + + let error = partition_non_following_read_root_with_limits(&fixture.target, 4_096, 100, 0) + .expect_err("nested directories must respect the independent depth limit"); + + assert!( + error.contains("safe nested-directory limit of 0 below the root"), + "unexpected error: {error}" + ); + } + fn create_junction(path: &Path, target: &Path) { let output = Command::new("cmd.exe") .args(["/d", "/c", "mklink", "/J"]) diff --git a/package-lock.json b/package-lock.json index a7755125cd..8b4a94ea40 100644 --- a/package-lock.json +++ b/package-lock.json @@ -5374,7 +5374,6 @@ "version": "4.0.4", "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", - "dev": true, "license": "MIT", "engines": { "node": "18 || 20 || >=22" @@ -5544,7 +5543,6 @@ "version": "5.0.7", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.7.tgz", "integrity": "sha512-7oFy703dxfY3/NLxC1fh2SUCQ0H9rmAY+5EpDVfXjUTTs+HEwR2nYaqLv+GWcTsumwxPfiz6CzCNkwXwBUwqCA==", - "dev": true, "license": "MIT", "dependencies": { "balanced-match": "^4.0.2" @@ -10250,7 +10248,6 @@ "version": "10.2.5", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.5.tgz", "integrity": "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==", - "dev": true, "license": "BlueOak-1.0.0", "dependencies": { "brace-expansion": "^5.0.5" @@ -13966,6 +13963,7 @@ "https-proxy-agent": "^9.1.0", "image-dimensions": "^2.5.1", "linkedom": "^0.18.13", + "minimatch": "10.2.5", "minisearch": "7.2.0", "node-pty": "^1.2.0-beta.15", "qrcode": "^1.5.4", diff --git a/packages/cli/THIRD_PARTY_NOTICES.txt b/packages/cli/THIRD_PARTY_NOTICES.txt index 7f6b2f82de..45f99c4e07 100644 --- a/packages/cli/THIRD_PARTY_NOTICES.txt +++ b/packages/cli/THIRD_PARTY_NOTICES.txt @@ -2236,6 +2236,38 @@ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLI ================================================================================ +Package: balanced-match@4.0.4 +Declared license: MIT +Selected license: MIT +Repository: git://github.com/juliangruber/balanced-match.git + +--- LICENSE.md --- +(MIT) + +Original code Copyright Julian Gruber + +Port to TypeScript Copyright Isaac Z. Schlueter + +Permission is hereby granted, free of charge, to any person obtaining a copy of +this software and associated documentation files (the "Software"), to deal in +the Software without restriction, including without limitation the rights to +use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies +of the Software, and to permit persons to whom the Software is furnished to do +so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. + +================================================================================ + Package: bare-addon-resolve@1.10.1 Declared license: Apache-2.0 Selected license: Apache-2.0 @@ -2888,6 +2920,38 @@ OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. ================================================================================ +Package: brace-expansion@5.0.7 +Declared license: MIT +Selected license: MIT +Repository: git+https://github.com/juliangruber/brace-expansion.git + +--- LICENSE --- +MIT License + +Copyright Julian Gruber + +TypeScript port Copyright Isaac Z. Schlueter + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. + +================================================================================ + Package: call-bind-apply-helpers@1.0.2 Declared license: MIT Selected license: MIT @@ -5395,6 +5459,70 @@ SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. ================================================================================ +Package: minimatch@10.2.5 +Declared license: BlueOak-1.0.0 +Selected license: BlueOak-1.0.0 +Repository: git@github.com:isaacs/minimatch + +--- LICENSE.md --- +# Blue Oak Model License + +Version 1.0.0 + +## Purpose + +This license gives everyone as much permission to work with +this software as possible, while protecting contributors +from liability. + +## Acceptance + +In order to receive this license, you must agree to its +rules. The rules of this license are both obligations +under that agreement and conditions to your license. +You must not do anything with this software that triggers +a rule that you cannot or will not follow. + +## Copyright + +Each contributor licenses you to do everything with this +software that would otherwise infringe that contributor's +copyright in it. + +## Notices + +You must ensure that everyone who gets a copy of +any part of this software from you, with or without +changes, also gets the text of this license or a link to +. + +## Excuse + +If anyone notifies you in writing that you have not +complied with [Notices](#notices), you can keep your +license by taking all practical steps to comply within 30 +days after the notice. If you do not do so, your license +ends immediately. + +## Patent + +Each contributor licenses you to do everything with this +software that would otherwise infringe any patent claims +they can license or become able to license. + +## Reliability + +No contributor can revoke this license. + +## No Liability + +**_As far as the law allows, this software comes as is, +without any warranty or condition, and no contributor +will be liable to anyone for any damages related to this +software or this license, under any kind of legal claim._** + +================================================================================ + Package: minisearch@7.2.0 Declared license: MIT Selected license: MIT diff --git a/packages/runtime/package.json b/packages/runtime/package.json index 53d05f563c..a0bb32adf0 100644 --- a/packages/runtime/package.json +++ b/packages/runtime/package.json @@ -146,6 +146,7 @@ "https-proxy-agent": "^9.1.0", "image-dimensions": "^2.5.1", "linkedom": "^0.18.13", + "minimatch": "10.2.5", "minisearch": "7.2.0", "node-pty": "^1.2.0-beta.15", "qrcode": "^1.5.4", diff --git a/packages/runtime/src/__tests__/filesystem-worker-windows-smoke.test.ts b/packages/runtime/src/__tests__/filesystem-worker-windows-smoke.test.ts index 9e55669394..d9ec1d5afa 100644 --- a/packages/runtime/src/__tests__/filesystem-worker-windows-smoke.test.ts +++ b/packages/runtime/src/__tests__/filesystem-worker-windows-smoke.test.ts @@ -222,6 +222,39 @@ describe('Windows filesystem worker smoke', { skip: !enabled }, () => { 'src/main.ts', ]); } + + const explicit = await client.execute({ + operation: { + kind: 'glob', + path: project, + pattern: 'node_modules/@sunrioa/rin-sdk/**/*', + }, + cwd: workspace, + mode: 'ask', + expectedIdentity: 'unchecked', + }); + assert.deepEqual(explicit, { kind: 'glob', files: [] }); + + const broad = await client.execute({ + operation: { kind: 'glob', path: project, pattern: '**/*' }, + cwd: workspace, + mode: 'ask', + expectedIdentity: 'unchecked', + }); + assert.equal(broad.kind, 'glob'); + if (broad.kind === 'glob') { + const files = broad.files.map((file) => file.replaceAll('\\', '/')); + assert.ok(files.includes('root.ts')); + assert.ok(files.includes('src/main.ts')); + assert.equal( + files.some( + (file) => + file === 'node_modules/@sunrioa/rin-sdk' || + file.startsWith('node_modules/@sunrioa/rin-sdk/'), + ), + false, + ); + } assert.equal( await readFile(join(outside, 'secret.ts'), 'utf8'), 'export const secret = true;\n', diff --git a/packages/runtime/src/__tests__/filesystem-worker.test.ts b/packages/runtime/src/__tests__/filesystem-worker.test.ts index d4bf0a755b..33f2096d7f 100644 --- a/packages/runtime/src/__tests__/filesystem-worker.test.ts +++ b/packages/runtime/src/__tests__/filesystem-worker.test.ts @@ -19,9 +19,11 @@ import { strict as assert } from 'node:assert'; import { + glob as nodeGlob, lstat, mkdtemp, mkdir, + readdir, readFile, realpath, rm, @@ -30,7 +32,7 @@ import { writeFile, } from 'node:fs/promises'; import { tmpdir } from 'node:os'; -import { join, parse } from 'node:path'; +import { join, parse, sep } from 'node:path'; import { afterEach, describe, test } from 'node:test'; import { executeFilesystemWorkerRequest } from '../filesystem-worker/operations.js'; @@ -206,6 +208,221 @@ describe('filesystem worker operations', () => { } }); + test('keeps Windows sandbox Glob out of directory links for broad and explicit patterns', async () => { + const root = await temporaryDirectory('maka-worker-glob-links-'); + const outside = await temporaryDirectory('maka-worker-glob-outside-'); + const project = join(root, 'project with spaces [glob]'); + const sourceDirectory = join(project, 'src'); + const linkParent = join(project, 'node_modules', '@sunrioa'); + const link = join(linkParent, 'rin-sdk'); + const magicLink = join(linkParent, 'module[1]'); + const braceLink = join(linkParent, 'bracea'); + await mkdir(sourceDirectory, { recursive: true }); + await mkdir(linkParent, { recursive: true }); + await writeFile(join(project, 'root.txt'), 'root', 'utf8'); + await writeFile(join(sourceDirectory, 'main [1].ts'), 'export const safe = true;\n', 'utf8'); + await writeFile(join(outside, 'secret.ts'), 'export const secret = true;\n', 'utf8'); + await writeFile(join(outside, 'package.json'), '{}\n', 'utf8'); + const linkType = process.platform === 'win32' ? 'junction' : 'dir'; + await symlink(outside, link, linkType); + await symlink(outside, magicLink, linkType); + await symlink(outside, braceLink, linkType); + + let visitedDirectories: string[] = []; + const runGlob = async (pattern: string, limit?: number): Promise => { + visitedDirectories = []; + const response = await executeFilesystemWorkerRequest( + await requestFor( + { kind: 'glob', cwd: root, path: project, pattern, ...(limit ? { limit } : {}) }, + { + enforcementPath: project, + access: 'read', + scope: 'subtree', + targetType: 'directory', + }, + ), + { + windowsSandboxed: true, + windowsGlobReadDirectory: async (path) => { + visitedDirectories.push(path); + return readdir(path, { withFileTypes: true }); + }, + }, + ); + assert.equal(response.ok, true); + if (!response.ok || response.result.kind !== 'glob') return []; + return response.result.files.map((file) => file.replaceAll('\\', '/')); + }; + + assert.deepEqual(await runGlob('node_modules/@sunrioa/rin-sdk/**/*'), []); + assert.deepEqual(await runGlob('node_modules/@sunrioa/rin-sdk'), []); + assert.deepEqual(await runGlob('node_modules/@sunrioa/rin-sdk/package.json'), []); + assert.deepEqual(await runGlob('node_modules/*/rin-sdk/**/*'), []); + assert.equal( + visitedDirectories.some((path) => path === link || path.startsWith(`${link}${sep}`)), + false, + ); + + const broad = await runGlob('**/*'); + assert.ok(broad.includes('root.txt')); + assert.ok(broad.includes('src/main [1].ts')); + assert.equal( + broad.some( + (file) => + file === 'node_modules/@sunrioa/rin-sdk' || + file.startsWith('node_modules/@sunrioa/rin-sdk/') || + file === 'node_modules/@sunrioa/module[1]' || + file.startsWith('node_modules/@sunrioa/module[1]/') || + file === 'node_modules/@sunrioa/bracea' || + file.startsWith('node_modules/@sunrioa/bracea/'), + ), + false, + ); + assert.equal( + visitedDirectories.some((path) => + [link, magicLink, braceLink].some( + (candidate) => path === candidate || path.startsWith(`${candidate}${sep}`), + ), + ), + false, + ); + + assert.deepEqual(await runGlob('src/**/*.ts'), ['src/main [1].ts']); + assert.deepEqual(await runGlob('{node_modules/@sunrioa/rin-sdk/**/*,src/**/*.ts}', 1), [ + 'src/main [1].ts', + ]); + assert.deepEqual(await runGlob('{node_modules/@sunrioa/brace{a,b}/**,src/**/*.ts}'), [ + 'src/main [1].ts', + ]); + }); + + test('preserves Windows Glob matching semantics in the non-following walker', async () => { + const root = await temporaryDirectory('maka-worker-glob-semantics-'); + await mkdir(join(root, 'src', 'nested'), { recursive: true }); + await mkdir(join(root, 'docs'), { recursive: true }); + await writeFile(join(root, 'root.txt'), 'root', 'utf8'); + await writeFile(join(root, '.hidden.ts'), 'hidden', 'utf8'); + await writeFile(join(root, 'src', 'main.ts'), 'main', 'utf8'); + await writeFile(join(root, 'src', 'nested', 'deep.ts'), 'deep', 'utf8'); + await writeFile(join(root, 'src', 'nested', 'readme.md'), 'readme', 'utf8'); + await writeFile(join(root, 'docs', 'guide.md'), 'guide', 'utf8'); + + const runGlob = async (pattern: string): Promise => { + const response = await executeFilesystemWorkerRequest( + await requestFor( + { kind: 'glob', cwd: root, path: root, pattern }, + { + enforcementPath: root, + access: 'read', + scope: 'subtree', + targetType: 'directory', + }, + ), + { windowsSandboxed: true }, + ); + assert.equal(response.ok, true); + if (!response.ok || response.result.kind !== 'glob') return []; + return response.result.files.map((file) => file.replaceAll('\\', '/')).sort(); + }; + + const cases: ReadonlyArray = [ + ['*', ['docs', 'root.txt', 'src']], + [ + '**', + [ + '.', + 'docs', + 'docs/guide.md', + 'root.txt', + 'src', + 'src/main.ts', + 'src/nested', + 'src/nested/deep.ts', + 'src/nested/readme.md', + ], + ], + ['**/', ['.', 'docs', 'src', 'src/nested']], + [ + 'src/**', + ['src', 'src/main.ts', 'src/nested', 'src/nested/deep.ts', 'src/nested/readme.md'], + ], + ['src/**/*.ts', ['src/main.ts', 'src/nested/deep.ts']], + ['{src/**/*.ts,docs/*.md}', ['docs/guide.md', 'src/main.ts', 'src/nested/deep.ts']], + ['SRC/**/*.TS', ['src/main.ts', 'src/nested/deep.ts']], + ['.hidden.ts', ['.hidden.ts']], + ]; + for (const [pattern, expected] of cases) { + assert.deepEqual(await runGlob(pattern), [...expected].sort(), pattern); + } + }); + + test('preserves Node Glob ordering and bounded prefixes without directory links', async () => { + const root = await temporaryDirectory('maka-worker-glob-node-parity-'); + await mkdir(join(root, 'src', 'nested'), { recursive: true }); + await mkdir(join(root, 'foo', 'bar', 'end'), { recursive: true }); + await mkdir(join(root, 'foo', 'bar', 'baz'), { recursive: true }); + await mkdir(join(root, '.dotdir'), { recursive: true }); + await mkdir(join(root, 'foo', '.dotdir', 'child'), { recursive: true }); + await writeFile(join(root, 'file'), 'file', 'utf8'); + await writeFile(join(root, '.dot'), 'dot', 'utf8'); + await writeFile(join(root, '.dotdir', 'child'), 'child', 'utf8'); + await writeFile(join(root, 'foo', '.dot'), 'dot', 'utf8'); + await writeFile(join(root, 'foo', '.dotdir', 'child', 'value'), 'value', 'utf8'); + await writeFile(join(root, 'foo', 'bar', 'end', 'value'), 'value', 'utf8'); + await writeFile(join(root, 'foo', 'bar', 'baz', 'end'), 'end', 'utf8'); + await writeFile(join(root, 'src', 'foo'), 'foo', 'utf8'); + await writeFile(join(root, 'src', 'main.ts'), 'main', 'utf8'); + await writeFile(join(root, 'src', 'nested', 'deep.ts'), 'deep', 'utf8'); + + const runWindowsGlob = async (pattern: string, limit?: number): Promise => { + const response = await executeFilesystemWorkerRequest( + await requestFor( + { kind: 'glob', cwd: root, path: root, pattern, ...(limit ? { limit } : {}) }, + { + enforcementPath: root, + access: 'read', + scope: 'subtree', + targetType: 'directory', + }, + ), + { windowsSandboxed: true }, + ); + assert.equal(response.ok, true); + if (!response.ok || response.result.kind !== 'glob') return []; + return response.result.files.map((file) => file.replaceAll('\\', '/')); + }; + + const patterns = [ + './src/**/*.ts', + './*', + './foo/', + './**', + 'foo/.', + 'foo/*/.', + 'src/?(foo)', + 'foo/**/?(end)', + '**/.*/**', + 'file/**', + 'src/**/', + '{src/**/*.ts,foo/**/end/**}', + '**/*', + ]; + for (const pattern of patterns) { + const expected: string[] = []; + for await (const file of nodeGlob(pattern, { cwd: root, followSymlinks: false })) { + expected.push(file.replaceAll('\\', '/')); + } + assert.deepEqual(await runWindowsGlob(pattern), expected, pattern); + for (const limit of [1, 2, 5]) { + assert.deepEqual( + await runWindowsGlob(pattern, limit), + expected.slice(0, limit), + `${pattern} limit=${limit}`, + ); + } + } + }); + test('runs Grep from the filesystem root without broadening its target permission', async () => { const root = await temporaryDirectory('maka-worker-grep-root-'); const target = join(root, 'file.ts'); diff --git a/packages/runtime/src/filesystem-worker/operations.ts b/packages/runtime/src/filesystem-worker/operations.ts index 1286fd6734..de77c5e7ec 100644 --- a/packages/runtime/src/filesystem-worker/operations.ts +++ b/packages/runtime/src/filesystem-worker/operations.ts @@ -18,9 +18,10 @@ */ import { spawn } from 'node:child_process'; -import { promises as fs } from 'node:fs'; +import { promises as fs, type Dirent } from 'node:fs'; import { glob as nodeGlob } from 'node:fs/promises'; -import { dirname, isAbsolute, parse, resolve } from 'node:path'; +import { dirname, isAbsolute, join, parse, resolve } from 'node:path'; +import { GLOBSTAR, Minimatch, type MinimatchOptions } from 'minimatch'; import { isPathInside } from '../path-containment.js'; import { sandboxPathApi } from './sandbox-paths.js'; import { sandboxBoundaryExpansionAllowsPath } from '@maka/core/sandbox-boundary'; @@ -60,6 +61,15 @@ import { isLikelySandboxDenial } from '../sandbox/detect.js'; const { realpath, realpathAllowMissing, resolveCanonicalDirectoryEntryTarget } = sandboxPathApi(); const DEFAULT_GLOB_LIMIT = 200; +const WINDOWS_GLOB_MATCH_OPTIONS = { + nocase: true, + windowsPathsNoEscape: true, + nonegate: true, + nocomment: true, + optimizationLevel: 2, + platform: 'win32', + nocaseMagicOnly: true, +} satisfies MinimatchOptions; const MAX_GREP_OUTPUT_BYTES = 8 * 1024 * 1024; const MAX_GREP_STDERR_BYTES = 16 * 1024; @@ -68,6 +78,8 @@ export interface FilesystemWorkerOperationDependencies { runGrep?: FilesystemWorkerGrepRunner; /** Set when the worker runs inside the Windows AppContainer sandbox. */ windowsSandboxed?: boolean; + /** Test seam for proving that sandboxed Windows Glob never enters a directory link. */ + windowsGlobReadDirectory?: (path: string) => Promise; } export interface FilesystemWorkerGrepRunInput { @@ -378,10 +390,24 @@ export async function executeFilesystemOperation( 'read', operationBoundary, ); - const files: string[] = []; const limit = operation.limit ?? DEFAULT_GLOB_LIMIT; - for await (const file of nodeGlob(operation.pattern, { cwd: path, followSymlinks: false })) { - files.push(typeof file === 'string' ? file : (file as { name: string }).name); + if (dependencies.windowsSandboxed) { + return { + kind: 'glob', + files: await windowsNonFollowingGlob( + path, + operation.pattern, + limit, + dependencies.windowsGlobReadDirectory, + ), + }; + } + const files: string[] = []; + for await (const file of nodeGlob(operation.pattern, { + cwd: path, + followSymlinks: false, + })) { + files.push(file); if (files.length >= limit) break; } return { kind: 'glob', files }; @@ -649,6 +675,254 @@ function assertContainedGlobPattern(pattern: string): void { } } +type WindowsGlobPatternPart = string | RegExp | typeof GLOBSTAR; + +interface WindowsGlobBranchState { + readonly id: number; + readonly pattern: readonly WindowsGlobPatternPart[]; + readonly indexes: readonly number[]; +} + +interface WindowsGlobPathState { + readonly absolutePath: string; + readonly relativePath: string; + readonly isDirectory: boolean; + readonly branches: readonly WindowsGlobBranchState[]; +} + +async function windowsNonFollowingGlob( + root: string, + pattern: string, + limit: number, + readDirectory?: (path: string) => Promise, +): Promise { + const matcher = new Minimatch(pattern, WINDOWS_GLOB_MATCH_OPTIONS); + const initialBranches = matcher.set.map((compiled, id) => ({ + id, + pattern: compiled as WindowsGlobPatternPart[], + indexes: [0], + })); + const files: string[] = []; + const emitted = new Set(); + const emit = (relativePath: string): boolean => { + if (emitted.has(relativePath)) return false; + emitted.add(relativePath); + files.push(relativePath); + return files.length >= limit; + }; + + const pending: WindowsGlobPathState[] = [ + { absolutePath: root, relativePath: '.', isDirectory: true, branches: initialBranches }, + ]; + while (pending.length > 0) { + const currentPath = pending.pop(); + if (!currentPath) break; + let entries: Dirent[] | undefined; + const readEntries = async (): Promise => { + if (entries) return entries; + entries = + (await readWindowsNonFollowingDirectory(root, currentPath.absolutePath, readDirectory)) ?? + []; + return entries; + }; + const childPaths = new Map(); + + // Keep Node Glob's branch and LIFO traversal order so a bounded result is + // the same prefix users receive outside the Windows sandbox. + for (const branch of currentPath.branches) { + const last = branch.pattern.length - 1; + const isLast = windowsGlobPatternIsLast(branch, currentPath.isDirectory); + const isFirst = branch.indexes.includes(0); + + if (isFirst && branch.pattern[0] === '.') { + addWindowsGlobSubpattern(childPaths, currentPath, { + ...branch, + indexes: [1], + }); + continue; + } + + const finalPart = branch.pattern[last]; + if (isLast && typeof finalPart === 'string') { + if (finalPart === '' || finalPart === '.') { + if (currentPath.isDirectory && emit(currentPath.relativePath)) return files; + } else if (currentPath.isDirectory) { + const entry = (await readEntries()).find( + (candidate) => + !candidate.isSymbolicLink() && + candidate.name.toLowerCase() === finalPart.toLowerCase(), + ); + if (entry) { + const relativePath = join(currentPath.relativePath, entry.name); + if (emit(relativePath)) return files; + } + } + if (branch.indexes.length === 1 && branch.indexes[0] === last) continue; + } else if ( + isLast && + finalPart === GLOBSTAR && + (currentPath.relativePath !== '.' || + branch.pattern[0] === '.' || + (last === 0 && currentPath.isDirectory)) && + emit(currentPath.relativePath) + ) { + return files; + } + + if (!currentPath.isDirectory) continue; + + for (const entry of await readEntries()) { + // libuv reports every Windows FILE_ATTRIBUTE_REPARSE_POINT as a link + // Dirent, including junctions and reparse types that lstat may otherwise + // present as ordinary directories. Never return or enter one. + if (entry.isSymbolicLink()) continue; + + const relativePath = join(currentPath.relativePath, entry.name); + const absolutePath = join(currentPath.absolutePath, entry.name); + const subIndexes = new Set(); + for (const index of branch.indexes) { + const part = branch.pattern[index]; + const nextIndex = index + 1; + const nextMatches = windowsGlobPartMatches(branch.pattern, nextIndex, entry.name); + + if (part === GLOBSTAR) { + let nextNonGlobIndex = nextIndex; + while (branch.pattern[nextNonGlobIndex] === GLOBSTAR) nextNonGlobIndex += 1; + const matchesDot = + entry.name.startsWith('.') && + windowsGlobPartMatches(branch.pattern, nextNonGlobIndex, entry.name); + if (entry.name.startsWith('.') && !matchesDot) continue; + + if (entry.isDirectory()) { + subIndexes.add(index); + } else if (index === last && emit(relativePath)) { + return files; + } + + if (nextMatches && nextIndex === last && !isLast) { + if (emit(relativePath)) return files; + } else if (nextMatches && entry.isDirectory()) { + subIndexes.add(index + 2); + } + if ((nextMatches || branch.pattern[0] === '.') && entry.isDirectory()) { + subIndexes.add(nextIndex); + } + } + + if (typeof part === 'string') { + if (windowsGlobPartMatches(branch.pattern, index, entry.name) && index !== last) { + subIndexes.add(nextIndex); + } else if ( + part === '.' && + windowsGlobPartMatches(branch.pattern, nextIndex, entry.name) + ) { + if (nextIndex === last) { + if (emit(relativePath)) return files; + } else { + subIndexes.add(nextIndex + 1); + } + } + } + + if (part instanceof RegExp && windowsGlobPartMatches(branch.pattern, index, entry.name)) { + if (index === last) { + if (emit(relativePath)) return files; + } else if (entry.isDirectory()) { + subIndexes.add(nextIndex); + } + } + } + + if (subIndexes.size > 0) { + addWindowsGlobSubpattern( + childPaths, + { + absolutePath, + relativePath, + isDirectory: entry.isDirectory(), + branches: [], + }, + { ...branch, indexes: [...subIndexes] }, + ); + } + } + } + for (const child of childPaths.values()) pending.push(child); + } + return files; +} + +function windowsGlobPatternIsLast(branch: WindowsGlobBranchState, isDirectory: boolean): boolean { + const last = branch.pattern.length - 1; + return ( + branch.indexes.includes(last) || + (branch.pattern[last] === '' && + isDirectory && + branch.indexes.includes(last - 1) && + branch.pattern.at(-2) === GLOBSTAR) + ); +} + +function windowsGlobPartMatches( + pattern: readonly WindowsGlobPatternPart[], + index: number, + component: string, +): boolean { + const part = pattern[index]; + if (part === GLOBSTAR) return true; + if (typeof part === 'string') return part.toLowerCase() === component.toLowerCase(); + if (part instanceof RegExp) { + part.lastIndex = 0; + return part.test(component); + } + return false; +} + +function addWindowsGlobSubpattern( + paths: Map, + path: Omit & { + readonly branches?: readonly WindowsGlobBranchState[]; + }, + branch: WindowsGlobBranchState, +): void { + const existing = paths.get(path.relativePath); + if (!existing) { + paths.set(path.relativePath, { ...path, branches: [branch] }); + return; + } + + const branchIndex = existing.branches.findIndex((candidate) => candidate.id === branch.id); + if (branchIndex === -1) { + paths.set(path.relativePath, { ...existing, branches: [...existing.branches, branch] }); + return; + } + + const branches = [...existing.branches]; + const previous = branches[branchIndex]; + branches[branchIndex] = { + ...previous, + indexes: [...new Set([...previous.indexes, ...branch.indexes])], + }; + paths.set(path.relativePath, { ...existing, branches }); +} + +async function readWindowsNonFollowingDirectory( + root: string, + path: string, + readDirectory?: (path: string) => Promise, +): Promise { + try { + return await (readDirectory ? readDirectory(path) : fs.readdir(path, { withFileTypes: true })); + } catch (error) { + if (path !== root && isNonFollowingPrunableError(error)) return undefined; + throw error; + } +} + +function isNonFollowingPrunableError(error: unknown): boolean { + return ['EACCES', 'ELOOP', 'ENOENT', 'ENOTDIR', 'EPERM'].includes(nodeErrorCode(error) ?? ''); +} + async function targetTypeOf(path: string): Promise { try { const metadata = await fs.stat(path); diff --git a/packages/runtime/src/filesystem-worker/sandbox-paths.ts b/packages/runtime/src/filesystem-worker/sandbox-paths.ts index 030e93b844..c0f2df47c8 100644 --- a/packages/runtime/src/filesystem-worker/sandbox-paths.ts +++ b/packages/runtime/src/filesystem-worker/sandbox-paths.ts @@ -35,13 +35,12 @@ import { * Inside the Windows AppContainer realpath is unavailable — both node's JS * implementation (lstat of every ancestor up to the volume root) and the * native one (GetFinalPathNameByHandle) are denied by the LowBox token. The - * Windows variant therefore resolves lexically and REJECTS requested reparse - * points instead of following them. That is sound because request paths are - * canonicalised by the client before launch, the broker rejects reparse-point - * roots and applies recursive grants with link traversal disabled, and the ACL - * grants themselves are the kernel-side enforcement: a nested link or a link - * created after grant time points at an ungranted target the worker cannot - * touch anyway. + * Windows variant therefore resolves lexically and REJECTS reparse points + * instead of following them. The broker rejects them for ordinary recursive + * roots. A read-only W1 Glob may opt into a partitioned root: directories on a + * reparse branch receive exact grants, clean siblings retain recursive grants, + * and the reparse entry and target receive no grant. The worker independently + * prunes those entries before Glob traversal; ACLs remain the kernel boundary. */ export interface SandboxPathApi { realpath(path: string): Promise; diff --git a/scripts/verify-windows-sandbox-e2e.mjs b/scripts/verify-windows-sandbox-e2e.mjs index 8969e5f288..f014485264 100644 --- a/scripts/verify-windows-sandbox-e2e.mjs +++ b/scripts/verify-windows-sandbox-e2e.mjs @@ -278,6 +278,35 @@ export async function verifyWindowsSandboxWorkerE2E(appDirectoryPath) { JSON.stringify(['root.ts', 'src/health.ts']), 'Sandboxed glob did not return exactly the safe project files beside a nested junction.', ); + const explicitJunctionGlob = await execute({ + kind: 'glob', + path: projectDirectory, + pattern: 'node_modules/@sunrioa/rin-sdk/**/*', + }); + assertCondition( + explicitJunctionGlob.kind === 'glob' && explicitJunctionGlob.files.length === 0, + 'Sandboxed glob traversed an explicitly named nested junction.', + ); + const broadJunctionGlob = await execute({ + kind: 'glob', + path: projectDirectory, + pattern: '**/*', + }); + const broadFiles = + broadJunctionGlob.kind === 'glob' + ? broadJunctionGlob.files.map((file) => file.replaceAll('\\', '/')) + : []; + assertCondition( + broadJunctionGlob.kind === 'glob' && + broadFiles.includes('root.ts') && + broadFiles.includes('src/health.ts') && + !broadFiles.some( + (file) => + file === 'node_modules/@sunrioa/rin-sdk' || + file.startsWith('node_modules/@sunrioa/rin-sdk/'), + ), + 'Sandboxed broad glob returned a nested junction or one of its descendants.', + ); // The sandbox preview does not expose Grep (no in-process substitute // preserves the ripgrep contract); the worker must fail closed. let grepUnavailable = false; From 637c7d03a2133b543d83f9fc39f44ded33362b0e Mon Sep 17 00:00:00 2001 From: ling Date: Thu, 27 Aug 2026 02:05:24 +0800 Subject: [PATCH 4/9] fix(windows): make ACL planner compile on Windows Generated-by: OpenAI Codex --- .../launcher/src/acl_ledger.rs | 25 +++++----- .../launcher/src/acl_ledger_tests.rs | 49 +++++++------------ .../launcher/src/protocol_tests.rs | 5 +- 3 files changed, 35 insertions(+), 44 deletions(-) diff --git a/experiments/windows-sandbox/launcher/src/acl_ledger.rs b/experiments/windows-sandbox/launcher/src/acl_ledger.rs index aef478ece9..1d352eec1c 100644 --- a/experiments/windows-sandbox/launcher/src/acl_ledger.rs +++ b/experiments/windows-sandbox/launcher/src/acl_ledger.rs @@ -405,9 +405,7 @@ pub(crate) fn collect_roots(request: &LaunchRequest) -> Result, if !contains_path(&request.read_roots, non_following_root) || contains_path(&request.exact_read_roots, non_following_root) { - return Err( - "nonFollowingReadRoot must name a declared recursive readRoot".to_owned(), - ); + return Err("nonFollowingReadRoot must name a declared recursive readRoot".to_owned()); } if !request.write_roots.is_empty() || !request.exact_write_roots.is_empty() { return Err("nonFollowingReadRoot requires a read-only launch".to_owned()); @@ -459,14 +457,17 @@ pub(crate) fn collect_roots(request: &LaunchRequest) -> Result, if metadata.is_dir() && (recursive_read || recursive_write) { reject_aliased_entries(Path::new(path))?; } - upsert_ledger_root(&mut roots, LedgerRoot { - path: path.clone(), - read: contains_path(&request.read_roots, path), - write: contains_path(&request.write_roots, path), - read_recursive: metadata.is_dir() && recursive_read, - write_recursive: metadata.is_dir() && recursive_write, - backup_path: None, - }); + upsert_ledger_root( + &mut roots, + LedgerRoot { + path: path.clone(), + read: contains_path(&request.read_roots, path), + write: contains_path(&request.write_roots, path), + read_recursive: metadata.is_dir() && recursive_read, + write_recursive: metadata.is_dir() && recursive_write, + backup_path: None, + }, + ); } Ok(roots) } @@ -614,7 +615,7 @@ fn plan_non_following_directory( entries.sort_by_key(|entry| entry.file_name()); let mut clean = true; - let mut directory_plans = Vec::new(); + let mut directory_plans: Vec = Vec::new(); for entry in entries { let child = entry.path(); let child_metadata = fs::symlink_metadata(&child) diff --git a/experiments/windows-sandbox/launcher/src/acl_ledger_tests.rs b/experiments/windows-sandbox/launcher/src/acl_ledger_tests.rs index 846be221ca..a167db3559 100644 --- a/experiments/windows-sandbox/launcher/src/acl_ledger_tests.rs +++ b/experiments/windows-sandbox/launcher/src/acl_ledger_tests.rs @@ -27,9 +27,10 @@ mod tests { use sha2::{Digest, Sha256}; use crate::acl_ledger::{ - LEDGER_VERSION, LaunchFailure, Ledger, LedgerRoot, collect_roots, recover_stale, + LEDGER_VERSION, LaunchFailure, Ledger, LedgerRoot, collect_roots, partition_non_following_read_root_with_limit, - partition_non_following_read_root_with_limits, with_acl_grants, write_ledger, + partition_non_following_read_root_with_limits, recover_stale, with_acl_grants, + write_ledger, }; use crate::protocol::{LaunchRequest, NetworkMode}; @@ -421,12 +422,7 @@ mod tests { fn clean_non_following_tree_compresses_to_one_recursive_root() { let fixture = Fixture::new("non-following-clean"); let root = fixture.target_str(); - let mut request = launch_request( - vec![root.clone()], - Vec::new(), - Vec::new(), - Vec::new(), - ); + let mut request = launch_request(vec![root.clone()], Vec::new(), Vec::new(), Vec::new()); request.non_following_read_root = Some(root.clone()); let roots = collect_roots(&request).expect("clean tree admits"); @@ -448,13 +444,9 @@ mod tests { fs::create_dir_all(&junction_parent).expect("create junction parent"); create_junction(&junction, &outside); let target = fixture.target_str(); - let raw_request = launch_request( - vec![target.clone()], - Vec::new(), - Vec::new(), - Vec::new(), - ); - let raw_error = collect_roots(&raw_request).expect_err("raw recursive root must stay strict"); + let raw_request = launch_request(vec![target.clone()], Vec::new(), Vec::new(), Vec::new()); + let raw_error = + collect_roots(&raw_request).expect_err("raw recursive root must stay strict"); assert!( raw_error.contains("reparse point"), "unexpected raw error: {raw_error}" @@ -485,8 +477,7 @@ mod tests { let junction = junction.to_string_lossy(); let outside = outside.to_string_lossy(); assert!(!roots.iter().any(|root| { - root.path.eq_ignore_ascii_case(&junction) - || root.path.eq_ignore_ascii_case(&outside) + root.path.eq_ignore_ascii_case(&junction) || root.path.eq_ignore_ascii_case(&outside) })); } @@ -497,12 +488,7 @@ mod tests { let junction = base.join("root-junction"); create_junction(&junction, &fixture.target); let root = junction.to_string_lossy().into_owned(); - let mut request = launch_request( - vec![root.clone()], - Vec::new(), - Vec::new(), - Vec::new(), - ); + let mut request = launch_request(vec![root.clone()], Vec::new(), Vec::new(), Vec::new()); request.non_following_read_root = Some(root); let error = collect_roots(&request).expect_err("reparse root must fail closed"); @@ -522,12 +508,7 @@ mod tests { fs::hard_link(&outside, fixture.target.join("child").join("linked.txt")) .expect("create hard link into tree"); let root = fixture.target_str(); - let mut request = launch_request( - vec![root.clone()], - Vec::new(), - Vec::new(), - Vec::new(), - ); + let mut request = launch_request(vec![root.clone()], Vec::new(), Vec::new(), Vec::new()); request.non_following_read_root = Some(root); let error = collect_roots(&request).expect_err("multi-link file must fail closed"); @@ -549,7 +530,10 @@ mod tests { let error = partition_non_following_read_root_with_limit(&fixture.target, 2) .expect_err("expanded grant plan must be bounded"); - assert!(error.contains("safe limit of 2"), "unexpected error: {error}"); + assert!( + error.contains("safe limit of 2"), + "unexpected error: {error}" + ); } #[test] @@ -576,7 +560,10 @@ mod tests { let error = partition_non_following_read_root_with_limit(&fixture.target, 0) .expect_err("even one clean recursive root must respect the grant limit"); - assert!(error.contains("safe limit of 0"), "unexpected error: {error}"); + assert!( + error.contains("safe limit of 0"), + "unexpected error: {error}" + ); } #[test] diff --git a/experiments/windows-sandbox/launcher/src/protocol_tests.rs b/experiments/windows-sandbox/launcher/src/protocol_tests.rs index 0fc8f62404..3fe234e8c5 100644 --- a/experiments/windows-sandbox/launcher/src/protocol_tests.rs +++ b/experiments/windows-sandbox/launcher/src/protocol_tests.rs @@ -138,7 +138,10 @@ mod tests { let original = launch_digest(&value.launch).expect("original digest"); value.launch.non_following_read_root = Some("C:\\work\\repo".to_owned()); - assert_ne!(launch_digest(&value.launch).expect("marked digest"), original); + assert_ne!( + launch_digest(&value.launch).expect("marked digest"), + original + ); } #[test] From 82c8766b776ba2300abce9807df5e188f4a8d4ee Mon Sep 17 00:00:00 2001 From: "yelong.hu" Date: Thu, 27 Aug 2026 11:26:08 +0800 Subject: [PATCH 5/9] fix(deps): sync brace-expansion notices Generated-by: OpenAI Codex --- apps/desktop/resources/licenses/npm/THIRD_PARTY_NOTICES.txt | 2 +- packages/cli/THIRD_PARTY_NOTICES.txt | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/desktop/resources/licenses/npm/THIRD_PARTY_NOTICES.txt b/apps/desktop/resources/licenses/npm/THIRD_PARTY_NOTICES.txt index e407640f1d..d33ab77767 100644 --- a/apps/desktop/resources/licenses/npm/THIRD_PARTY_NOTICES.txt +++ b/apps/desktop/resources/licenses/npm/THIRD_PARTY_NOTICES.txt @@ -4922,7 +4922,7 @@ OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. ================================================================================ -Package: brace-expansion@5.0.7 +Package: brace-expansion@5.0.9 Declared license: MIT Selected license: MIT Repository: git+https://github.com/juliangruber/brace-expansion.git diff --git a/packages/cli/THIRD_PARTY_NOTICES.txt b/packages/cli/THIRD_PARTY_NOTICES.txt index 45f99c4e07..36a1fea41c 100644 --- a/packages/cli/THIRD_PARTY_NOTICES.txt +++ b/packages/cli/THIRD_PARTY_NOTICES.txt @@ -2920,7 +2920,7 @@ OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. ================================================================================ -Package: brace-expansion@5.0.7 +Package: brace-expansion@5.0.9 Declared license: MIT Selected license: MIT Repository: git+https://github.com/juliangruber/brace-expansion.git From c1d365a9f328197d55af066db594447e1e99970e Mon Sep 17 00:00:00 2001 From: "yelong.hu" Date: Tue, 1 Sep 2026 16:54:33 +0800 Subject: [PATCH 6/9] fix(windows): harden non-following Glob admission Bind the unfollowed root identity, revalidate directory reads, and bound ACL planning by Glob traversal depth and directory work. Generated-by: OpenAI Codex --- docs/architecture/windows-sandbox-rfc-v1.md | 15 +- .../windows-sandbox-rfc-v1.zh-CN.md | 12 +- experiments/windows-sandbox/README.md | 9 +- .../launcher/src/acl_ledger.rs | 205 ++++++++++++++++-- .../launcher/src/acl_ledger_tests.rs | 58 ++++- .../windows-sandbox/launcher/src/protocol.rs | 29 +++ .../launcher/src/protocol_tests.rs | 34 +++ .../launcher/src/windows_launcher_tests.rs | 2 + .../filesystem-worker-client.test.ts | 56 ++++- .../filesystem-worker-windows-smoke.test.ts | 34 +++ .../src/__tests__/filesystem-worker.test.ts | 83 +++++++ .../__tests__/windows-sandbox-profile.test.ts | 25 ++- .../src/__tests__/windows-sandbox.test.ts | 8 +- .../runtime/src/filesystem-worker/client.ts | 53 +++-- .../src/filesystem-worker/operations.ts | 62 ++++-- .../filesystem-worker/windows-glob-pattern.ts | 52 +++++ packages/runtime/src/sandbox/types.ts | 12 +- .../runtime/src/sandbox/windows-profile.ts | 24 +- .../runtime/src/sandbox/windows-sandbox.ts | 12 +- scripts/verify-windows-sandbox-e2e.mjs | 31 ++- 20 files changed, 727 insertions(+), 89 deletions(-) create mode 100644 packages/runtime/src/filesystem-worker/windows-glob-pattern.ts diff --git a/docs/architecture/windows-sandbox-rfc-v1.md b/docs/architecture/windows-sandbox-rfc-v1.md index a9a86c6ae4..a937fb6dec 100644 --- a/docs/architecture/windows-sandbox-rfc-v1.md +++ b/docs/architecture/windows-sandbox-rfc-v1.md @@ -394,9 +394,16 @@ for the current launch. Before mutation it recursively rejects `FILE_ATTRIBUTE_R default. A manifest produced specifically for the read-only W1 Glob operation may mark its single recursive root as non-following: the broker then records an exact grant for directories containing a nested reparse entry, recursive grants for clean child directories, and no grant for the reparse -entry or its target. The marked root itself and every multi-hard-link file still fail closed. The -decomposition fails closed above 4,096 physical grants, 100,000 inspected filesystem entries, or 256 -nested directory levels below the root. The broker persists a versioned ledger with `create_new` and `sync_all`, and +entry or its target. The manifest binds both the canonical authority and the original final path +entry; before ACL mutation the broker opens both with `FILE_FLAG_OPEN_REPARSE_POINT`, rejects a root +reparse point, and requires matching volume/file identity. The worker also compares an opened +directory handle with non-following path metadata before and after each `readdir`, pruning a child +whose cached `Dirent` was replaced. A finite Glob pattern binds its maximum traversal depth, so a +root-only pattern receives one exact directory grant without scanning its children; GLOBSTAR keeps +the full decomposition. The marked root itself and every multi-hard-link file included in a +recursive grant still fail closed. Decomposition fails closed above 4,096 physical grants, 100,000 +directory/reparse planning entries, or 256 nested directory levels below the root. Ordinary file +count does not consume the directory planning budget. The broker persists a versioned ledger with `create_new` and `sync_all`, and reconciles every stale ledger before accepting a new request. A global kernel mutex covers only ledger/ACL mutation; each launch holds a separate request-specific kernel lease through child settlement, so recovery skips live ledgers while disjoint @@ -497,7 +504,7 @@ For the W1 preview, the packaged verifier maps the supported attack surface to e | Category | Packaged evidence | | --- | --- | -| Filesystem aliases | outside denial, raw recursive junction and multi-hard-link admission refusal, plus a product Glob that succeeds beside a nested junction without following it | +| Filesystem aliases | outside denial; raw recursive junction and multi-hard-link refusal; root-junction refusal; cached-directory replacement pruning; a bounded root-only Glob; and a product Glob that succeeds beside a nested junction without following it | | Network channels | TCP connect denial without network capabilities | | IPC | host named-pipe denial and an explicit inherited-handle list | | Descendants | child creation is denied fail-closed, or a created descendant retains the AppContainer token and kill-on-close Job | diff --git a/docs/architecture/windows-sandbox-rfc-v1.zh-CN.md b/docs/architecture/windows-sandbox-rfc-v1.zh-CN.md index e18be81a2c..1cc5a00244 100644 --- a/docs/architecture/windows-sandbox-rfc-v1.zh-CN.md +++ b/docs/architecture/windows-sandbox-rfc-v1.zh-CN.md @@ -242,8 +242,14 @@ sequenceDiagram native binary 只给当前 launch 允许的 root 授予其独立 SID。修改前默认递归拒绝 `FILE_ATTRIBUTE_REPARSE_POINT`。只有只读 W1 Glob 生成的 manifest 可以把它唯一的递归 root 标记为 不跟随:Broker 对含嵌套 reparse entry 的目录使用 exact grant,对干净子目录保留 recursive grant, -并且不给 reparse entry 或其 target 授权。被标记的 root 自身以及任何多硬链接文件仍然 fail closed。 -该分解在超过 4,096 个物理授权、100,000 个文件系统条目或根目录以下 256 层嵌套目录时 fail closed。 +并且不给 reparse entry 或其 target 授权。manifest 同时绑定 canonical 权限路径与 realpath 前的原始 +final entry;ACL 修改前,Broker 使用 `FILE_FLAG_OPEN_REPARSE_POINT` 打开两者,拒绝 root reparse, +并要求 volume/file identity 一致。worker 在每次 `readdir` 前后也会将已打开目录 handle 与不跟随的 +路径 metadata 比较,被替换的缓存 `Dirent` 会被剪枝。有限 Glob pattern 会绑定最大遍历深度,因此 +只匹配 root entry 的 pattern 只获得一个 exact 目录授权且完全不扫描子项;GLOBSTAR 继续使用完整分解。 +被标记的 root 自身以及 recursive grant 涵盖的任何多硬链接文件仍然 fail closed。该分解在超过 +4,096 个物理授权、100,000 个目录/reparse 规划条目或根目录以下 256 层嵌套目录时 fail closed; +普通文件数量不消耗目录规划额度。 随后用 `create_new` 和 `sync_all` 持久化版本化 ledger,并在接收新请求前 reconcile 全部遗留 ledger。正常结束先移除 SID ACE,再 删除 ledger。全局 kernel mutex 只覆盖 ledger/ACL 修改;每个 launch 在 child settlement 完成前持有独立的 request-specific kernel lease,因此 recovery 会跳过仍在使用的 ledger,同时不同 launch 仍可并发执行。 @@ -336,7 +342,7 @@ Windows sandbox job 必须运行真实 child-process 正反测试: | 类别 | 打包证据 | | --- | --- | -| 文件别名 | outside 拒绝、raw 递归 junction 与多硬链接准入拒绝,以及产品 Glob 在嵌套 junction 旁成功且不跟随它 | +| 文件别名 | outside 拒绝、raw 递归 junction 与多硬链接准入拒绝、root junction 拒绝、缓存目录替换剪枝、受限 root-only Glob,以及产品 Glob 在嵌套 junction 旁成功且不跟随它 | | 网络通道 | 无网络 capability 时拒绝 TCP connect | | IPC | 拒绝宿主 named pipe,并只继承显式 handle 列表 | | descendant | child 创建被 fail-closed 拒绝,或已创建 descendant 仍持有 AppContainer token 与 kill-on-close Job | diff --git a/experiments/windows-sandbox/README.md b/experiments/windows-sandbox/README.md index 324cfcd14c..7c6c5277f2 100644 --- a/experiments/windows-sandbox/README.md +++ b/experiments/windows-sandbox/README.md @@ -85,8 +85,13 @@ default, and grants that per-launch SID only the requested roots. The W1 filesystem worker can explicitly mark one read-only Glob root for non-following decomposition: nested reparse entries are omitted while clean child directories receive narrower recursive grants; the root itself and hard links remain -fail-closed. Planning is bounded to 4,096 physical grants, 100,000 inspected -filesystem entries, and 256 nested directory levels below the root. A short-lived global mutex +fail-closed. The manifest binds both the canonical authority and the original +unfollowed root entry; the broker opens both without following and requires the +same directory identity. Finite Glob patterns also bind their maximum traversal +depth, so a root-only pattern receives one exact directory grant without scanning +its children. Planning is bounded to 4,096 physical grants, 100,000 directory or +reparse entries, and 256 nested directory levels below the root; ordinary file +count does not exhaust the planning budget. A short-lived global mutex serializes ACL mutation, while a request-specific kernel lease distinguishes live ledgers from abandoned ones without serializing child execution. The smoke proves allowed read/write access, denial of a user-readable sibling file and diff --git a/experiments/windows-sandbox/launcher/src/acl_ledger.rs b/experiments/windows-sandbox/launcher/src/acl_ledger.rs index 1d352eec1c..9efd4d949e 100644 --- a/experiments/windows-sandbox/launcher/src/acl_ledger.rs +++ b/experiments/windows-sandbox/launcher/src/acl_ledger.rs @@ -41,9 +41,10 @@ use windows_sys::Win32::Security::{ OWNER_SECURITY_INFORMATION, PSECURITY_DESCRIPTOR, SECURITY_ATTRIBUTES, }; use windows_sys::Win32::Storage::FileSystem::{ - BY_HANDLE_FILE_INFORMATION, CreateFileW, FILE_ATTRIBUTE_REPARSE_POINT, - FILE_FLAG_BACKUP_SEMANTICS, FILE_FLAG_OPEN_REPARSE_POINT, FILE_SHARE_DELETE, FILE_SHARE_READ, - FILE_SHARE_WRITE, GetFileInformationByHandle, OPEN_EXISTING, + BY_HANDLE_FILE_INFORMATION, CreateFileW, FILE_ATTRIBUTE_DIRECTORY, + FILE_ATTRIBUTE_REPARSE_POINT, FILE_FLAG_BACKUP_SEMANTICS, FILE_FLAG_OPEN_REPARSE_POINT, + FILE_SHARE_DELETE, FILE_SHARE_READ, FILE_SHARE_WRITE, GetFileInformationByHandle, + OPEN_EXISTING, }; use windows_sys::Win32::System::Threading::{CreateMutexW, ReleaseMutex, WaitForSingleObject}; @@ -410,6 +411,15 @@ pub(crate) fn collect_roots(request: &LaunchRequest) -> Result, if !request.write_roots.is_empty() || !request.exact_write_roots.is_empty() { return Err("nonFollowingReadRoot requires a read-only launch".to_owned()); } + let source = request + .non_following_read_root_source + .as_deref() + .ok_or_else(|| "nonFollowingReadRoot requires nonFollowingReadRootSource".to_owned())?; + validate_non_following_read_root_source(Path::new(source), Path::new(non_following_root))?; + } else if request.non_following_read_root_source.is_some() { + return Err("nonFollowingReadRootSource requires nonFollowingReadRoot".to_owned()); + } else if request.non_following_read_root_max_depth.is_some() { + return Err("nonFollowingReadRootMaxDepth requires nonFollowingReadRoot".to_owned()); } for path in request.read_roots.iter().chain(&request.write_roots) { if request @@ -417,7 +427,12 @@ pub(crate) fn collect_roots(request: &LaunchRequest) -> Result, .as_deref() .is_some_and(|root| root.eq_ignore_ascii_case(path)) { - let partitioned = partition_non_following_read_root(Path::new(path))?; + let partitioned = partition_non_following_read_root( + Path::new(path), + request + .non_following_read_root_max_depth + .map(|depth| depth as usize), + )?; for root in partitioned { upsert_ledger_root(&mut roots, root); } @@ -495,12 +510,12 @@ struct DirectoryReadPlan { roots: Vec, } -struct NonFollowingScanBudget { +struct NonFollowingDirectoryBudget { remaining: usize, limit: usize, } -impl NonFollowingScanBudget { +impl NonFollowingDirectoryBudget { fn new(limit: usize) -> Self { Self { remaining: limit, @@ -511,7 +526,7 @@ impl NonFollowingScanBudget { fn consume(&mut self) -> Result<(), String> { if self.remaining == 0 { return Err(format!( - "nonFollowingReadRoot exceeds the safe scan limit of {} filesystem entries", + "nonFollowingReadRoot exceeds the safe directory scan limit of {} entries", self.limit )); } @@ -524,19 +539,29 @@ impl NonFollowingScanBudget { /// non-following operation enumerate ordinary entries without granting or /// traversing nested Windows reparse points. The root itself remains strict: /// a reparse root is rejected instead of silently changing its meaning. -fn partition_non_following_read_root(path: &Path) -> Result, String> { - partition_non_following_read_root_with_limit(path, MAX_NON_FOLLOWING_READ_GRANTS) +fn partition_non_following_read_root( + path: &Path, + traversal_depth: Option, +) -> Result, String> { + partition_non_following_read_root_with_options( + path, + MAX_NON_FOLLOWING_READ_GRANTS, + MAX_NON_FOLLOWING_READ_ENTRIES, + MAX_NON_FOLLOWING_READ_DEPTH, + traversal_depth, + ) } pub(crate) fn partition_non_following_read_root_with_limit( path: &Path, max_grants: usize, ) -> Result, String> { - partition_non_following_read_root_with_limits( + partition_non_following_read_root_with_options( path, max_grants, MAX_NON_FOLLOWING_READ_ENTRIES, MAX_NON_FOLLOWING_READ_DEPTH, + None, ) } @@ -545,6 +570,16 @@ pub(crate) fn partition_non_following_read_root_with_limits( max_grants: usize, max_entries: usize, max_depth: usize, +) -> Result, String> { + partition_non_following_read_root_with_options(path, max_grants, max_entries, max_depth, None) +} + +pub(crate) fn partition_non_following_read_root_with_options( + path: &Path, + max_grants: usize, + max_entries: usize, + max_depth: usize, + traversal_depth: Option, ) -> Result, String> { match fs::symlink_metadata(path) { Ok(metadata) => { @@ -569,17 +604,102 @@ pub(crate) fn partition_non_following_read_root_with_limits( )); } } - let mut scan_budget = NonFollowingScanBudget::new(max_entries); + if let Some(traversal_depth) = traversal_depth { + let mut scan_budget = NonFollowingDirectoryBudget::new(max_entries); + let roots = plan_bounded_non_following_directories( + path, + max_grants, + &mut scan_budget, + 0, + max_depth, + traversal_depth, + )?; + ensure_non_following_grant_limit(roots.len(), max_grants)?; + return Ok(roots); + } + let mut scan_budget = NonFollowingDirectoryBudget::new(max_entries); let roots = plan_non_following_directory(path, max_grants, &mut scan_budget, 0, max_depth)?.roots; ensure_non_following_grant_limit(roots.len(), max_grants)?; Ok(roots) } +fn plan_bounded_non_following_directories( + path: &Path, + max_grants: usize, + scan_budget: &mut NonFollowingDirectoryBudget, + depth: usize, + max_depth: usize, + remaining_depth: usize, +) -> Result, String> { + if depth > max_depth { + return Err(format!( + "nonFollowingReadRoot exceeds the safe nested-directory limit of {max_depth} below the root" + )); + } + let metadata = fs::symlink_metadata(path) + .map_err(|error| format!("inspect ACL root {} failed: {error}", path.display()))?; + if metadata.file_attributes() & FILE_ATTRIBUTE_REPARSE_POINT != 0 { + return Ok(Vec::new()); + } + if !metadata.is_dir() { + return Err(format!( + "expected a directory while partitioning ACL root: {}", + path.display() + )); + } + + let mut roots = vec![read_root(path, false)?]; + ensure_non_following_grant_limit(roots.len(), max_grants)?; + if remaining_depth == 0 { + return Ok(roots); + } + + let mut entries = Vec::new(); + for entry in fs::read_dir(path) + .map_err(|error| format!("scan ACL root {} failed: {error}", path.display()))? + { + let entry = + entry.map_err(|error| format!("scan ACL root {} failed: {error}", path.display()))?; + let file_type = entry.file_type().map_err(|error| { + format!( + "inspect ACL root {} failed: {error}", + entry.path().display() + ) + })?; + if !file_type.is_file() { + entries.push(entry); + } + } + entries.sort_by_key(|entry| entry.file_name()); + for entry in entries { + let child = entry.path(); + let child_metadata = fs::symlink_metadata(&child) + .map_err(|error| format!("inspect ACL root {} failed: {error}", child.display()))?; + if child_metadata.file_attributes() & FILE_ATTRIBUTE_REPARSE_POINT != 0 { + scan_budget.consume()?; + continue; + } + if child_metadata.is_dir() { + scan_budget.consume()?; + let child_roots = plan_bounded_non_following_directories( + &child, + max_grants, + scan_budget, + depth.saturating_add(1), + max_depth, + remaining_depth.saturating_sub(1), + )?; + append_partitioned_roots(&mut roots, child_roots, max_grants)?; + } + } + Ok(roots) +} + fn plan_non_following_directory( path: &Path, max_grants: usize, - scan_budget: &mut NonFollowingScanBudget, + scan_budget: &mut NonFollowingDirectoryBudget, depth: usize, max_depth: usize, ) -> Result { @@ -607,7 +727,6 @@ fn plan_non_following_directory( for entry in fs::read_dir(path) .map_err(|error| format!("scan ACL root {} failed: {error}", path.display()))? { - scan_budget.consume()?; entries.push( entry.map_err(|error| format!("scan ACL root {} failed: {error}", path.display()))?, ); @@ -621,6 +740,7 @@ fn plan_non_following_directory( let child_metadata = fs::symlink_metadata(&child) .map_err(|error| format!("inspect ACL root {} failed: {error}", child.display()))?; if child_metadata.file_attributes() & FILE_ATTRIBUTE_REPARSE_POINT != 0 { + scan_budget.consume()?; clean = false; let child_grants = directory_plans .iter() @@ -629,6 +749,7 @@ fn plan_non_following_directory( continue; } if child_metadata.is_dir() { + scan_budget.consume()?; let child_plan = plan_non_following_directory( &child, max_grants, @@ -740,6 +861,56 @@ fn reject_aliased_entries(path: &Path) -> Result { /// grant mutates belongs to the file object shared by every hard link, so a /// path-keyed admission that only sees one alias must not grant through it. fn reject_multi_link_file(path: &Path) -> Result<(), String> { + let information = path_entry_information(path)?; + if information.nNumberOfLinks > 1 { + return Err(format!( + "ACL root contains a multi-link file: {}", + path.display() + )); + } + Ok(()) +} + +fn validate_non_following_read_root_source( + source: &Path, + enforcement: &Path, +) -> Result<(), String> { + let source_information = path_entry_information(source)?; + if source_information.dwFileAttributes & FILE_ATTRIBUTE_REPARSE_POINT != 0 { + return Err(format!( + "nonFollowingReadRootSource contains a reparse point: {}", + source.display() + )); + } + if source_information.dwFileAttributes & FILE_ATTRIBUTE_DIRECTORY == 0 { + return Err(format!( + "nonFollowingReadRootSource must be a directory: {}", + source.display() + )); + } + + let enforcement_information = path_entry_information(enforcement)?; + if enforcement_information.dwFileAttributes & FILE_ATTRIBUTE_REPARSE_POINT != 0 + || enforcement_information.dwFileAttributes & FILE_ATTRIBUTE_DIRECTORY == 0 + { + return Err(format!( + "nonFollowingReadRoot must be an ordinary directory: {}", + enforcement.display() + )); + } + if source_information.dwVolumeSerialNumber != enforcement_information.dwVolumeSerialNumber + || source_information.nFileIndexHigh != enforcement_information.nFileIndexHigh + || source_information.nFileIndexLow != enforcement_information.nFileIndexLow + { + return Err(format!( + "nonFollowingReadRootSource does not identify nonFollowingReadRoot: {}", + source.display() + )); + } + Ok(()) +} + +fn path_entry_information(path: &Path) -> Result { let wide_path = wide(&path.to_string_lossy()); let handle = unsafe { CreateFileW( @@ -767,13 +938,7 @@ fn reject_multi_link_file(path: &Path) -> Result<(), String> { path.display() ))); } - if information.nNumberOfLinks > 1 { - return Err(format!( - "ACL root contains a multi-link file: {}", - path.display() - )); - } - Ok(()) + Ok(information) } pub(crate) fn write_ledger(path: &Path, ledger: &Ledger) -> Result<(), String> { diff --git a/experiments/windows-sandbox/launcher/src/acl_ledger_tests.rs b/experiments/windows-sandbox/launcher/src/acl_ledger_tests.rs index a167db3559..623fd08479 100644 --- a/experiments/windows-sandbox/launcher/src/acl_ledger_tests.rs +++ b/experiments/windows-sandbox/launcher/src/acl_ledger_tests.rs @@ -29,7 +29,8 @@ mod tests { use crate::acl_ledger::{ LEDGER_VERSION, LaunchFailure, Ledger, LedgerRoot, collect_roots, partition_non_following_read_root_with_limit, - partition_non_following_read_root_with_limits, recover_stale, with_acl_grants, + partition_non_following_read_root_with_limits, + partition_non_following_read_root_with_options, recover_stale, with_acl_grants, write_ledger, }; use crate::protocol::{LaunchRequest, NetworkMode}; @@ -341,6 +342,8 @@ mod tests { environment: BTreeMap::new(), timeout_ms: None, non_following_read_root: None, + non_following_read_root_source: None, + non_following_read_root_max_depth: None, } } @@ -424,6 +427,7 @@ mod tests { let root = fixture.target_str(); let mut request = launch_request(vec![root.clone()], Vec::new(), Vec::new(), Vec::new()); request.non_following_read_root = Some(root.clone()); + request.non_following_read_root_source = Some(root.clone()); let roots = collect_roots(&request).expect("clean tree admits"); @@ -453,6 +457,7 @@ mod tests { ); let mut request = raw_request; request.non_following_read_root = Some(target.clone()); + request.non_following_read_root_source = Some(target.clone()); let roots = collect_roots(&request).expect("partition non-following read root"); @@ -487,9 +492,11 @@ mod tests { let base = fixture.target.parent().expect("fixture base"); let junction = base.join("root-junction"); create_junction(&junction, &fixture.target); - let root = junction.to_string_lossy().into_owned(); + let root = fixture.target_str(); + let source = junction.to_string_lossy().into_owned(); let mut request = launch_request(vec![root.clone()], Vec::new(), Vec::new(), Vec::new()); request.non_following_read_root = Some(root); + request.non_following_read_root_source = Some(source); let error = collect_roots(&request).expect_err("reparse root must fail closed"); @@ -509,7 +516,8 @@ mod tests { .expect("create hard link into tree"); let root = fixture.target_str(); let mut request = launch_request(vec![root.clone()], Vec::new(), Vec::new(), Vec::new()); - request.non_following_read_root = Some(root); + request.non_following_read_root = Some(root.clone()); + request.non_following_read_root_source = Some(root); let error = collect_roots(&request).expect_err("multi-link file must fail closed"); @@ -540,19 +548,53 @@ mod tests { fn non_following_read_root_bounds_scan_work_before_planning_finishes() { let fixture = Fixture::new("non-following-scan-limit"); - let roots = partition_non_following_read_root_with_limits(&fixture.target, 4_096, 2, 256) - .expect("an exact scan-entry budget must admit the clean fixture"); + let roots = partition_non_following_read_root_with_limits(&fixture.target, 4_096, 1, 256) + .expect("an exact directory budget must admit the clean fixture"); assert_eq!(roots.len(), 1); - let error = partition_non_following_read_root_with_limits(&fixture.target, 4_096, 1, 256) - .expect_err("filesystem scan work must be bounded independently of final grants"); + let error = partition_non_following_read_root_with_limits(&fixture.target, 4_096, 0, 256) + .expect_err("directory scan work must be bounded independently of final grants"); assert!( - error.contains("safe scan limit of 1 filesystem entries"), + error.contains("safe directory scan limit of 0 entries"), "unexpected error: {error}" ); } + #[test] + fn ordinary_files_do_not_exhaust_the_directory_scan_budget() { + let fixture = Fixture::new("non-following-large-file-tree"); + fs::remove_dir_all(fixture.target.join("child")).expect("remove fixture directory"); + for name in ["main.go", "peer-a.txt", "peer-b.txt"] { + fs::write(fixture.target.join(name), name).expect("seed ordinary file"); + } + + // A zero directory budget admits any number of ordinary single-link + // files. This is the regression for roots with >100k file entries: + // file count no longer decides whether admission is available. + let roots = partition_non_following_read_root_with_limits(&fixture.target, 4_096, 0, 256) + .expect("ordinary files must not consume the directory budget"); + + assert_eq!(roots.len(), 1); + assert!(roots[0].read_recursive); + } + + #[test] + fn root_only_glob_skips_entry_scan_for_arbitrarily_large_trees() { + let fixture = Fixture::new("non-following-root-only"); + + // A zero scan budget proves that admission does not enumerate even + // the fixture's existing children. The same constant work therefore + // applies when a root-only pattern such as `main.go` has >100k peers. + let roots = + partition_non_following_read_root_with_options(&fixture.target, 4_096, 0, 256, Some(0)) + .expect("root-only Glob must not scan child entries"); + + assert_eq!(roots.len(), 1); + assert!(!roots[0].read_recursive); + assert!(roots[0].path.eq_ignore_ascii_case(&fixture.target_str())); + } + #[test] fn non_following_read_root_enforces_zero_grant_limit() { let fixture = Fixture::new("non-following-zero-grants"); diff --git a/experiments/windows-sandbox/launcher/src/protocol.rs b/experiments/windows-sandbox/launcher/src/protocol.rs index 5a36163d1d..45051d07a5 100644 --- a/experiments/windows-sandbox/launcher/src/protocol.rs +++ b/experiments/windows-sandbox/launcher/src/protocol.rs @@ -48,11 +48,21 @@ pub struct LaunchRequest { /// reparse points. The broker may decompose it into narrower ACL grants. #[serde(default, skip_serializing_if = "Option::is_none")] pub non_following_read_root: Option, + /// Original final path entry before client realpath canonicalization. The + /// broker opens this entry without following and requires it to identify + /// the same ordinary directory as `non_following_read_root`. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub non_following_read_root_source: Option, + /// Static maximum directory depth needed by a finite Glob pattern. An + /// absent value means GLOBSTAR traversal is unbounded. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub non_following_read_root_max_depth: Option, } pub const MIN_LAUNCH_TIMEOUT_MS: u64 = 1_000; pub const MAX_LAUNCH_TIMEOUT_MS: u64 = 600_000; pub const DEFAULT_LAUNCH_TIMEOUT_MS: u64 = 30_000; +pub const MAX_NON_FOLLOWING_READ_ROOT_DEPTH: u32 = 256; /// `request_id` reserved for the internal Windows readiness probe. Its own /// AppContainer profile/SID is derived from this exact identity, and @@ -146,6 +156,25 @@ impl LaunchRequest { if !self.write_roots.is_empty() || !self.exact_write_roots.is_empty() { return Err("nonFollowingReadRoot requires a read-only launch".to_owned()); } + let source = self + .non_following_read_root_source + .as_deref() + .ok_or_else(|| { + "nonFollowingReadRoot requires nonFollowingReadRootSource".to_owned() + })?; + validate_path(source, "nonFollowingReadRootSource")?; + if self + .non_following_read_root_max_depth + .is_some_and(|depth| depth > MAX_NON_FOLLOWING_READ_ROOT_DEPTH) + { + return Err(format!( + "nonFollowingReadRootMaxDepth must not exceed {MAX_NON_FOLLOWING_READ_ROOT_DEPTH}" + )); + } + } else if self.non_following_read_root_source.is_some() { + return Err("nonFollowingReadRootSource requires nonFollowingReadRoot".to_owned()); + } else if self.non_following_read_root_max_depth.is_some() { + return Err("nonFollowingReadRootMaxDepth requires nonFollowingReadRoot".to_owned()); } if let Some(timeout_ms) = self.timeout_ms { if !(MIN_LAUNCH_TIMEOUT_MS..=MAX_LAUNCH_TIMEOUT_MS).contains(&timeout_ms) { diff --git a/experiments/windows-sandbox/launcher/src/protocol_tests.rs b/experiments/windows-sandbox/launcher/src/protocol_tests.rs index 3fe234e8c5..b51c2991e7 100644 --- a/experiments/windows-sandbox/launcher/src/protocol_tests.rs +++ b/experiments/windows-sandbox/launcher/src/protocol_tests.rs @@ -90,6 +90,8 @@ mod tests { let value = request(); assert!(value.launch.timeout_ms.is_none()); assert!(value.launch.non_following_read_root.is_none()); + assert!(value.launch.non_following_read_root_source.is_none()); + assert!(value.launch.non_following_read_root_max_depth.is_none()); let serialized = serde_json::to_string(&value.launch).expect("serialize launch"); assert!(!serialized.contains("timeoutMs")); assert!(!serialized.contains("nonFollowingReadRoot")); @@ -107,6 +109,8 @@ mod tests { let mut value = request(); value.launch.read_roots = vec![root.clone()]; value.launch.non_following_read_root = Some(root.clone()); + value.launch.non_following_read_root_source = Some(root.clone()); + value.launch.non_following_read_root_max_depth = Some(0); assert!(value.launch.validate().is_ok()); let mut missing = value.launch.clone(); @@ -129,6 +133,34 @@ mod tests { writable.validate().unwrap_err(), "nonFollowingReadRoot requires a read-only launch" ); + + let mut missing_source = value.launch.clone(); + missing_source.non_following_read_root_source = None; + assert_eq!( + missing_source.validate().unwrap_err(), + "nonFollowingReadRoot requires nonFollowingReadRootSource" + ); + + let mut source_only = request().launch; + source_only.non_following_read_root_source = Some(root.clone()); + assert_eq!( + source_only.validate().unwrap_err(), + "nonFollowingReadRootSource requires nonFollowingReadRoot" + ); + + let mut depth_only = request().launch; + depth_only.non_following_read_root_max_depth = Some(0); + assert_eq!( + depth_only.validate().unwrap_err(), + "nonFollowingReadRootMaxDepth requires nonFollowingReadRoot" + ); + + let mut excessive_depth = value.launch.clone(); + excessive_depth.non_following_read_root_max_depth = Some(257); + assert_eq!( + excessive_depth.validate().unwrap_err(), + "nonFollowingReadRootMaxDepth must not exceed 256" + ); } #[test] @@ -137,6 +169,8 @@ mod tests { value.launch.read_roots = vec!["C:\\work\\repo".to_owned()]; let original = launch_digest(&value.launch).expect("original digest"); value.launch.non_following_read_root = Some("C:\\work\\repo".to_owned()); + value.launch.non_following_read_root_source = Some("C:\\work\\repo".to_owned()); + value.launch.non_following_read_root_max_depth = Some(0); assert_ne!( launch_digest(&value.launch).expect("marked digest"), diff --git a/experiments/windows-sandbox/launcher/src/windows_launcher_tests.rs b/experiments/windows-sandbox/launcher/src/windows_launcher_tests.rs index b29e72f9b1..dcd84e5332 100644 --- a/experiments/windows-sandbox/launcher/src/windows_launcher_tests.rs +++ b/experiments/windows-sandbox/launcher/src/windows_launcher_tests.rs @@ -43,6 +43,8 @@ mod tests { environment: BTreeMap::new(), timeout_ms: None, non_following_read_root: None, + non_following_read_root_source: None, + non_following_read_root_max_depth: None, } } diff --git a/packages/runtime/src/__tests__/filesystem-worker-client.test.ts b/packages/runtime/src/__tests__/filesystem-worker-client.test.ts index dbc148c967..d2ee9e6124 100644 --- a/packages/runtime/src/__tests__/filesystem-worker-client.test.ts +++ b/packages/runtime/src/__tests__/filesystem-worker-client.test.ts @@ -57,6 +57,7 @@ import { type FilesystemWorkerRequest, type FilesystemWorkerResult, } from '../filesystem-worker/protocol.js'; +import { windowsGlobTraversalDepth } from '../filesystem-worker/windows-glob-pattern.js'; import { LinuxBubblewrapBackend } from '../sandbox/linux-sandbox.js'; import { MacosSeatbeltBackend } from '../sandbox/macos-seatbelt.js'; import { SandboxManager } from '../sandbox/sandbox-manager.js'; @@ -77,6 +78,16 @@ test('Read image payloads fit within the filesystem worker response limit', () = assert.ok(base64Bytes + 1024 < FILESYSTEM_WORKER_MAX_RESPONSE_BYTES); }); +test('derives a conservative finite Windows Glob traversal depth', () => { + assert.equal(windowsGlobTraversalDepth('main.go'), 0); + assert.equal(windowsGlobTraversalDepth('*'), 0); + assert.equal(windowsGlobTraversalDepth('src/*.ts'), 1); + assert.equal(windowsGlobTraversalDepth('./src/main.ts'), 1); + assert.equal(windowsGlobTraversalDepth('packages/*/main.ts'), 2); + assert.equal(windowsGlobTraversalDepth('{src,docs}/*.md'), 1); + assert.equal(windowsGlobTraversalDepth('src/**/*.ts'), undefined); +}); + describe('filesystem worker client permission snapshots', () => { test('authorizes delete against the symlink entry', async () => { const workspace = await temporaryDirectory('maka-worker-client-delete-link-'); @@ -499,7 +510,50 @@ describe('filesystem worker Windows Glob path context', () => { expectedIdentity: 'unchecked', }); - assert.equal(transforms[0]?.command.pathContext.windowsNonFollowingReadRoot, workspace); + assert.deepEqual(transforms[0]?.command.pathContext.windowsNonFollowingReadRoot, { + enforcementPath: workspace, + sourcePath: workspace, + }); + }); + + test('preserves the un-followed Glob root beside its canonical authority', async () => { + const workspace = await temporaryDirectory('maka-windows-worker-glob-root-link-'); + const target = join(workspace, 'target'); + const rootLink = join(workspace, 'root-link'); + await mkdir(target); + await symlink(target, rootLink, process.platform === 'win32' ? 'junction' : 'dir'); + const { client, transforms, requests } = fakeClient({ platform: 'win32' }); + + await client.execute({ + operation: { kind: 'glob', path: rootLink, pattern: '**/*.ts' }, + cwd: workspace, + mode: 'ask', + expectedIdentity: 'unchecked', + }); + + assert.equal(requests[0]?.operation.path, target); + assert.deepEqual(transforms[0]?.command.pathContext.windowsNonFollowingReadRoot, { + enforcementPath: target, + sourcePath: rootLink, + }); + }); + + test('bounds broker planning for a root-only Glob pattern', async () => { + const workspace = await temporaryDirectory('maka-windows-worker-glob-bounded-'); + const { client, transforms } = fakeClient({ platform: 'win32' }); + + await client.execute({ + operation: { kind: 'glob', path: workspace, pattern: 'main.go', limit: 1 }, + cwd: workspace, + mode: 'ask', + expectedIdentity: 'unchecked', + }); + + assert.deepEqual(transforms[0]?.command.pathContext.windowsNonFollowingReadRoot, { + enforcementPath: workspace, + sourcePath: workspace, + maxDepth: 0, + }); }); test('does not mark other operations or a non-Windows Glob', async () => { diff --git a/packages/runtime/src/__tests__/filesystem-worker-windows-smoke.test.ts b/packages/runtime/src/__tests__/filesystem-worker-windows-smoke.test.ts index d9ec1d5afa..98c7e51798 100644 --- a/packages/runtime/src/__tests__/filesystem-worker-windows-smoke.test.ts +++ b/packages/runtime/src/__tests__/filesystem-worker-windows-smoke.test.ts @@ -259,6 +259,40 @@ describe('Windows filesystem worker smoke', { skip: !enabled }, () => { await readFile(join(outside, 'secret.ts'), 'utf8'), 'export const secret = true;\n', ); + + // A root-only pattern carries maxDepth=0, so broker admission grants only + // the project directory and does not scan its children at all. Nested + // junctions and arbitrarily many unrelated entries cannot make it fail. + const rootOnly = await client.execute({ + operation: { kind: 'glob', path: project, pattern: 'root.ts', limit: 1 }, + cwd: workspace, + mode: 'ask', + expectedIdentity: 'unchecked', + }); + assert.deepEqual(rootOnly, { kind: 'glob', files: ['root.ts'] }); + }); + + test('rejects a requested Glob root that is itself a junction', async () => { + const target = join(workspace, 'root-junction-target'); + const rootJunction = join(workspace, 'root-junction'); + await mkdir(target); + await writeFile(join(target, 'secret.ts'), 'secret', 'utf8'); + await symlink(target, rootJunction, 'junction'); + + await assert.rejects( + client.execute({ + operation: { kind: 'glob', path: rootJunction, pattern: '**/*.ts' }, + cwd: workspace, + mode: 'ask', + expectedIdentity: 'unchecked', + }), + (error: unknown) => + error instanceof FilesystemWorkerClientError && + error.reason === 'spawn_failed' && + /nonFollowingReadRootSource.*reparse point/iu.test(error.message), + ); + + assert.equal(await readFile(join(target, 'secret.ts'), 'utf8'), 'secret'); }); test('fails closed for unapproved outside paths', async () => { diff --git a/packages/runtime/src/__tests__/filesystem-worker.test.ts b/packages/runtime/src/__tests__/filesystem-worker.test.ts index 33f2096d7f..366200143b 100644 --- a/packages/runtime/src/__tests__/filesystem-worker.test.ts +++ b/packages/runtime/src/__tests__/filesystem-worker.test.ts @@ -18,6 +18,7 @@ */ import { strict as assert } from 'node:assert'; +import type { Dirent } from 'node:fs'; import { glob as nodeGlob, lstat, @@ -296,6 +297,80 @@ describe('filesystem worker operations', () => { ]); }); + test('prunes a directory replaced by a junction after its parent Dirent was cached', async () => { + const root = await temporaryDirectory('maka-worker-glob-replacement-'); + const outside = await temporaryDirectory('maka-worker-glob-replacement-outside-'); + const child = join(root, 'queued-child'); + await mkdir(child); + await writeFile(join(child, 'safe.ts'), 'safe', 'utf8'); + await writeFile(join(outside, 'secret.ts'), 'secret', 'utf8'); + const visitedDirectories: string[] = []; + let replaced = false; + + const response = await executeFilesystemWorkerRequest( + await requestFor( + { kind: 'glob', cwd: root, path: root, pattern: '**/*.ts' }, + { + enforcementPath: root, + access: 'read', + scope: 'subtree', + targetType: 'directory', + }, + ), + { + windowsSandboxed: true, + windowsGlobReadDirectory: async (path) => { + visitedDirectories.push(path); + const entries = await readdir(path, { withFileTypes: true }); + if (path === root && !replaced) { + replaced = true; + await rm(child, { recursive: true }); + await symlink(outside, child, process.platform === 'win32' ? 'junction' : 'dir'); + } + return entries; + }, + }, + ); + + assert.equal(response.ok, true); + if (response.ok) assert.deepEqual(response.result, { kind: 'glob', files: [] }); + assert.equal(replaced, true); + assert.equal(visitedDirectories.includes(child), false); + }); + + test('returns a bounded root-only match beside more than 100k ordinary entries', async () => { + const root = await temporaryDirectory('maka-worker-glob-large-root-'); + const entries = Array.from({ length: 100_001 }, (_, index) => + fakeWindowsDirent(`peer-${index}.txt`), + ); + entries.push(fakeWindowsDirent('nested-junction', true), fakeWindowsDirent('main.go')); + const visitedDirectories: string[] = []; + + const response = await executeFilesystemWorkerRequest( + await requestFor( + { kind: 'glob', cwd: root, path: root, pattern: 'main.go', limit: 1 }, + { + enforcementPath: root, + access: 'read', + scope: 'subtree', + targetType: 'directory', + }, + ), + { + windowsSandboxed: true, + windowsGlobReadDirectory: async (path) => { + visitedDirectories.push(path); + if (path !== root) throw new Error(`unexpected directory read: ${path}`); + return entries; + }, + }, + ); + + assert.equal(response.ok, true); + if (response.ok) assert.deepEqual(response.result, { kind: 'glob', files: ['main.go'] }); + assert.deepEqual(visitedDirectories, [root]); + }); + test('preserves Windows Glob matching semantics in the non-following walker', async () => { const root = await temporaryDirectory('maka-worker-glob-semantics-'); await mkdir(join(root, 'src', 'nested'), { recursive: true }); @@ -841,6 +916,14 @@ describe('filesystem worker operations', () => { }); }); +function fakeWindowsDirent(name: string, symbolicLink = false): Dirent { + return { + name, + isDirectory: () => false, + isSymbolicLink: () => symbolicLink, + } as unknown as Dirent; +} + async function requestFor( operation: FilesystemWorkerOperation, expectedTarget: Omit, diff --git a/packages/runtime/src/__tests__/windows-sandbox-profile.test.ts b/packages/runtime/src/__tests__/windows-sandbox-profile.test.ts index e1d8faff1e..9cbe49b7f9 100644 --- a/packages/runtime/src/__tests__/windows-sandbox-profile.test.ts +++ b/packages/runtime/src/__tests__/windows-sandbox-profile.test.ts @@ -130,11 +130,17 @@ test('admits one recursive read root for non-following broker decomposition', () }, }; const input = command(recursiveRead); - input.pathContext.windowsNonFollowingReadRoot = String.raw`C:\work\repo`; + input.pathContext.windowsNonFollowingReadRoot = { + enforcementPath: String.raw`C:\work\repo`, + sourcePath: String.raw`C:\work\repo`, + }; const policy = compileWindowsSandboxPolicy(input); - assert.equal(policy.nonFollowingReadRoot, String.raw`C:\work\repo`); + assert.deepEqual(policy.nonFollowingReadRoot, { + enforcementPath: String.raw`C:\work\repo`, + sourcePath: String.raw`C:\work\repo`, + }); }); test('rejects invalid non-following read-root combinations', () => { @@ -146,7 +152,10 @@ test('rejects invalid non-following read-root combinations', () => { }, }; const outside = command(recursiveRead); - outside.pathContext.windowsNonFollowingReadRoot = String.raw`C:\outside`; + outside.pathContext.windowsNonFollowingReadRoot = { + enforcementPath: String.raw`C:\outside`, + sourcePath: String.raw`C:\outside`, + }; assert.throws(() => compileWindowsSandboxPolicy(outside), /not a declared read root/); const exact: PermissionProfileManaged = { @@ -157,11 +166,17 @@ test('rejects invalid non-following read-root combinations', () => { }, }; const exactInput = command(exact); - exactInput.pathContext.windowsNonFollowingReadRoot = String.raw`C:\work\repo`; + exactInput.pathContext.windowsNonFollowingReadRoot = { + enforcementPath: String.raw`C:\work\repo`, + sourcePath: String.raw`C:\work\repo`, + }; assert.throws(() => compileWindowsSandboxPolicy(exactInput), /must be recursive/); const writable = command(createWorkspaceWritePermissionProfile()); - writable.pathContext.windowsNonFollowingReadRoot = String.raw`C:\work\repo`; + writable.pathContext.windowsNonFollowingReadRoot = { + enforcementPath: String.raw`C:\work\repo`, + sourcePath: String.raw`C:\work\repo`, + }; assert.throws(() => compileWindowsSandboxPolicy(writable), /read-only sandbox profile/); }); diff --git a/packages/runtime/src/__tests__/windows-sandbox.test.ts b/packages/runtime/src/__tests__/windows-sandbox.test.ts index a06fbfe7fe..bf11046b9b 100644 --- a/packages/runtime/src/__tests__/windows-sandbox.test.ts +++ b/packages/runtime/src/__tests__/windows-sandbox.test.ts @@ -165,7 +165,11 @@ test('binds a non-following read root into the broker manifest digest', () => { }, pathContext: { workspaceRoots: [String.raw`C:\work\repo`], - windowsNonFollowingReadRoot: String.raw`C:\work\repo`, + windowsNonFollowingReadRoot: { + enforcementPath: String.raw`C:\work\repo`, + sourcePath: String.raw`C:\work\repo`, + maxDepth: 0, + }, }, }, }); @@ -173,6 +177,8 @@ test('binds a non-following read root into the broker manifest digest', () => { assert.equal(result.ok, true); const launch = written?.launch; assert.equal(launch?.nonFollowingReadRoot, String.raw`C:\work\repo`); + assert.equal(launch?.nonFollowingReadRootSource, String.raw`C:\work\repo`); + assert.equal(launch?.nonFollowingReadRootMaxDepth, 0); if (!written || !launch) return; assert.equal( written.profileDigest, diff --git a/packages/runtime/src/filesystem-worker/client.ts b/packages/runtime/src/filesystem-worker/client.ts index 6a28ec6214..3bf9b5d9eb 100644 --- a/packages/runtime/src/filesystem-worker/client.ts +++ b/packages/runtime/src/filesystem-worker/client.ts @@ -52,6 +52,7 @@ import { type FilesystemWorkerResult, type FilesystemWorkerTarget, } from './protocol.js'; +import { windowsGlobTraversalDepth } from './windows-glob-pattern.js'; export const FILESYSTEM_WORKER_MAX_REQUEST_BYTES = 16 * 1024 * 1024; @@ -236,22 +237,24 @@ export class FilesystemWorkerClient { // The wire identity contract is derived below from the caller's explicit // expectedIdentity; the normalised target itself has no identity field, // so the declared type omits it. - const target: Omit & { writableAncestor?: string } = - await (entryMode - ? normalizeDirectoryEntryTarget({ - path: parsedOperation.data.path, - access, - cwd: canonicalCwd, - }) - : normalizeSandboxBoundaryPath({ - path: parsedOperation.data.path, - access, - scope: operationScope(parsedOperation.data.kind), - cwd: canonicalCwd, - }) - ).catch(() => { - throw clientError('invalid_operation', 'validation', requestId); - }); + const target: Omit & { + writableAncestor?: string; + displayPath?: string; + } = await (entryMode + ? normalizeDirectoryEntryTarget({ + path: parsedOperation.data.path, + access, + cwd: canonicalCwd, + }) + : normalizeSandboxBoundaryPath({ + path: parsedOperation.data.path, + access, + scope: operationScope(parsedOperation.data.kind), + cwd: canonicalCwd, + }) + ).catch(() => { + throw clientError('invalid_operation', 'validation', requestId); + }); // The identity was captured by the caller at lock acquisition (T0) and // passed in as expectedIdentity. Do NOT re-derive it here: re-deriving at // this point (after the lock is held) would sample the post-queue inode, @@ -381,7 +384,23 @@ export class FilesystemWorkerClient { operation.kind === 'glob' && target.scope === 'subtree' && target.targetType === 'directory' - ? target.enforcementPath + ? (() => { + const sourcePath = target.displayPath; + if (!sourcePath) { + throw clientError( + 'invalid_operation', + 'validation', + requestId, + 'Windows Glob requires an un-followed source root.', + ); + } + const maxDepth = windowsGlobTraversalDepth(operation.pattern); + return { + enforcementPath: target.enforcementPath, + sourcePath, + ...(maxDepth !== undefined ? { maxDepth } : {}), + }; + })() : undefined; const pinnedTarget = platform === 'linux' && !entryMode && target.targetType !== 'missing' diff --git a/packages/runtime/src/filesystem-worker/operations.ts b/packages/runtime/src/filesystem-worker/operations.ts index de77c5e7ec..2d4ca94abc 100644 --- a/packages/runtime/src/filesystem-worker/operations.ts +++ b/packages/runtime/src/filesystem-worker/operations.ts @@ -18,10 +18,10 @@ */ import { spawn } from 'node:child_process'; -import { promises as fs, type Dirent } from 'node:fs'; +import { promises as fs, type BigIntStats, type Dirent } from 'node:fs'; import { glob as nodeGlob } from 'node:fs/promises'; import { dirname, isAbsolute, join, parse, resolve } from 'node:path'; -import { GLOBSTAR, Minimatch, type MinimatchOptions } from 'minimatch'; +import { GLOBSTAR } from 'minimatch'; import { isPathInside } from '../path-containment.js'; import { sandboxPathApi } from './sandbox-paths.js'; import { sandboxBoundaryExpansionAllowsPath } from '@maka/core/sandbox-boundary'; @@ -54,6 +54,7 @@ import { type FilesystemWorkerTarget, } from './protocol.js'; import { isLikelySandboxDenial } from '../sandbox/detect.js'; +import { compileWindowsGlobPattern, type WindowsGlobPatternPart } from './windows-glob-pattern.js'; // Canonicalisation must match the sandbox the worker runs in: realpath-based // on POSIX, lexical + reparse-rejecting inside the Windows AppContainer where @@ -61,15 +62,6 @@ import { isLikelySandboxDenial } from '../sandbox/detect.js'; const { realpath, realpathAllowMissing, resolveCanonicalDirectoryEntryTarget } = sandboxPathApi(); const DEFAULT_GLOB_LIMIT = 200; -const WINDOWS_GLOB_MATCH_OPTIONS = { - nocase: true, - windowsPathsNoEscape: true, - nonegate: true, - nocomment: true, - optimizationLevel: 2, - platform: 'win32', - nocaseMagicOnly: true, -} satisfies MinimatchOptions; const MAX_GREP_OUTPUT_BYTES = 8 * 1024 * 1024; const MAX_GREP_STDERR_BYTES = 16 * 1024; @@ -675,8 +667,6 @@ function assertContainedGlobPattern(pattern: string): void { } } -type WindowsGlobPatternPart = string | RegExp | typeof GLOBSTAR; - interface WindowsGlobBranchState { readonly id: number; readonly pattern: readonly WindowsGlobPatternPart[]; @@ -696,10 +686,9 @@ async function windowsNonFollowingGlob( limit: number, readDirectory?: (path: string) => Promise, ): Promise { - const matcher = new Minimatch(pattern, WINDOWS_GLOB_MATCH_OPTIONS); - const initialBranches = matcher.set.map((compiled, id) => ({ + const initialBranches = compileWindowsGlobPattern(pattern).map((compiled, id) => ({ id, - pattern: compiled as WindowsGlobPatternPart[], + pattern: compiled, indexes: [0], })); const files: string[] = []; @@ -911,14 +900,53 @@ async function readWindowsNonFollowingDirectory( path: string, readDirectory?: (path: string) => Promise, ): Promise { + let handle: Awaited> | undefined; try { - return await (readDirectory ? readDirectory(path) : fs.readdir(path, { withFileTypes: true })); + const beforeOpen = await fs.lstat(path, { bigint: true }); + if (!isOrdinaryDirectory(beforeOpen)) return changedWindowsGlobDirectory(root, path); + + // A parent Dirent is only a hint. Keep a directory handle open while the + // path is read and compare its identity before and after readdir so a + // directory-to-junction replacement cannot contribute target entries. + handle = await fs.open(path, 'r'); + const opened = await handle.stat({ bigint: true }); + if (!sameDirectoryIdentity(beforeOpen, opened)) { + return changedWindowsGlobDirectory(root, path); + } + const beforeRead = await fs.lstat(path, { bigint: true }); + if (!isOrdinaryDirectory(beforeRead) || !sameDirectoryIdentity(opened, beforeRead)) { + return changedWindowsGlobDirectory(root, path); + } + + const entries = await (readDirectory + ? readDirectory(path) + : fs.readdir(path, { withFileTypes: true })); + const afterRead = await fs.lstat(path, { bigint: true }); + if (!isOrdinaryDirectory(afterRead) || !sameDirectoryIdentity(opened, afterRead)) { + return changedWindowsGlobDirectory(root, path); + } + return entries; } catch (error) { if (path !== root && isNonFollowingPrunableError(error)) return undefined; throw error; + } finally { + await handle?.close(); } } +function isOrdinaryDirectory(metadata: BigIntStats): boolean { + return metadata.isDirectory() && !metadata.isSymbolicLink(); +} + +function sameDirectoryIdentity(left: BigIntStats, right: BigIntStats): boolean { + return left.dev === right.dev && left.ino === right.ino; +} + +function changedWindowsGlobDirectory(root: string, path: string): undefined { + if (path !== root) return undefined; + throw operationError('path_changed', 'Windows Glob root changed during directory traversal.'); +} + function isNonFollowingPrunableError(error: unknown): boolean { return ['EACCES', 'ELOOP', 'ENOENT', 'ENOTDIR', 'EPERM'].includes(nodeErrorCode(error) ?? ''); } diff --git a/packages/runtime/src/filesystem-worker/windows-glob-pattern.ts b/packages/runtime/src/filesystem-worker/windows-glob-pattern.ts new file mode 100644 index 0000000000..9cf2798983 --- /dev/null +++ b/packages/runtime/src/filesystem-worker/windows-glob-pattern.ts @@ -0,0 +1,52 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { GLOBSTAR, Minimatch, type MinimatchOptions } from 'minimatch'; + +export type WindowsGlobPatternPart = string | RegExp | typeof GLOBSTAR; + +const WINDOWS_GLOB_MATCH_OPTIONS = { + nocase: true, + windowsPathsNoEscape: true, + nonegate: true, + nocomment: true, + optimizationLevel: 2, + platform: 'win32', + nocaseMagicOnly: true, +} satisfies MinimatchOptions; + +export function compileWindowsGlobPattern(pattern: string): WindowsGlobPatternPart[][] { + return new Minimatch(pattern, WINDOWS_GLOB_MATCH_OPTIONS).set as WindowsGlobPatternPart[][]; +} + +/** + * Returns the deepest directory that a non-globstar pattern can enter. An + * absent result means the pattern contains GLOBSTAR and therefore has no + * static traversal bound. Dot path components advance matcher state without + * entering another directory and are excluded from the depth. + */ +export function windowsGlobTraversalDepth(pattern: string): number | undefined { + let maximum = 0; + for (const branch of compileWindowsGlobPattern(pattern)) { + if (branch.includes(GLOBSTAR)) return undefined; + const depth = branch.slice(0, -1).filter((part) => part !== '' && part !== '.').length; + maximum = Math.max(maximum, depth); + } + return maximum; +} diff --git a/packages/runtime/src/sandbox/types.ts b/packages/runtime/src/sandbox/types.ts index da5ce376e4..ef42a9567e 100644 --- a/packages/runtime/src/sandbox/types.ts +++ b/packages/runtime/src/sandbox/types.ts @@ -57,10 +57,16 @@ export interface SandboxPathContext { unavailableProfilePaths?: readonly string[]; /** * Windows-only recursive read root whose operation contract does not - * follow reparse points. The broker may split this root into narrower - * physical ACL grants while omitting nested reparse entries. + * follow reparse points. `enforcementPath` remains the canonical authority, + * while `sourcePath` preserves the final path entry before realpath so the + * broker can reject a root junction before granting ACLs. */ - windowsNonFollowingReadRoot?: string; + windowsNonFollowingReadRoot?: { + readonly enforcementPath: string; + readonly sourcePath: string; + /** Omitted when a GLOBSTAR makes traversal depth unbounded. */ + readonly maxDepth?: number; + }; /** Profile roots pinned by open host descriptors until sandbox launch. */ pinnedProfilePaths?: readonly { path: string; diff --git a/packages/runtime/src/sandbox/windows-profile.ts b/packages/runtime/src/sandbox/windows-profile.ts index b08c81840b..198f94c5dd 100644 --- a/packages/runtime/src/sandbox/windows-profile.ts +++ b/packages/runtime/src/sandbox/windows-profile.ts @@ -31,7 +31,11 @@ export interface WindowsSandboxPolicy { readonly exactWriteRoots: readonly string[]; readonly network: 'restricted' | 'enabled'; readonly environment: Readonly>; - readonly nonFollowingReadRoot?: string; + readonly nonFollowingReadRoot?: { + readonly enforcementPath: string; + readonly sourcePath: string; + readonly maxDepth?: number; + }; } export function compileWindowsSandboxPolicy(command: SandboxCommand): WindowsSandboxPolicy { @@ -99,20 +103,28 @@ export function compileWindowsSandboxPolicy(command: SandboxCommand): WindowsSan } const nonFollowingReadRoot = pathContext.windowsNonFollowingReadRoot - ? canonicalWindowsPath(pathContext.windowsNonFollowingReadRoot) + ? { + enforcementPath: canonicalWindowsPath( + pathContext.windowsNonFollowingReadRoot.enforcementPath, + ), + sourcePath: canonicalWindowsPath(pathContext.windowsNonFollowingReadRoot.sourcePath), + ...(pathContext.windowsNonFollowingReadRoot.maxDepth !== undefined + ? { maxDepth: pathContext.windowsNonFollowingReadRoot.maxDepth } + : {}), + } : undefined; if (nonFollowingReadRoot && writeRoots.length > 0) { throw new Error('Windows non-following read roots require a read-only sandbox profile.'); } if (nonFollowingReadRoot) { - if (!containsPath(readRoots, nonFollowingReadRoot)) { + if (!containsPath(readRoots, nonFollowingReadRoot.enforcementPath)) { throw new Error( - `Windows non-following root is not a declared read root: ${nonFollowingReadRoot}`, + `Windows non-following root is not a declared read root: ${nonFollowingReadRoot.enforcementPath}`, ); } - if (containsPath(exactReadRoots, nonFollowingReadRoot)) { + if (containsPath(exactReadRoots, nonFollowingReadRoot.enforcementPath)) { throw new Error( - `Windows non-following root must be recursive, not exact: ${nonFollowingReadRoot}`, + `Windows non-following root must be recursive, not exact: ${nonFollowingReadRoot.enforcementPath}`, ); } } diff --git a/packages/runtime/src/sandbox/windows-sandbox.ts b/packages/runtime/src/sandbox/windows-sandbox.ts index bf36be094b..65496cfab8 100644 --- a/packages/runtime/src/sandbox/windows-sandbox.ts +++ b/packages/runtime/src/sandbox/windows-sandbox.ts @@ -68,6 +68,10 @@ export interface WindowsBrokerManifest { readonly timeoutMs: number; /** Optional W1 Glob admission mode; serialized last only when requested. */ readonly nonFollowingReadRoot?: string; + /** Original final path entry, checked without following before ACL grants. */ + readonly nonFollowingReadRootSource?: string; + /** Finite directory traversal depth; absent for GLOBSTAR patterns. */ + readonly nonFollowingReadRootMaxDepth?: number; }; } @@ -184,7 +188,13 @@ export class WindowsBrokerSandboxBackend implements SandboxBackend { }), timeoutMs: this.options.timeoutMs ?? DEFAULT_WINDOWS_BROKER_TIMEOUT_MS, ...(plan.policy.nonFollowingReadRoot - ? { nonFollowingReadRoot: plan.policy.nonFollowingReadRoot } + ? { + nonFollowingReadRoot: plan.policy.nonFollowingReadRoot.enforcementPath, + nonFollowingReadRootSource: plan.policy.nonFollowingReadRoot.sourcePath, + ...(plan.policy.nonFollowingReadRoot.maxDepth !== undefined + ? { nonFollowingReadRootMaxDepth: plan.policy.nonFollowingReadRoot.maxDepth } + : {}), + } : {}), }; manifestPath = this.options.writeManifest({ diff --git a/scripts/verify-windows-sandbox-e2e.mjs b/scripts/verify-windows-sandbox-e2e.mjs index 0af11b4227..f66227572c 100644 --- a/scripts/verify-windows-sandbox-e2e.mjs +++ b/scripts/verify-windows-sandbox-e2e.mjs @@ -42,7 +42,12 @@ const execFileAsync = promisify(execFile); export const WINDOWS_SANDBOX_PHASE4_MATRIX = Object.freeze([ { category: 'filesystem_aliases', - evidence: ['junction admission', 'multi-hard-link admission', 'outside-root read denial'], + evidence: [ + 'nested and root junction admission', + 'multi-hard-link admission', + 'bounded root-only Glob admission', + 'outside-root read denial', + ], }, { category: 'network_channels', @@ -307,6 +312,30 @@ export async function verifyWindowsSandboxWorkerE2E(appDirectoryPath) { ), 'Sandboxed broad glob returned a nested junction or one of its descendants.', ); + const rootOnlyGlob = await execute({ + kind: 'glob', + path: projectDirectory, + pattern: 'root.ts', + limit: 1, + }); + assertCondition( + rootOnlyGlob.kind === 'glob' && JSON.stringify(rootOnlyGlob.files) === '["root.ts"]', + 'Sandboxed root-only glob did not use bounded directory admission.', + ); + + const rootJunction = join(workspace, 'root-junction'); + await symlink(projectDirectory, rootJunction, 'junction'); + let rootJunctionDenied = false; + try { + await execute({ kind: 'glob', path: rootJunction, pattern: '**/*.ts' }); + } catch (error) { + rootJunctionDenied = + error instanceof FilesystemWorkerClientError && + error.reason === 'spawn_failed' && + /nonFollowingReadRootSource.*reparse point/iu.test(error.message); + } + assertCondition(rootJunctionDenied, 'Sandboxed glob admitted a root junction.'); + // The sandbox preview does not expose Grep (no in-process substitute // preserves the ripgrep contract); the worker must fail closed. let grepUnavailable = false; From 2b1cf5a1c999e1d1a7669c77056ba78060123777 Mon Sep 17 00:00:00 2001 From: "yelong.hu" Date: Tue, 1 Sep 2026 16:59:00 +0800 Subject: [PATCH 7/9] test(windows): retain non-following root fixture Generated-by: OpenAI Codex --- experiments/windows-sandbox/launcher/src/protocol_tests.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/experiments/windows-sandbox/launcher/src/protocol_tests.rs b/experiments/windows-sandbox/launcher/src/protocol_tests.rs index b51c2991e7..7a2363220b 100644 --- a/experiments/windows-sandbox/launcher/src/protocol_tests.rs +++ b/experiments/windows-sandbox/launcher/src/protocol_tests.rs @@ -128,7 +128,7 @@ mod tests { ); let mut writable = value.launch.clone(); - writable.write_roots = vec![root]; + writable.write_roots = vec![root.clone()]; assert_eq!( writable.validate().unwrap_err(), "nonFollowingReadRoot requires a read-only launch" From 9e7649ce48bbe32fe0fdc377d93b5c4f0f61e3a1 Mon Sep 17 00:00:00 2001 From: "yelong.hu" Date: Tue, 1 Sep 2026 17:03:14 +0800 Subject: [PATCH 8/9] test(windows): tamper complete Glob admission policy Generated-by: OpenAI Codex --- .../windows-sandbox/launcher/src/broker_authorization_tests.rs | 2 ++ 1 file changed, 2 insertions(+) diff --git a/experiments/windows-sandbox/launcher/src/broker_authorization_tests.rs b/experiments/windows-sandbox/launcher/src/broker_authorization_tests.rs index 721aaaf9a1..1e83c06a1f 100644 --- a/experiments/windows-sandbox/launcher/src/broker_authorization_tests.rs +++ b/experiments/windows-sandbox/launcher/src/broker_authorization_tests.rs @@ -113,6 +113,8 @@ mod tests { value.profile_digest = launch_digest(&value.launch).expect("launch digest"); let approved = value.profile_digest.clone(); value.launch.non_following_read_root = Some("C:\\work".to_owned()); + value.launch.non_following_read_root_source = Some("C:\\work".to_owned()); + value.launch.non_following_read_root_max_depth = Some(0); let mut authorizer = BrokerAuthorizer::new([approved]); assert_eq!( From da2612040f866ef72aec0be7b76c7585e492f959 Mon Sep 17 00:00:00 2001 From: "yelong.hu" Date: Tue, 1 Sep 2026 17:09:18 +0800 Subject: [PATCH 9/9] test(windows): accept local broker rejection channel Generated-by: OpenAI Codex --- .../src/__tests__/filesystem-worker-windows-smoke.test.ts | 3 ++- scripts/verify-windows-sandbox-e2e.mjs | 3 ++- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/packages/runtime/src/__tests__/filesystem-worker-windows-smoke.test.ts b/packages/runtime/src/__tests__/filesystem-worker-windows-smoke.test.ts index 98c7e51798..8b1c81575a 100644 --- a/packages/runtime/src/__tests__/filesystem-worker-windows-smoke.test.ts +++ b/packages/runtime/src/__tests__/filesystem-worker-windows-smoke.test.ts @@ -288,7 +288,8 @@ describe('Windows filesystem worker smoke', { skip: !enabled }, () => { }), (error: unknown) => error instanceof FilesystemWorkerClientError && - error.reason === 'spawn_failed' && + error.stage === 'launch' && + ['spawn_failed', 'worker_crashed'].includes(error.reason) && /nonFollowingReadRootSource.*reparse point/iu.test(error.message), ); diff --git a/scripts/verify-windows-sandbox-e2e.mjs b/scripts/verify-windows-sandbox-e2e.mjs index f66227572c..a77981f5af 100644 --- a/scripts/verify-windows-sandbox-e2e.mjs +++ b/scripts/verify-windows-sandbox-e2e.mjs @@ -331,7 +331,8 @@ export async function verifyWindowsSandboxWorkerE2E(appDirectoryPath) { } catch (error) { rootJunctionDenied = error instanceof FilesystemWorkerClientError && - error.reason === 'spawn_failed' && + error.stage === 'launch' && + ['spawn_failed', 'worker_crashed'].includes(error.reason) && /nonFollowingReadRootSource.*reparse point/iu.test(error.message); } assertCondition(rootJunctionDenied, 'Sandboxed glob admitted a root junction.');