diff --git a/apps/desktop/resources/licenses/npm/THIRD_PARTY_NOTICES.txt b/apps/desktop/resources/licenses/npm/THIRD_PARTY_NOTICES.txt index 71ec5e9c50..d33ab77767 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.9 +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 afd0433c98..a9a86c6ae4 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); @@ -374,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 @@ -386,9 +390,15 @@ 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 -a new request. A global kernel mutex covers only ledger/ACL mutation; each launch holds a separate +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 +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. @@ -487,7 +497,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..e18be81a2c 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); @@ -226,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 @@ -237,8 +239,12 @@ 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。 +该分解在超过 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 仍可并发执行。 @@ -330,7 +336,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..324cfcd14c 100644 --- a/experiments/windows-sandbox/README.md +++ b/experiments/windows-sandbox/README.md @@ -80,8 +80,13 @@ 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. 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 607ced68a7..1d352eec1c 100644 --- a/experiments/windows-sandbox/launcher/src/acl_ledger.rs +++ b/experiments/windows-sandbox/launcher/src/acl_ledger.rs @@ -53,6 +53,9 @@ 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 @@ -398,11 +401,26 @@ 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 @@ -439,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))?; } - roots.push(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) } @@ -455,6 +476,240 @@ fn contains_path(paths: &[String], path: &str) -> bool { paths.iter().any(|entry| entry.eq_ignore_ascii_case(path)) } +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, +} + +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: +/// 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> { + 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) => { + 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() + )); + } + } + 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 { + 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 = Vec::new(); + 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()))?, + ); + } + entries.sort_by_key(|entry| entry.file_name()); + + let mut clean = true; + let mut directory_plans: Vec = 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; + 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, + 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() { + 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 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() + .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 diff --git a/experiments/windows-sandbox/launcher/src/acl_ledger_tests.rs b/experiments/windows-sandbox/launcher/src/acl_ledger_tests.rs index e03b99532a..a167db3559 100644 --- a/experiments/windows-sandbox/launcher/src/acl_ledger_tests.rs +++ b/experiments/windows-sandbox/launcher/src/acl_ledger_tests.rs @@ -27,8 +27,10 @@ mod tests { use sha2::{Digest, Sha256}; use crate::acl_ledger::{ - LEDGER_VERSION, LaunchFailure, Ledger, LedgerRoot, collect_roots, recover_stale, - with_acl_grants, write_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, + write_ledger, }; use crate::protocol::{LaunchRequest, NetworkMode}; @@ -338,6 +340,7 @@ mod tests { network: NetworkMode::Restricted, environment: BTreeMap::new(), timeout_ms: None, + non_following_read_root: None, } } @@ -415,6 +418,182 @@ mod tests { assert!(!roots[0].read_recursive); } + #[test] + 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()); + request.non_following_read_root = Some(root.clone()); + + 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!( + 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!( + roots + .iter() + .any(|root| root.path.eq_ignore_ascii_case(&child) && root.read_recursive) + ); + 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 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::new()); + 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 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("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("multi-link file must fail closed"); + + 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}" + ); + } + + #[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"]) + .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 { std::env::temp_dir().join("maka-sandbox-acl-ledgers") } 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..3fe234e8c5 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,49 @@ 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/package-lock.json b/package-lock.json index e2e04cc77d..7c03ecef74 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.9", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.9.tgz", "integrity": "sha512-ScQ4IuvIEF1TMlP7Zt+vjJ//9zlPb2SDcxWxM3bk8s6t6GGdJ7KO1dCcTidOPJKePW30LE/2cT7wCyPho9/Wxg==", - "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" @@ -13969,6 +13966,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..36a1fea41c 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.9 +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-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 1bb6dd2ff1..d9ec1d5afa 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,70 @@ describe('Windows filesystem worker smoke', { skip: !enabled }, () => { ); }); + 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.equal(result.kind, 'glob'); + if (result.kind === 'glob') { + assert.deepEqual(result.files.map((file) => file.replaceAll('\\', '/')).sort(), [ + 'root.ts', + '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', + ); + }); + test('fails closed for unapproved outside paths', async () => { await assert.rejects( client.execute({ 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/__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..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 })) { - 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 715e80b137..c0f2df47c8 100644 --- a/packages/runtime/src/filesystem-worker/sandbox-paths.ts +++ b/packages/runtime/src/filesystem-worker/sandbox-paths.ts @@ -36,11 +36,11 @@ import { * 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. + * 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/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..f014485264 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,54 @@ 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.', + ); + 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.