diff --git a/crates/agent-gateway/web/src/agent-ui-adapters/sandboxCapability.ts b/crates/agent-gateway/web/src/agent-ui-adapters/sandboxCapability.ts index 7457b0a1e..d42d28f19 100644 --- a/crates/agent-gateway/web/src/agent-ui-adapters/sandboxCapability.ts +++ b/crates/agent-gateway/web/src/agent-ui-adapters/sandboxCapability.ts @@ -2,14 +2,11 @@ export type SandboxCapability = { supported: boolean; mechanism: string; platform: string; + /** 是否支持断网变体(sandboxOffline)。Windows 免管理员方案恒为 false。 */ + network_control: boolean; reason?: string; }; -/** WebUI:沙箱在桌面端执行,浏览器的 OS 不代表执行端平台;null = 未知,显示通用文案。 */ -export function inferSandboxPlatform(): "macos" | "linux" | "windows" | null { - return null; -} - /** WebUI:沙箱在桌面端执行,浏览器侧无从探测;null 表示能力未知(由桌面端裁决)。 */ export function useSandboxCapability(): SandboxCapability | null { return null; diff --git a/crates/agent-gui/src-tauri/Cargo.toml b/crates/agent-gui/src-tauri/Cargo.toml index e8c6629f6..0964df995 100644 --- a/crates/agent-gui/src-tauri/Cargo.toml +++ b/crates/agent-gui/src-tauri/Cargo.toml @@ -75,4 +75,13 @@ chardetng = "0.1.17" objc2-app-kit = { version = "0.3", default-features = false, features = ["std", "NSButton", "NSControl", "NSView", "NSWindow"] } [target.'cfg(windows)'.dependencies] -windows-sys = { version = "0.61", features = ["Win32_Foundation", "Win32_Storage_FileSystem", "Win32_System_Threading"] } +windows-sys = { version = "0.61", features = [ + "Win32_Foundation", + "Win32_Storage_FileSystem", + "Win32_Security", + "Win32_Security_Authorization", + "Win32_System_Threading", + "Win32_System_JobObjects", + "Win32_System_Console", + "Win32_System_Environment", +] } diff --git a/crates/agent-gui/src-tauri/src/lib.rs b/crates/agent-gui/src-tauri/src/lib.rs index 281ed6339..55eba39a9 100644 --- a/crates/agent-gui/src-tauri/src/lib.rs +++ b/crates/agent-gui/src-tauri/src/lib.rs @@ -662,6 +662,11 @@ fn configure_windows_window_chrome(app: &tauri::App) -> tauri::Result<()> { #[cfg_attr(mobile, tauri::mobile_entry_point)] pub fn run() { + // 最早期钩子:若本进程是 Windows 沙箱的自我再执行启动器(__sandbox_exec), + // 就在此建立受限令牌并运行真实命令,以其退出码退出——绝不继续初始化 Tauri。 + // 非 Windows 平台为空操作。 + runtime::windows_sandbox::run_sandbox_launcher_if_requested(); + let automation_store = Arc::new( services::automation::AutomationStore::open() .expect("failed to initialize LiveAgent automation store"), diff --git a/crates/agent-gui/src-tauri/src/runtime/mod.rs b/crates/agent-gui/src-tauri/src/runtime/mod.rs index 98999d172..40447ffbc 100644 --- a/crates/agent-gui/src-tauri/src/runtime/mod.rs +++ b/crates/agent-gui/src-tauri/src/runtime/mod.rs @@ -9,3 +9,4 @@ pub mod shell_runner; pub mod shell_session; pub mod task_runner; pub mod terminal; +pub mod windows_sandbox; diff --git a/crates/agent-gui/src-tauri/src/runtime/sandbox.rs b/crates/agent-gui/src-tauri/src/runtime/sandbox.rs index 024f49826..7e577a84b 100644 --- a/crates/agent-gui/src-tauri/src/runtime/sandbox.rs +++ b/crates/agent-gui/src-tauri/src/runtime/sandbox.rs @@ -1,15 +1,213 @@ //! OS 级沙箱(沙箱模式 v1):模型驱动的 Bash / ManagedProcess 在生成子进程前 //! 由平台原生机制包裹——macOS 走 Seatbelt(/usr/bin/sandbox-exec),Linux 走 -//! bubblewrap(bwrap),Windows 暂不支持(受限令牌 + Job Object + WFP 路线待实现)。 +//! bubblewrap(bwrap),Windows 走受限令牌(CreateRestrictedToken WRITE_RESTRICTED +//! + 工作区继承写 ACE + Job Object,免管理员/免 UAC)。 //! //! 语义为 workspace-write:读默认放行(工具链/依赖散布全盘,default-deny 不现实), //! 写仅限工作区根 + 临时目录,敏感目录(~/.ssh、应用配置库等)读写全掩蔽,网络可 //! 整体关断。fail-closed:沙箱被请求而平台机制不可用时直接报错,绝不静默降级为 //! 无沙箱执行。 +//! +//! Windows 平台限制(免管理员方案的固有边界,见 memory windows-sandbox-facts): +//! WRITE_RESTRICTED 只围栏“写”,读无法在无管理员下掩蔽敏感目录;断网只能靠 +//! AppContainer 而它会连带默认拒读、破坏工具链。故 Windows 上 sandbox 仅提供写 +//! 围栏,sandboxOffline(断网)不可用 —— `network_control=false`,前端据此禁用, +//! `wrap_command` 对 `!allow_network` 直接 fail-closed 报错。 use serde::Serialize; use std::path::{Path, PathBuf}; +/// 自我再执行启动器子命令标记:Windows `wrap_command` 把 (program, args) 包成 +/// `current_exe __sandbox_exec --write-root -- `; +/// 进程启动最早期 `windows_sandbox::run_sandbox_launcher_if_requested` 识别它, +/// 建受限令牌后 `CreateProcessAsUserW` 真实命令。非 Windows 平台不产生该标记。 +pub(crate) const SANDBOX_EXEC_SUBCOMMAND: &str = "__sandbox_exec"; + +/// 启动器解析后的调用信息(纯逻辑,跨平台可测)。 +#[cfg_attr(not(windows), allow(dead_code))] +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) struct LauncherInvocation { + pub write_root: PathBuf, + pub program: PathBuf, + pub args: Vec, +} + +/// 构造传给自我再执行启动器的参数向量(含子命令标记,作为 argv[1])。 +/// 形如 `[__sandbox_exec, --write-root, , --, , ]`。 +#[cfg_attr(not(windows), allow(dead_code))] +pub(crate) fn build_launcher_args(write_root: &Path, program: &Path, args: &[String]) -> Vec { + let mut out = vec![ + SANDBOX_EXEC_SUBCOMMAND.to_string(), + "--write-root".to_string(), + write_root.to_string_lossy().into_owned(), + "--".to_string(), + program.to_string_lossy().into_owned(), + ]; + out.extend(args.iter().cloned()); + out +} + +/// 解析启动器 payload(子命令标记之后的部分):`--write-root -- [args...]`。 +#[cfg_attr(not(windows), allow(dead_code))] +pub(crate) fn parse_launcher_args(payload: &[String]) -> Result { + let mut it = payload.iter(); + let mut write_root: Option = None; + let mut program: Option = None; + let mut rest: Vec = Vec::new(); + while let Some(tok) = it.next() { + match tok.as_str() { + "--write-root" => { + let value = it + .next() + .ok_or_else(|| "--write-root requires a value".to_string())?; + write_root = Some(PathBuf::from(value)); + } + "--" => { + program = it.next().map(PathBuf::from); + rest = it.cloned().collect(); + break; + } + other => return Err(format!("unexpected launcher argument: {other}")), + } + } + let write_root = write_root.ok_or_else(|| "missing --write-root".to_string())?; + let program = program.ok_or_else(|| "missing program after `--`".to_string())?; + Ok(LauncherInvocation { + write_root, + program, + args: rest, + }) +} + +/// 由工作区规范路径确定性推导合成 SID(Codex 形式 `S-1-5-21-{4×u32}`)。 +/// 稳定 + 无状态:同一路径永远得同一 SID —— 遗留的继承 ACE 在下次运行仍精确匹配, +/// 无需持久化。用稳定的 FNV-1a(不用 DefaultHasher,其算法跨版本不保证稳定)。 +/// Windows 路径大小写不敏感,先小写化再哈希,`C:\Foo` 与 `c:\foo` 得同一 SID。 +/// 边角:Rust 的 Unicode 小写化与 Windows 的 upcase 折叠(如 dotted/dotless I、ß) +/// 不完全一致,非 ASCII 工作区路径的两种大小写可能得不同 SID,导致遗留继承 ACE 不匹配 +/// → 写被拒。这是 fail-closed(功能受限,非逃逸),ASCII 路径不受影响。 +#[cfg_attr(not(windows), allow(dead_code))] +pub(crate) fn synthetic_workspace_sid(write_root: &Path) -> String { + fn fnv1a64(bytes: &[u8]) -> u64 { + let mut hash: u64 = 0xcbf2_9ce4_8422_2325; + for &b in bytes { + hash ^= b as u64; + hash = hash.wrapping_mul(0x0000_0100_0000_01b3); + } + hash + } + let canonical = write_root.to_string_lossy().to_lowercase(); + let h1 = fnv1a64(canonical.as_bytes()); + // 二次哈希掺入盐,得到独立的低 64 位,凑满 4×u32 子权限。 + let mut salted = canonical.into_bytes(); + salted.push(0); + salted.extend_from_slice(b"liveagent-sandbox"); + let h2 = fnv1a64(&salted); + let a = (h1 >> 32) as u32; + let b = h1 as u32; + let c = (h2 >> 32) as u32; + let d = h2 as u32; + format!("S-1-5-21-{a}-{b}-{c}-{d}") +} + +/// 按 Windows(CommandLineToArgvW)规则拼装命令行,并以 NUL 结尾成 UTF-16。 +/// 算法逐字复刻 Rust 标准库 `make_command_line`/`append_arg`,以保证受限令牌下 +/// `CreateProcessAsUserW` 的子进程收到与非沙箱 `std::process::Command` 完全一致的 +/// argv —— 行为对齐,不引入解析差异。纯逻辑,跨平台可测。 +#[cfg_attr(not(windows), allow(dead_code))] +pub(crate) fn build_command_line(program: &str, args: &[String]) -> Vec { + fn append_arg(cmd: &mut Vec, arg: &str) { + let arg: Vec = arg.encode_utf16().collect(); + let space = u16::from(b' '); + let tab = u16::from(b'\t'); + let quote = u16::from(b'"'); + let backslash = u16::from(b'\\'); + let needs_quote = arg.is_empty() || arg.iter().any(|&c| c == space || c == tab); + if needs_quote { + cmd.push(quote); + } + let mut backslashes: usize = 0; + for &w in &arg { + if w == backslash { + backslashes += 1; + } else { + if w == quote { + // 把 " 之前的反斜杠翻倍,再补一个,最后加转义的 "。 + for _ in 0..=backslashes { + cmd.push(backslash); + } + } + backslashes = 0; + } + cmd.push(w); + } + if needs_quote { + for _ in 0..backslashes { + cmd.push(backslash); + } + cmd.push(quote); + } + } + + let mut cmd: Vec = Vec::new(); + append_arg(&mut cmd, program); + for a in args { + cmd.push(u16::from(b' ')); + append_arg(&mut cmd, a); + } + cmd.push(0); + cmd +} + +/// 把裸程序名解析成 PATH 中的绝对路径(Windows 语义:`;` 分隔、套用 PATHEXT), +/// **只搜索 PATH 里的绝对目录,绝不搜索当前/工作目录**。 +/// +/// 缘由:`CreateProcessAsUserW` 的 `lpApplicationName` 若是“部分名”,Win32 只用当前 +/// 盘符+当前目录补全且**不查 PATH**(见 CreateProcess 文档)。而沙箱启动器的 cwd 就是 +/// 工作区(模型可写),裸名 `cmd.exe` 会在工作区里被补全:轻则找不到而整体失败,重则 +/// 命中模型投毒的同名二进制并被当作 shell 执行。故这里预解析成系统 shell 的绝对路径, +/// 剔除 PATH 里的相对项(含 `"."`),即便用户 PATH 带 `.` 也不会落到工作区。 +/// +/// 绝对路径入参原样返回。纯逻辑;`is_file` 谓词注入以便跨平台单测(Windows 路径语义 +/// 由 Windows 编译+真机验证,`is_absolute`/`join` 在本机按 Unix 规则)。 +#[cfg_attr(not(windows), allow(dead_code))] +pub(crate) fn resolve_program_in_path( + program: &Path, + path_env: &str, + pathext: &str, + is_file: &dyn Fn(&Path) -> bool, +) -> Option { + if program.is_absolute() { + return Some(program.to_path_buf()); + } + let name = program.as_os_str(); + // 候选扩展名:先原样(""),再逐个 PATHEXT 项(裸名 pwsh → pwsh.EXE)。 + let mut exts: Vec = vec![String::new()]; + exts.extend( + pathext + .split(';') + .map(str::trim) + .filter(|e| !e.is_empty()) + .map(str::to_string), + ); + for dir in path_env.split(';').map(str::trim) { + let dir_path = Path::new(dir); + // 只认绝对目录:剔除 ""、"."、相对项 —— 杜绝落回工作区。 + if !dir_path.is_absolute() { + continue; + } + for ext in &exts { + let mut file = name.to_os_string(); + file.push(ext); + let candidate = dir_path.join(&file); + if is_file(&candidate) { + return Some(candidate); + } + } + } + None +} + #[derive(Debug, Clone, Copy)] pub(crate) struct SandboxOptions { pub allow_network: bool, @@ -45,6 +243,10 @@ pub struct SandboxCapability { pub supported: bool, pub mechanism: &'static str, pub platform: &'static str, + /// 是否支持断网变体(sandboxOffline)。macOS/Linux 在 `supported` 时为 true; + /// Windows 免管理员方案无法可靠断网,恒为 false —— 前端据此仅禁用 sandboxOffline, + /// 保留 sandbox。`supported=false` 时该字段无意义(整体不可用)。 + pub network_control: bool, #[serde(skip_serializing_if = "Option::is_none")] pub reason: Option, } @@ -184,6 +386,7 @@ mod platform { supported: true, mechanism: "seatbelt", platform: "macos", + network_control: true, reason: None, } } else { @@ -191,6 +394,7 @@ mod platform { supported: false, mechanism: "seatbelt", platform: "macos", + network_control: false, reason: Some(format!("{SANDBOX_EXEC} not found")), } } @@ -263,6 +467,7 @@ mod platform { supported: false, mechanism: "bubblewrap", platform: "linux", + network_control: false, reason: Some(reason), }; // 探测真实可用性(容器/受限内核里 bwrap 可能存在但无法建 namespace)。 @@ -286,6 +491,7 @@ mod platform { supported: true, mechanism: "bubblewrap", platform: "linux", + network_control: true, reason: None, }, Ok(output) => unsupported(format!( @@ -361,23 +567,38 @@ mod platform { use super::*; pub(super) fn capability() -> SandboxCapability { + // 免管理员写围栏(CreateRestrictedToken WRITE_RESTRICTED + 工作区继承写 ACE) + // 无需任何依赖或提权,恒可用。断网(sandboxOffline)不可用:见模块注释。 SandboxCapability { - supported: false, - mechanism: "none", + supported: true, + mechanism: "restricted-token", platform: "windows", - reason: Some( - "Windows sandbox (restricted token + job object + WFP) is not implemented yet" - .to_string(), - ), + network_control: false, + reason: None, } } pub(super) fn wrap_command( - _spec: &SandboxSpec, - _program: &Path, - _args: &[String], + spec: &SandboxSpec, + program: &Path, + args: &[String], ) -> Result<(PathBuf, Vec, &'static str), String> { - Err("Windows sandbox is not implemented yet".to_string()) + // fail-closed:免管理员方案无法断网,sandboxOffline 在 Windows 上直接报错, + // 绝不静默当作联网沙箱执行。capability.network_control=false 已让前端禁用该项, + // 这里是执行层的兜底(设置可能同步自 macOS)。 + if !spec.allow_network { + return Err( + "Offline sandbox (no network) is not available on Windows without elevation. \ +Use the plain Sandbox mode, or run on macOS/Linux for the offline variant." + .to_string(), + ); + } + // 自我再执行:把真实命令包进 current_exe 的 __sandbox_exec 启动器。启动器在 + // 进程最早期建受限令牌并 CreateProcessAsUserW 真实命令(见 windows_sandbox)。 + let current_exe = std::env::current_exe() + .map_err(|err| format!("failed to resolve current executable for sandbox: {err}"))?; + let launcher_args = build_launcher_args(&spec.write_root, program, args); + Ok((current_exe, launcher_args, "restricted-token")) } } @@ -495,4 +716,123 @@ mod tests { fn validate_workspace_allows_ordinary_workspace() { assert!(validate_workspace(Path::new("/tmp/liveagent-ordinary-ws")).is_ok()); } + + // --- 跨平台纯逻辑(Windows 启动器所依赖,可在任意宿主上运行) --- + + #[test] + fn launcher_args_roundtrip() { + let program = PathBuf::from(r"C:\Program Files\Git\bin\bash.exe"); + let args = vec!["-lc".to_string(), "echo \"hi there\" && ls".to_string()]; + let built = build_launcher_args(Path::new(r"C:\ws\proj"), &program, &args); + assert_eq!(built[0], SANDBOX_EXEC_SUBCOMMAND); + // payload = built[1..](去掉 argv[1] 子命令标记),即启动器实际解析的部分。 + let parsed = parse_launcher_args(&built[1..]).expect("parse"); + assert_eq!(parsed.write_root, PathBuf::from(r"C:\ws\proj")); + assert_eq!(parsed.program, program); + assert_eq!(parsed.args, args); + } + + #[test] + fn parse_launcher_args_rejects_incomplete() { + assert!(parse_launcher_args(&["--write-root".to_string()]).is_err()); + assert!(parse_launcher_args(&["--".to_string()]).is_err()); + assert!(parse_launcher_args(&[]).is_err()); + // 缺 --write-root。 + assert!(parse_launcher_args(&["--".to_string(), "cmd.exe".to_string()]).is_err()); + } + + #[test] + fn parse_launcher_args_program_without_extra_args() { + let parsed = parse_launcher_args(&[ + "--write-root".to_string(), + r"C:\ws".to_string(), + "--".to_string(), + "cmd.exe".to_string(), + ]) + .expect("parse"); + assert_eq!(parsed.program, PathBuf::from("cmd.exe")); + assert!(parsed.args.is_empty()); + } + + #[test] + fn synthetic_sid_is_deterministic_and_case_insensitive() { + let a = synthetic_workspace_sid(Path::new(r"C:\Users\Me\Project")); + let b = synthetic_workspace_sid(Path::new(r"c:\users\me\project")); + assert_eq!(a, b, "Windows 路径大小写不敏感,应得同一 SID"); + assert!(a.starts_with("S-1-5-21-")); + // 形如 S-1-5-21----:S,1,5,21 + 4 段子权限 = 8 段。 + assert_eq!(a.split('-').count(), 8); + let other = synthetic_workspace_sid(Path::new(r"C:\Users\Me\Other")); + assert_ne!(a, other, "不同路径应得不同 SID"); + } + + #[test] + fn command_line_quotes_spaces_and_escapes_quotes() { + let line = build_command_line( + r"C:\Program Files\App\app.exe", + &["--flag".to_string(), "a b".to_string(), r#"say "hi""#.to_string()], + ); + assert_eq!(line.last(), Some(&0u16), "须以 NUL 结尾"); + let decoded = String::from_utf16(&line[..line.len() - 1]).unwrap(); + // 含空格的程序路径整体加引号(反斜杠不因无 `"` 而翻倍)。 + assert!(decoded.starts_with(r#""C:\Program Files\App\app.exe""#)); + // 无特殊字符的参数不加引号。 + assert!(decoded.contains(" --flag ")); + // 含空格的参数加引号。 + assert!(decoded.contains(r#" "a b" "#)); + // 内部的 " 用反斜杠转义。 + assert!(decoded.ends_with(r#""say \"hi\"""#)); + } + + #[test] + fn command_line_doubles_trailing_backslashes_before_closing_quote() { + // 参数含空格需加引号,且以反斜杠结尾时,收尾反斜杠必须翻倍, + // 否则会转义掉闭合引号(CommandLineToArgvW 经典陷阱)。 + let line = build_command_line("prog", &[r"a\b c\".to_string()]); + let decoded = String::from_utf16(&line[..line.len() - 1]).unwrap(); + assert!(decoded.ends_with(r#""a\b c\\""#)); + } + + // resolve_program_in_path:本机(Unix)按 Unix 绝对/分隔规则验证“搜绝对目录、套 + // PATHEXT、跳相对项、绝对入参直通”这套算法;Windows 路径语义由 Windows 编译+真机验证。 + #[test] + fn resolve_program_searches_absolute_dirs_first_match_wins() { + let present: std::collections::HashSet = + [PathBuf::from("/usr/bin/sh")].into_iter().collect(); + let is_file = |p: &Path| present.contains(p); + let got = resolve_program_in_path( + Path::new("sh"), + "/nonexist;/usr/bin;/bin", + ".EXE", + &is_file, + ); + assert_eq!(got, Some(PathBuf::from("/usr/bin/sh"))); + } + + #[test] + fn resolve_program_applies_pathext_to_bare_name() { + let present: std::collections::HashSet = + [PathBuf::from("/tools/pwsh.EXE")].into_iter().collect(); + let is_file = |p: &Path| present.contains(p); + let got = resolve_program_in_path(Path::new("pwsh"), "/tools", ".COM;.EXE", &is_file); + assert_eq!(got, Some(PathBuf::from("/tools/pwsh.EXE"))); + } + + #[test] + fn resolve_program_never_probes_relative_or_dot_dirs() { + // PATH 里的 "." 与相对项绝不被探测:谓词只应收到绝对候选。 + let is_file = |p: &Path| { + assert!(p.is_absolute(), "resolver probed a non-absolute path: {p:?}"); + false + }; + let got = resolve_program_in_path(Path::new("cmd.exe"), ".;rel/dir;/abs", ".EXE", &is_file); + assert_eq!(got, None); + } + + #[test] + fn resolve_program_passes_absolute_input_through_without_probing() { + let is_file = |_: &Path| panic!("absolute input must not be probed"); + let got = resolve_program_in_path(Path::new("/bin/sh"), "/other", ".EXE", &is_file); + assert_eq!(got, Some(PathBuf::from("/bin/sh"))); + } } diff --git a/crates/agent-gui/src-tauri/src/runtime/windows_sandbox.rs b/crates/agent-gui/src-tauri/src/runtime/windows_sandbox.rs new file mode 100644 index 000000000..8c69b3a4b --- /dev/null +++ b/crates/agent-gui/src-tauri/src/runtime/windows_sandbox.rs @@ -0,0 +1,580 @@ +//! Windows 沙箱启动器(自我再执行模型,免管理员 / 免 UAC)。 +//! +//! `sandbox::wrap_command`(Windows)不直接返回真实命令,而是把它包成对本 exe 的 +//! 再调用:`current_exe __sandbox_exec --write-root -- `。 +//! 进程启动最早期(`lib::run` 首行)调用 `run_sandbox_launcher_if_requested`:若检出 +//! 该子命令,就地建立“写受限”令牌并 `CreateProcessAsUserW` 真实命令,等待其退出,以 +//! 其退出码退出——绝不返回去初始化 Tauri。 +//! +//! 写围栏机制(见 memory `windows-sandbox-facts`,均已研究+对抗验证): +//! - `CreateRestrictedToken(WRITE_RESTRICTED)`:限制性 SID 只在“写”访问时参与判定, +//! 读/执行跳过第二遍 ⇒ 读广泛放行(工具链可用),写须“常规 SID 放行 且 至少一个 +//! 限制性 SID 放行”。 +//! - 限制性 SID 集 = {登录 SID(从当前令牌 TokenGroups 按 SE_GROUP_LOGON_ID 读, +//! 逐会话,不可硬编码)、WRITE RESTRICTED SID `S-1-5-33`、一个由工作区路径确定性 +//! 推导的合成 SID}。省略 Everyone,否则重开“全局可写目录”漏洞;过紧(仅合成 SID) +//! 会让进程连自己的线程/管道都建不了而启动即死。 +//! - 在工作区根 + 一个受围栏的临时目录上盖“可继承(OI)(CI)”的合成-SID 授权写 ACE。 +//! 合成 SID 只匹配此工作区,遗留 ACE 惰性无害;绝不移除 ACE(空 DACL 陷阱)。 +//! +//! 免管理员的固有边界:无法掩蔽敏感目录的“读”,无法可靠断网——故 Windows 上 +//! `sandboxOffline` 不可用,`wrap_command` 已对 `!allow_network` fail-closed。 +//! +//! 已知残留(严重度低,有界):`CreateProcessAsUserW` 用 `bInheritHandles=TRUE`,会把 +//! 启动器此刻**所有**可继承句柄一并传给子进程,而非仅 stdin/stdout/stderr。因本启动器 +//! 在 `lib::run` 首行、Tauri 初始化前即执行,此时除 shell_runner 建好的管道标准句柄外 +//! 并无其它句柄打开,暴露面很小。彻底收敛需 `STARTUPINFOEX` + +//! `PROC_THREAD_ATTRIBUTE_HANDLE_LIST` 只继承这三个句柄——因无法在本机真机验证该段 +//! 新 FFI,暂不引入,留作后续在 Windows 上验证后再加固。 + +/// 非 Windows:自我再执行启动器不存在,空操作。 +#[cfg(not(windows))] +pub fn run_sandbox_launcher_if_requested() {} + +/// Windows:若本次进程是 `__sandbox_exec` 启动器,执行真实命令并以其退出码退出; +/// 否则原样返回,交由正常的 Tauri 启动流程继续。 +#[cfg(windows)] +pub fn run_sandbox_launcher_if_requested() { + use crate::runtime::sandbox::{parse_launcher_args, SANDBOX_EXEC_SUBCOMMAND}; + + let raw: Vec = std::env::args().collect(); + // raw[0] = exe 自身;raw[1] = 子命令标记;raw[2..] = 启动器 payload。 + if raw.get(1).map(String::as_str) != Some(SANDBOX_EXEC_SUBCOMMAND) { + return; + } + + let code = match parse_launcher_args(&raw[2..]) { + Ok(inv) => match win::execute(&inv.write_root, &inv.program, &inv.args) { + Ok(code) => code, + Err(err) => { + // fail-closed:已进入沙箱启动器分支,任何建令牌/派生失败都必须让命令 + // 整体不执行,绝不回退到无沙箱运行。 + eprintln!("liveagent sandbox launcher failed: {err}"); + 127 + } + }, + Err(err) => { + eprintln!("liveagent sandbox launcher: invalid arguments: {err}"); + 127 + } + }; + std::process::exit(code); +} + +#[cfg(windows)] +mod win { + use std::ffi::c_void; + use std::path::Path; + use std::ptr::{null, null_mut}; + + use windows_sys::Win32::Foundation::{ + CloseHandle, GetLastError, LocalFree, SetHandleInformation, HANDLE, + }; + use windows_sys::Win32::Security::Authorization::{ + ConvertStringSidToSidW, GetNamedSecurityInfoW, SetEntriesInAclW, SetNamedSecurityInfoW, + EXPLICIT_ACCESS_W, TRUSTEE_W, + }; + use windows_sys::Win32::Security::{ + CopySid, CreateRestrictedToken, EqualSid, GetAce, GetAclInformation, GetLengthSid, + GetTokenInformation, ACCESS_ALLOWED_ACE, ACE_HEADER, ACL, ACL_SIZE_INFORMATION, + SID_AND_ATTRIBUTES, TOKEN_GROUPS, + }; + use windows_sys::Win32::System::Console::GetStdHandle; + use windows_sys::Win32::System::Environment::SetEnvironmentVariableW; + use windows_sys::Win32::System::JobObjects::{ + AssignProcessToJobObject, CreateJobObjectW, SetInformationJobObject, + JOBOBJECT_EXTENDED_LIMIT_INFORMATION, + }; + use windows_sys::Win32::System::Threading::{ + CreateProcessAsUserW, GetCurrentProcess, GetExitCodeProcess, OpenProcessToken, + ResumeThread, WaitForSingleObject, PROCESS_INFORMATION, STARTUPINFOW, + }; + + // 以本地常量代替对 windows-sys 各 feature 常量导出的依赖:字段类型均为整型别名 + // (windows-sys 用 type alias 而非 newtype),直接赋整型字面量即可,极大降低 + // “某常量是否在某 feature 下导出”的编译风险。数值均取自 Win32 头文件。 + const TOKEN_QUERY: u32 = 0x0008; + const TOKEN_DUPLICATE: u32 = 0x0002; + const TOKEN_ASSIGN_PRIMARY: u32 = 0x0001; + const TOKEN_ADJUST_DEFAULT: u32 = 0x0080; + + const DISABLE_MAX_PRIVILEGE: u32 = 0x1; + const LUA_TOKEN: u32 = 0x4; + const WRITE_RESTRICTED: u32 = 0x8; + + const SE_GROUP_LOGON_ID: u32 = 0xC000_0000; + const TOKEN_GROUPS_CLASS: i32 = 2; // TOKEN_INFORMATION_CLASS::TokenGroups + + const SE_FILE_OBJECT: i32 = 1; // SE_OBJECT_TYPE + const DACL_SECURITY_INFORMATION: u32 = 0x0000_0004; + const OBJECT_INHERIT_ACE: u32 = 0x1; + const CONTAINER_INHERIT_ACE: u32 = 0x2; + const GRANT_ACCESS: i32 = 1; // ACCESS_MODE + const TRUSTEE_IS_SID: i32 = 0; // TRUSTEE_FORM + const TRUSTEE_IS_UNKNOWN: i32 = 0; // TRUSTEE_TYPE + const ACL_SIZE_INFORMATION_CLASS: i32 = 2; // ACL_INFORMATION_CLASS::AclSizeInformation + const ACCESS_ALLOWED_ACE_TYPE: u8 = 0; + + // 文件访问权掩码(标准值);DELETE 本地定义以回避导入位置歧义。 + const FILE_GENERIC_READ: u32 = 0x0012_0089; + const FILE_GENERIC_WRITE: u32 = 0x0012_0116; + const FILE_GENERIC_EXECUTE: u32 = 0x0012_00A0; + const DELETE_RIGHT: u32 = 0x0001_0000; + + const HANDLE_FLAG_INHERIT: u32 = 0x1; + const STARTF_USESTDHANDLES: u32 = 0x0000_0100; + const CREATE_NO_WINDOW: u32 = 0x0800_0000; + const CREATE_SUSPENDED: u32 = 0x0000_0004; + const INFINITE: u32 = 0xFFFF_FFFF; + const STD_INPUT_HANDLE: u32 = 0xFFFF_FFF6; // (DWORD)-10 + const STD_OUTPUT_HANDLE: u32 = 0xFFFF_FFF5; // -11 + const STD_ERROR_HANDLE: u32 = 0xFFFF_FFF4; // -12 + + const JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE: u32 = 0x2000; + const JOB_OBJECT_EXTENDED_LIMIT_INFO_CLASS: i32 = 9; // JobObjectExtendedLimitInformation + + const WRITE_RESTRICTED_SID: &str = "S-1-5-33"; + + /// PSID 别名(windows-sys 里就是 `*mut c_void`),提升可读性。 + type PSID = *mut c_void; + + // windows-sys 0.61 的 FFI 布尔返回是 `windows_sys::core::BOOL`(= i32);此处直接 + // 用 i32 作参数(透明别名,可接收所有这些函数的返回)。 + #[inline] + fn ok(b: i32) -> bool { + b != 0 + } + + fn last_error(ctx: &str) -> String { + let code = unsafe { GetLastError() }; + format!("{ctx} (GetLastError={code})") + } + + /// str → 以 NUL 结尾的 UTF-16。 + fn to_wide(s: &str) -> Vec { + s.encode_utf16().chain(std::iter::once(0)).collect() + } + + /// ConvertStringSidToSidW 分配的 SID,Drop 时 LocalFree。 + struct LocalSid(PSID); + + impl Drop for LocalSid { + fn drop(&mut self) { + if !self.0.is_null() { + unsafe { + LocalFree(self.0 as _); + } + } + } + } + + fn string_to_sid(s: &str) -> Result { + let wide = to_wide(s); + let mut sid: PSID = null_mut(); + let r = unsafe { ConvertStringSidToSidW(wide.as_ptr(), &mut sid) }; + if !ok(r) || sid.is_null() { + return Err(last_error(&format!("ConvertStringSidToSidW({s})"))); + } + Ok(LocalSid(sid)) + } + + /// 打开当前进程的主令牌(建受限令牌需 DUPLICATE|QUERY;附带 ASSIGN_PRIMARY| + /// ADJUST_DEFAULT 与 Chromium 一致,便于后续以其派生的令牌启动进程)。 + fn open_process_token() -> Result { + let mut token: HANDLE = null_mut(); + let access = TOKEN_QUERY | TOKEN_DUPLICATE | TOKEN_ASSIGN_PRIMARY | TOKEN_ADJUST_DEFAULT; + let r = unsafe { OpenProcessToken(GetCurrentProcess(), access, &mut token) }; + if !ok(r) { + return Err(last_error("OpenProcessToken")); + } + Ok(token) + } + + /// 从令牌 TokenGroups 里读出登录 SID(SE_GROUP_LOGON_ID),复制成自持字节缓冲。 + fn logon_sid_bytes(token: HANDLE) -> Result, String> { + let mut len: u32 = 0; + // 首次调用取所需长度(预期失败并置 len)。 + unsafe { GetTokenInformation(token, TOKEN_GROUPS_CLASS, null_mut(), 0, &mut len) }; + if len == 0 { + return Err(last_error("GetTokenInformation(TokenGroups) size probe")); + } + // 用 u64 缓冲保证 8 字节对齐(TOKEN_GROUPS 含指针,Vec 不保证对齐)。 + let mut buf: Vec = vec![0u64; ((len as usize) + 7) / 8]; + let r = unsafe { + GetTokenInformation( + token, + TOKEN_GROUPS_CLASS, + buf.as_mut_ptr() as *mut c_void, + len, + &mut len, + ) + }; + if !ok(r) { + return Err(last_error("GetTokenInformation(TokenGroups)")); + } + unsafe { + let groups = buf.as_ptr() as *const TOKEN_GROUPS; + let count = (*groups).GroupCount; + let arr = (*groups).Groups.as_ptr(); + for i in 0..count as usize { + let entry: &SID_AND_ATTRIBUTES = &*arr.add(i); + if entry.Attributes & SE_GROUP_LOGON_ID == SE_GROUP_LOGON_ID { + let sid_len = GetLengthSid(entry.Sid); + if sid_len == 0 { + return Err(last_error("GetLengthSid(logon sid)")); + } + let mut sid_buf = vec![0u8; sid_len as usize]; + if !ok(CopySid(sid_len, sid_buf.as_mut_ptr() as PSID, entry.Sid)) { + return Err(last_error("CopySid(logon sid)")); + } + return Ok(sid_buf); + } + } + } + Err("logon SID (SE_GROUP_LOGON_ID) not present in token".to_string()) + } + + /// 用 {登录 SID, S-1-5-33, 合成 SID} 作限制性 SID,建 WRITE_RESTRICTED 主令牌。 + fn create_restricted_token(base: HANDLE, restricting: &[PSID]) -> Result { + let mut sids: Vec = restricting + .iter() + .map(|&sid| SID_AND_ATTRIBUTES { + Sid: sid, + Attributes: 0, + }) + .collect(); + let mut restricted: HANDLE = null_mut(); + let flags = DISABLE_MAX_PRIVILEGE | LUA_TOKEN | WRITE_RESTRICTED; + let r = unsafe { + CreateRestrictedToken( + base, + flags, + 0, + null(), + 0, + null(), + sids.len() as u32, + sids.as_mut_ptr(), + &mut restricted, + ) + }; + if !ok(r) { + return Err(last_error("CreateRestrictedToken")); + } + Ok(restricted) + } + + /// 目录根 DACL 上是否已含合成 SID 的 ACE。命中即认为整棵树已盖章(可继承 ACE 会 + /// 自动传播到后建的文件),跳过昂贵的重新传播。任何探测失败按“未盖章”处理。 + fn root_has_ace(path_wide: &[u16], sid: PSID) -> bool { + unsafe { + let mut dacl: *mut ACL = null_mut(); + let mut psd: *mut c_void = null_mut(); + let rc = GetNamedSecurityInfoW( + path_wide.as_ptr(), + SE_FILE_OBJECT, + DACL_SECURITY_INFORMATION, + null_mut(), + null_mut(), + &mut dacl, + null_mut(), + &mut psd, + ); + if rc != 0 || dacl.is_null() { + if !psd.is_null() { + LocalFree(psd as _); + } + return false; + } + let mut info = ACL_SIZE_INFORMATION { + AceCount: 0, + AclBytesInUse: 0, + AclBytesFree: 0, + }; + let mut found = false; + if ok(GetAclInformation( + dacl, + &mut info as *mut _ as *mut c_void, + std::mem::size_of::() as u32, + ACL_SIZE_INFORMATION_CLASS, + )) { + for i in 0..info.AceCount { + let mut ace: *mut c_void = null_mut(); + if !ok(GetAce(dacl, i, &mut ace)) || ace.is_null() { + continue; + } + let header = ace as *const ACE_HEADER; + if (*header).AceType == ACCESS_ALLOWED_ACE_TYPE { + let allow = ace as *const ACCESS_ALLOWED_ACE; + let sid_ptr = &(*allow).SidStart as *const u32 as PSID; + if ok(EqualSid(sid_ptr, sid)) { + found = true; + break; + } + } + } + } + LocalFree(psd as _); + found + } + } + + /// 在 path 上盖“可继承(OI)(CI)”的合成-SID 授权写 ACE(不存在才盖)。 + fn ensure_write_ace(path: &Path, sid: PSID) -> Result<(), String> { + let mut path_wide = to_wide(&path.to_string_lossy()); + if root_has_ace(&path_wide, sid) { + return Ok(()); + } + unsafe { + let mut old_dacl: *mut ACL = null_mut(); + let mut psd: *mut c_void = null_mut(); + let rc = GetNamedSecurityInfoW( + path_wide.as_ptr(), + SE_FILE_OBJECT, + DACL_SECURITY_INFORMATION, + null_mut(), + null_mut(), + &mut old_dacl, + null_mut(), + &mut psd, + ); + if rc != 0 { + return Err(format!("GetNamedSecurityInfoW failed (error={rc})")); + } + + // NULL DACL = 隐式“everyone 全权”:受限令牌的合成 SID 本就被授予写,无需盖章; + // 若仍用 SetEntriesInAclW(oldacl=NULL) 生成“仅合成 SID”的 DACL 再回写,反而把 + // 正常(无沙箱)访问锁死。故此情形直接跳过。 + if old_dacl.is_null() { + if !psd.is_null() { + LocalFree(psd as _); + } + return Ok(()); + } + + let mut ea: EXPLICIT_ACCESS_W = std::mem::zeroed(); + ea.grfAccessPermissions = + FILE_GENERIC_READ | FILE_GENERIC_WRITE | FILE_GENERIC_EXECUTE | DELETE_RIGHT; + ea.grfAccessMode = GRANT_ACCESS; + ea.grfInheritance = OBJECT_INHERIT_ACE | CONTAINER_INHERIT_ACE; + ea.Trustee = TRUSTEE_W { + pMultipleTrustee: null_mut(), + MultipleTrusteeOperation: 0, // NO_MULTIPLE_TRUSTEE + TrusteeForm: TRUSTEE_IS_SID, + TrusteeType: TRUSTEE_IS_UNKNOWN, + ptstrName: sid as *mut u16, + }; + + let mut new_dacl: *mut ACL = null_mut(); + let rc = SetEntriesInAclW(1, &ea, old_dacl, &mut new_dacl); + if rc != 0 || new_dacl.is_null() { + if !psd.is_null() { + LocalFree(psd as _); + } + return Err(format!("SetEntriesInAclW failed (error={rc})")); + } + + let rc = SetNamedSecurityInfoW( + path_wide.as_mut_ptr(), + SE_FILE_OBJECT, + DACL_SECURITY_INFORMATION, + null_mut(), + null_mut(), + new_dacl, + null_mut(), + ); + LocalFree(new_dacl as _); + if !psd.is_null() { + LocalFree(psd as _); + } + if rc != 0 { + return Err(format!("SetNamedSecurityInfoW failed (error={rc})")); + } + } + Ok(()) + } + + /// 创建并盖章一个受围栏的临时目录(系统 temp 下,按工作区确定性命名),把 + /// TEMP/TMP/TMPDIR 指向它——否则沙箱进程写默认 %TEMP% 会被限制性判定拒绝。 + fn setup_fenced_temp(write_root: &Path, sid: PSID, dir_key: &str) -> Result<(), String> { + use std::os::windows::fs::MetadataExt; + const FILE_ATTRIBUTE_REPARSE_POINT: u32 = 0x400; + + let base = std::env::temp_dir().join(format!("liveagent-sandbox-{dir_key}")); + std::fs::create_dir_all(&base) + .map_err(|err| format!("create fenced temp dir failed: {err}"))?; + // 路径确定性且可预测 ⇒ 另一同用户进程可能抢先把它建成 junction/symlink 指向敏感 + // 目录,使授权写 ACE 盖到目标、TEMP 重定向落进目标。拒绝 reparse point 以堵此路 + //(残留 TOCTOU:盖章/使用之间的替换需另一恶意同用户进程,严重度低)。 + let meta = std::fs::symlink_metadata(&base) + .map_err(|err| format!("stat fenced temp dir failed: {err}"))?; + if meta.file_attributes() & FILE_ATTRIBUTE_REPARSE_POINT != 0 { + return Err("fenced temp dir is a reparse point; refusing to stamp".to_string()); + } + ensure_write_ace(&base, sid)?; + let _ = write_root; // 保留签名清晰度;temp 独立于工作区。 + let base_wide = to_wide(&base.to_string_lossy()); + for name in ["TEMP", "TMP", "TMPDIR"] { + let name_wide = to_wide(name); + unsafe { + if !ok(SetEnvironmentVariableW(name_wide.as_ptr(), base_wide.as_ptr())) { + return Err(last_error(&format!("SetEnvironmentVariableW({name})"))); + } + } + } + Ok(()) + } + + /// 令三个标准句柄可继承,并作为 STARTF_USESTDHANDLES 传给子进程(stdin=NUL、 + /// stdout/stderr=父层管道,均由 shell_runner 建好后经继承落到本启动器)。 + fn inheritable_std_handles() -> Result<(HANDLE, HANDLE, HANDLE), String> { + // GetStdHandle 在句柄缺失时返回 INVALID_HANDLE_VALUE(-1)而非 null;两者都跳过。 + let invalid: HANDLE = usize::MAX as HANDLE; + unsafe { + let stdin = GetStdHandle(STD_INPUT_HANDLE); + let stdout = GetStdHandle(STD_OUTPUT_HANDLE); + let stderr = GetStdHandle(STD_ERROR_HANDLE); + for h in [stdin, stdout, stderr] { + if !h.is_null() && h != invalid { + // 失败不致命:句柄可能本就可继承;继续尝试。 + SetHandleInformation(h, HANDLE_FLAG_INHERIT, HANDLE_FLAG_INHERIT); + } + } + Ok((stdin, stdout, stderr)) + } + } + + pub(super) fn execute( + write_root: &Path, + program: &Path, + args: &[String], + ) -> Result { + use crate::runtime::sandbox::{ + build_command_line, resolve_program_in_path, synthetic_workspace_sid, + }; + + let synthetic_str = synthetic_workspace_sid(write_root); + // temp 目录名沿用合成 SID 的数值段,确定性且文件系统安全。 + let dir_key = synthetic_str.trim_start_matches("S-1-5-21-").replace('-', "_"); + + // --- SID 准备 --- + let synthetic = string_to_sid(&synthetic_str)?; + let write_restricted = string_to_sid(WRITE_RESTRICTED_SID)?; + let token = open_process_token()?; + let logon = logon_sid_bytes(token); + // logon SID 缺失时不能保证进程能操作自有内核对象,fail-closed。 + let logon = match logon { + Ok(bytes) => bytes, + Err(err) => { + unsafe { CloseHandle(token) }; + return Err(err); + } + }; + let logon_ptr = logon.as_ptr() as PSID; + + let restricting: [PSID; 3] = [logon_ptr, write_restricted.0, synthetic.0]; + let restricted_token = match create_restricted_token(token, &restricting) { + Ok(t) => t, + Err(err) => { + unsafe { CloseHandle(token) }; + return Err(err); + } + }; + unsafe { CloseHandle(token) }; + + // --- 文件系统写围栏 --- + let stamp = ensure_write_ace(write_root, synthetic.0) + .and_then(|_| setup_fenced_temp(write_root, synthetic.0, &dir_key)); + if let Err(err) = stamp { + unsafe { CloseHandle(restricted_token) }; + return Err(err); + } + + // --- 标准句柄 + 命令行 --- + let (h_in, h_out, h_err) = inheritable_std_handles()?; + + // lpApplicationName 必须是绝对路径:CreateProcessAsUserW 对“部分名”只按当前目录 + // (= 工作区,模型可写)补全且不查 PATH。用 PATH 里的绝对目录预解析,绝不搜工作区 + // —— 既保证系统 shell 能找到,又杜绝工作区投毒的同名二进制被当作 shell 执行。 + let path_env = std::env::var("PATH").unwrap_or_default(); + let pathext = + std::env::var("PATHEXT").unwrap_or_else(|_| ".COM;.EXE;.BAT;.CMD".to_string()); + let resolved = resolve_program_in_path(program, &path_env, &pathext, &|p| p.is_file()) + .ok_or_else(|| { + format!( + "sandbox refuses to resolve program {program:?}: not found in any absolute \ + PATH directory (the workspace cwd is intentionally never searched)" + ) + })?; + let program_str = program.to_string_lossy(); // argv[0] 保留原始名(对齐非沙箱路径) + let app_wide = to_wide(&resolved.to_string_lossy()); // lpApplicationName = 解析出的绝对路径 + let mut cmdline = build_command_line(&program_str, args); // 已含结尾 NUL + + // --- 启动子进程(挂起态,便于先入 Job 再放行) --- + let result = unsafe { + let mut si: STARTUPINFOW = std::mem::zeroed(); + si.cb = std::mem::size_of::() as u32; + si.dwFlags = STARTF_USESTDHANDLES; + si.hStdInput = h_in; + si.hStdOutput = h_out; + si.hStdError = h_err; + + let mut pi: PROCESS_INFORMATION = std::mem::zeroed(); + let created = CreateProcessAsUserW( + restricted_token, + app_wide.as_ptr(), + cmdline.as_mut_ptr(), + null(), + null(), + 1, // bInheritHandles = TRUE + CREATE_NO_WINDOW | CREATE_SUSPENDED, + null(), // lpEnvironment = NULL ⇒ 子进程继承本启动器环境(含代理/temp 重定向) + null(), // lpCurrentDirectory = NULL ⇒ 继承本启动器 cwd(= 实际工作目录) + &si, + &mut pi, + ); + if !ok(created) { + return Err(last_error("CreateProcessAsUserW")); + } + + // Job Object(KILL_ON_JOB_CLOSE):启动器意外死亡时连带杀子进程,为 + // taskkill /T 之外的兜底。尽力而为,失败仅告警。 + let job = CreateJobObjectW(null(), null()); + if !job.is_null() { + let mut limits: JOBOBJECT_EXTENDED_LIMIT_INFORMATION = std::mem::zeroed(); + limits.BasicLimitInformation.LimitFlags = JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE; + SetInformationJobObject( + job, + JOB_OBJECT_EXTENDED_LIMIT_INFO_CLASS, + &limits as *const _ as *const c_void, + std::mem::size_of::() as u32, + ); + if !ok(AssignProcessToJobObject(job, pi.hProcess)) { + eprintln!( + "liveagent sandbox: {}", + last_error("AssignProcessToJobObject (continuing; taskkill /T still cascades)") + ); + } + } + + ResumeThread(pi.hThread); + CloseHandle(pi.hThread); + + WaitForSingleObject(pi.hProcess, INFINITE); + let mut exit_code: u32 = 0; + let got = GetExitCodeProcess(pi.hProcess, &mut exit_code); + CloseHandle(pi.hProcess); + // job 句柄须保持打开直到子进程退出;此刻关闭即可(KILL_ON_JOB_CLOSE 无害)。 + if !job.is_null() { + CloseHandle(job); + } + if !ok(got) { + return Err(last_error("GetExitCodeProcess")); + } + exit_code as i32 + }; + + unsafe { CloseHandle(restricted_token) }; + Ok(result) + } +} diff --git a/crates/agent-gui/src/agent-ui-adapters/sandboxCapability.ts b/crates/agent-gui/src/agent-ui-adapters/sandboxCapability.ts index 7d6c1f8f1..bd22db5f8 100644 --- a/crates/agent-gui/src/agent-ui-adapters/sandboxCapability.ts +++ b/crates/agent-gui/src/agent-ui-adapters/sandboxCapability.ts @@ -1,22 +1,18 @@ import { invoke } from "@tauri-apps/api/core"; import { useEffect, useState } from "react"; -import { inferRuntimePlatform } from "../lib/runtimePlatform"; export type SandboxCapability = { supported: boolean; mechanism: string; platform: string; + /** 是否支持断网变体(sandboxOffline)。Windows 免管理员方案恒为 false。 */ + network_control: boolean; reason?: string; }; -/** 桌面端:本机平台即沙箱执行平台,可在探测返回前先按平台渲染文案/禁用开关。 */ -export function inferSandboxPlatform(): "macos" | "linux" | "windows" | null { - return inferRuntimePlatform(); -} - let cachedCapability: SandboxCapability | null = null; -/** 桌面端:探测本机 OS 沙箱可用性(macOS Seatbelt / Linux bwrap / Windows 未实现)。 */ +/** 桌面端:探测本机 OS 沙箱可用性(macOS Seatbelt / Linux bwrap / Windows 受限令牌写围栏)。 */ export function useSandboxCapability(): SandboxCapability | null { const [capability, setCapability] = useState(cachedCapability); diff --git a/crates/agent-ui/src/components/chat/CommandSafetyModeSelector.tsx b/crates/agent-ui/src/components/chat/CommandSafetyModeSelector.tsx index e3acbaa9b..0d17407dd 100644 --- a/crates/agent-ui/src/components/chat/CommandSafetyModeSelector.tsx +++ b/crates/agent-ui/src/components/chat/CommandSafetyModeSelector.tsx @@ -1,4 +1,4 @@ -import { inferSandboxPlatform, useSandboxCapability } from "@liveagent/adapters/sandboxCapability"; +import { useSandboxCapability } from "@liveagent/adapters/sandboxCapability"; import type { CommandSafetyMode } from "@liveagent/app/lib/settings"; import { Hand, Shield, ShieldOff, Zap } from "@liveagent/ui/components/IconSet"; import { @@ -10,7 +10,6 @@ import { } from "@liveagent/ui/components/ui/select"; import { useLocale } from "@liveagent/ui/i18n/index"; import { cn } from "@liveagent/ui/lib/shared/utils"; -import { useMemo } from "react"; const MODE_I18N_KEYS: Record = { ask: "chat.safety.ask", @@ -39,15 +38,17 @@ export function CommandSafetyModeSelector(props: { const { surface, value, disabled, onChange } = props; const { t } = useLocale(); const capability = useSandboxCapability(); - // 桌面端平台同步可知,Windows 立即禁用沙箱两项;WebUI 为 null(执行端平台 - // 未知),不禁用,由桌面端执行层 fail-closed 兜底。探测结果进一步覆盖。 - const platform = useMemo(() => inferSandboxPlatform(), []); - const sandboxUnavailable = - platform === "windows" || (capability !== null && !capability.supported); - const sandboxUnavailableHint = - platform === "windows" - ? t("chat.safety.sandboxUnavailableWindows") - : t("chat.safety.sandboxUnavailable"); + // 写围栏(sandbox):平台不支持时禁用。桌面端探测返回前(null)乐观启用,由执行层 + // fail-closed 兜底;WebUI 执行端平台未知,同样交由 fail-closed。 + const sandboxUnavailable = capability !== null && !capability.supported; + // 断网(sandboxOffline):额外要求平台可断网。Windows 免管理员方案 network_control=false + // ⇒ 仅此项禁用,sandbox 仍可用。 + const offlineUnavailable = + sandboxUnavailable || (capability !== null && !capability.network_control); + // 禁用时的说明文案:整体不可用优先,否则为“仅断网不可用”。 + const disabledHint = sandboxUnavailable + ? t("chat.safety.sandboxUnavailable") + : t("chat.safety.sandboxOfflineUnavailable"); // 当前值本身不可用(如设置同步自 macOS,本机是 Windows)时仍显示,但标红提示 // 由执行层报错兜底;这里不做静默改写,避免设置回写抖动。 const selected = isCommandSafetyMode(value) ? value : "auto"; @@ -101,8 +102,12 @@ export function CommandSafetyModeSelector(props: { {(["ask", "auto", "sandbox", "sandboxOffline"] as const).map((mode) => { - const isSandboxEntry = mode === "sandbox" || mode === "sandboxOffline"; - const entryDisabled = isSandboxEntry && sandboxUnavailable; + const entryDisabled = + mode === "sandbox" + ? sandboxUnavailable + : mode === "sandboxOffline" + ? offlineUnavailable + : false; return ( {t(MODE_I18N_KEYS[mode])} - {entryDisabled ? sandboxUnavailableHint : t(MODE_DESC_I18N_KEYS[mode])} + {entryDisabled ? disabledHint : t(MODE_DESC_I18N_KEYS[mode])} diff --git a/crates/agent-ui/src/i18n/translations/enUSCommon.ts b/crates/agent-ui/src/i18n/translations/enUSCommon.ts index 7a17d64dd..3a40df35c 100644 --- a/crates/agent-ui/src/i18n/translations/enUSCommon.ts +++ b/crates/agent-ui/src/i18n/translations/enUSCommon.ts @@ -278,8 +278,8 @@ export const EN_US_COMMON_TRANSLATIONS = { "chat.safety.sandboxOffline": "Sandbox · offline", "chat.safety.sandboxOfflineDesc": "Sandbox plus no network access for commands", "chat.safety.sandboxUnavailable": "Sandbox mechanism unavailable on this platform", - "chat.safety.sandboxUnavailableWindows": - "Not supported on Windows yet (restricted-token backend in progress)", + "chat.safety.sandboxOfflineUnavailable": + "Offline sandbox unavailable on this platform (no-admin Windows can't sever network)", "chat.emptyRound": "(No reply)", "chat.inputHint": "Type a message, @ to reference files, prompts can be queued...", "chat.inputHintWithSkills": diff --git a/crates/agent-ui/src/i18n/translations/zhCNCommon.ts b/crates/agent-ui/src/i18n/translations/zhCNCommon.ts index c138ade0b..154f8cd04 100644 --- a/crates/agent-ui/src/i18n/translations/zhCNCommon.ts +++ b/crates/agent-ui/src/i18n/translations/zhCNCommon.ts @@ -252,7 +252,7 @@ export const ZH_CN_COMMON_TRANSLATIONS = { "chat.safety.sandboxOffline": "沙箱·断网", "chat.safety.sandboxOfflineDesc": "在沙箱基础上进一步禁止命令联网", "chat.safety.sandboxUnavailable": "当前平台沙箱机制不可用", - "chat.safety.sandboxUnavailableWindows": "Windows 暂不支持(受限令牌方案开发中)", + "chat.safety.sandboxOfflineUnavailable": "当前平台不支持断网沙箱(Windows 免管理员方案无法断网)", "chat.emptyRound": "(无回复)", "chat.inputHint": "输入消息,@ 引用文件,提示词可队列发送...", "chat.inputHintWithSkills": "输入消息,@ 引用文件,/ 引用Skills,提示词可队列发送...", diff --git a/crates/agent-ui/src/lib/settings/types.ts b/crates/agent-ui/src/lib/settings/types.ts index 6cbb92f6a..0efabfd25 100644 --- a/crates/agent-ui/src/lib/settings/types.ts +++ b/crates/agent-ui/src/lib/settings/types.ts @@ -236,8 +236,9 @@ export type ToolPolicy = "allow" | "ask" | "deny"; // - ask:每次带副作用的工具调用都请求用户批准(只读工具不拦)。 // - auto:按工具审批策略直接执行(既有默认行为)。 // - sandbox / sandboxOffline:Bash 与常驻进程在 OS 级沙箱内执行(macOS -// Seatbelt / Linux bubblewrap;Windows 暂不支持),写入限工作区+临时目录, -// 敏感目录掩蔽;offline 变体额外断网。 +// Seatbelt / Linux bubblewrap / Windows 受限令牌 WRITE_RESTRICTED),写入限 +// 工作区+临时目录;offline 变体额外断网。敏感目录读掩蔽与断网需 macOS/Linux — +// Windows 免管理员方案只围栏写,故 sandboxOffline 在 Windows 上不可用。 export type CommandSafetyMode = "ask" | "auto" | "sandbox" | "sandboxOffline"; export const COMMAND_SAFETY_MODES: readonly CommandSafetyMode[] = [ diff --git a/docs/images/sandbox-mode/windows-composer-sandbox.png b/docs/images/sandbox-mode/windows-composer-sandbox.png new file mode 100644 index 000000000..b8e3f93ad Binary files /dev/null and b/docs/images/sandbox-mode/windows-composer-sandbox.png differ diff --git a/docs/images/sandbox-mode/windows-selector-dropdown.png b/docs/images/sandbox-mode/windows-selector-dropdown.png new file mode 100644 index 000000000..bf1f4352e Binary files /dev/null and b/docs/images/sandbox-mode/windows-selector-dropdown.png differ