use super::*; use sha2::{Digest, Sha256}; use std::collections::{HashSet, VecDeque}; use std::ffi::{OsStr, OsString}; use std::process::Stdio; use tokio::io::AsyncReadExt; #[cfg(target_os = "linux")] use crate::command_sandbox_trampoline::{LaunchGate, TargetExecState, TargetTerminalState}; const PROJECT_COMMAND_MAX_ARGUMENTS: usize = 64; const PROJECT_COMMAND_MAX_ARGUMENT_CHARS: usize = 512; const PROJECT_COMMAND_MAX_ARGUMENT_BYTES: usize = 8 * 1024; const PROJECT_COMMAND_MIN_TIMEOUT_SECONDS: u64 = 1; const PROJECT_COMMAND_MAX_TIMEOUT_SECONDS: u64 = 300; const PROJECT_COMMAND_OUTPUT_MAX_BYTES: usize = 24 * 1024; const PROJECT_COMMAND_FINGERPRINT_MAX_ENTRIES: usize = 20_000; const PROJECT_COMMAND_FINGERPRINT_MAX_FILES: usize = 10_000; const PROJECT_COMMAND_FINGERPRINT_MAX_BYTES: u64 = 512 * 1024 * 1024; const PROJECT_COMMAND_SENSITIVE_GIT_PATHSPECS: &[&str] = &[ ":(exclude).agent/**", ":(exclude)**/.agent/**", ":(exclude).git/**", ":(exclude)**/.git/**", ":(exclude).env", ":(exclude).env.*", ":(exclude)**/.env", ":(exclude)**/.env.*", ":(exclude)key", ":(exclude)key.*", ":(exclude)**/key", ":(exclude)**/key.*", ":(exclude)**/*.key", ":(exclude)config", ":(exclude)config.*", ":(exclude)**/config", ":(exclude)**/config.*", ":(exclude)**/secrets/**", ":(exclude)**/credentials/**", ":(exclude)**/*.pem", ":(exclude)**/*.p12", ":(exclude)**/*.pfx", ":(exclude)**/*.kdbx", ":(exclude)**/.npmrc", ":(exclude)**/.pypirc", ":(exclude)**/.netrc", ":(exclude)**/.git-credentials", ":(exclude)**/credentials.json", ":(exclude)**/auth.json", ":(exclude)**/secrets.json", ":(exclude)**/cookies.json", ":(exclude)**/game-creator.config*.json", ]; const PROJECT_COMMAND_SENSITIVE_RG_GLOBS: &[&str] = &[ "!.agent/**", "!**/.agent/**", "!.git/**", "!**/.git/**", "!.hg/**", "!**/.hg/**", "!.svn/**", "!**/.svn/**", "!.env", "!.env.*", "!**/.env", "!**/.env.*", "!key", "!key.*", "!**/key", "!**/key.*", "!**/*.key", "!config", "!config.*", "!**/config", "!**/config.*", "!**/secrets/**", "!**/credentials/**", "!**/*.pem", "!**/*.p12", "!**/*.pfx", "!**/*.kdbx", "!**/.npmrc", "!**/.pypirc", "!**/.netrc", "!**/.git-credentials", "!**/credentials.json", "!**/auth.json", "!**/secrets.json", "!**/cookies.json", "!**/game-creator.config*.json", ]; #[derive(Clone, Debug, Eq, PartialEq)] pub(crate) struct ProjectCommandSpec { pub(crate) program: String, pub(crate) executable: PathBuf, pub(crate) safe_path: OsString, pub(crate) arguments: Vec, pub(crate) cwd_relative: String, pub(crate) cwd: PathBuf, pub(crate) timeout_seconds: u64, pub(crate) verification_eligible: bool, } #[derive(Clone, Debug, Eq, PartialEq)] pub(crate) struct ProjectCommandLaunchSpec { pub(crate) executable: PathBuf, pub(crate) arguments: Vec, pub(crate) cwd: PathBuf, pub(crate) environment: Vec<(OsString, OsString)>, pub(crate) sandbox_backend: String, pub(crate) sandbox_mode: String, pub(crate) network_access: String, pub(crate) sandbox_profile_version: String, } #[derive(Debug)] pub(crate) struct StagedProjectCommandLaunchSpec { pub(crate) launch: ProjectCommandLaunchSpec, #[cfg(target_os = "linux")] pub(crate) gate: LaunchGate, } #[derive(Debug)] pub(crate) struct EstablishedProjectCommand { pub(crate) child: tokio::process::Child, #[cfg(target_os = "linux")] pub(crate) gate: LaunchGate, } #[derive(Clone, Debug, Eq, PartialEq)] pub(crate) struct ProjectCommandResult { pub(crate) command_id: String, pub(crate) program: String, pub(crate) arguments: Vec, pub(crate) cwd_relative: String, pub(crate) status: String, pub(crate) exit_code: Option, pub(crate) timed_out: bool, pub(crate) duration_ms: u64, pub(crate) output: String, pub(crate) capture_truncated: bool, pub(crate) output_ref: Option, pub(crate) output_sha256: String, pub(crate) total_lines: usize, pub(crate) source_fingerprint_before: String, pub(crate) source_fingerprint_after: String, pub(crate) source_changed: bool, pub(crate) verification_eligible: bool, pub(crate) sandbox_backend: String, pub(crate) sandbox_mode: String, pub(crate) network_access: String, pub(crate) sandbox_profile_version: String, pub(crate) log_path: String, pub(crate) updated_at: u64, } #[derive(Clone, Copy, Debug, Eq, PartialEq)] pub(crate) enum ProjectCommandErrorStage { Validation, Preflight, Spawn, DurableCommit, TargetExec, LaunchUnknown, Execution, PostExecutionFingerprint, OutputSidecar, AuditLog, ManifestProjection, } #[derive(Clone, Debug, Eq, PartialEq)] pub(crate) struct ProjectCommandError { stage: ProjectCommandErrorStage, message: String, } impl ProjectCommandError { pub(crate) fn new(stage: ProjectCommandErrorStage, message: impl Into) -> Self { Self { stage, message: sanitize_project_verification_output(&message.into()), } } pub(crate) fn stage(&self) -> ProjectCommandErrorStage { self.stage } pub(crate) fn message(&self) -> &str { &self.message } pub(crate) fn execution_started(&self) -> bool { matches!( self.stage, ProjectCommandErrorStage::LaunchUnknown | ProjectCommandErrorStage::Execution | ProjectCommandErrorStage::PostExecutionFingerprint | ProjectCommandErrorStage::OutputSidecar | ProjectCommandErrorStage::AuditLog | ProjectCommandErrorStage::ManifestProjection ) } pub(crate) fn needs_reconciliation(&self) -> bool { self.execution_started() } } impl ProjectCommandErrorStage { pub(crate) fn as_str(self) -> &'static str { match self { Self::Validation => "validation", Self::Preflight => "preflight", Self::Spawn => "spawn", Self::DurableCommit => "durable-commit", Self::TargetExec => "target-exec", Self::LaunchUnknown => "launch-unknown", Self::Execution => "execution", Self::PostExecutionFingerprint => "post-execution-fingerprint", Self::OutputSidecar => "output-sidecar", Self::AuditLog => "audit-log", Self::ManifestProjection => "manifest-projection", } } } impl std::fmt::Display for ProjectCommandError { fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { formatter.write_str(&self.message) } } impl std::error::Error for ProjectCommandError {} impl std::ops::Deref for ProjectCommandError { type Target = str; fn deref(&self) -> &Self::Target { self.message() } } #[derive(Debug)] struct ProjectCommandProcessResult { exit_code: Option, timed_out: bool, output: String, capture_truncated: bool, } #[derive(Debug)] struct BoundedCommandOutput { text: String, truncated: bool, } #[derive(Debug)] struct BoundedCommandBytes { head: Vec, tail: VecDeque, total: usize, max_bytes: usize, } impl BoundedCommandBytes { fn new(max_bytes: usize) -> Self { Self { head: Vec::new(), tail: VecDeque::new(), total: 0, max_bytes, } } fn push(&mut self, chunk: &[u8]) { self.total = self.total.saturating_add(chunk.len()); let head_limit = self.max_bytes / 3; let tail_limit = self.max_bytes.saturating_sub(head_limit); let head_len = head_limit.saturating_sub(self.head.len()).min(chunk.len()); self.head.extend_from_slice(&chunk[..head_len]); self.tail.extend(&chunk[head_len..]); while self.tail.len() > tail_limit { self.tail.pop_front(); } } fn finish(self) -> BoundedCommandOutput { let tail = self.tail.into_iter().collect::>(); if self.total <= self.max_bytes { let mut bytes = self.head; bytes.extend(tail); return BoundedCommandOutput { text: String::from_utf8_lossy(&bytes).into_owned(), truncated: false, }; } let omitted = self.total.saturating_sub(self.head.len() + tail.len()); BoundedCommandOutput { text: format!( "{}\n...<{} output bytes omitted>...\n{}", String::from_utf8_lossy(&self.head), omitted, String::from_utf8_lossy(&tail) ), truncated: true, } } } pub(crate) fn resolve_project_command_spec_at( root: &Path, program: &str, arguments: &[String], cwd: &str, timeout_seconds: u64, ) -> Result { resolve_project_command_spec_inner(root, program, arguments, cwd, timeout_seconds) .map_err(|error| ProjectCommandError::new(ProjectCommandErrorStage::Validation, error)) } /// Resolves the one privileged npm operation used to hydrate a DirectProject. /// It intentionally bypasses the general command.exec npm allow-list: callers /// must use the dedicated `project.bootstrap` action, which only accepts the /// literal `npm install` in the project's `game` directory. pub(crate) fn resolve_project_bootstrap_spec_at( root: &Path, timeout_seconds: u64, ) -> Result { validate_project_root(root) .map_err(|error| ProjectCommandError::new(ProjectCommandErrorStage::Validation, error))?; let cwd_relative = "game".to_string(); let cwd = resolve_local_project_path(root, &cwd_relative) .map_err(|error| ProjectCommandError::new(ProjectCommandErrorStage::Validation, error))?; validate_project_command_cwd_components(root, &cwd_relative) .map_err(|error| ProjectCommandError::new(ProjectCommandErrorStage::Validation, error))?; if !(PROJECT_COMMAND_MIN_TIMEOUT_SECONDS..=PROJECT_COMMAND_MAX_TIMEOUT_SECONDS) .contains(&timeout_seconds) { return Err(ProjectCommandError::new( ProjectCommandErrorStage::Validation, format!("project.bootstrap timeoutSeconds 必须在 {PROJECT_COMMAND_MIN_TIMEOUT_SECONDS}-{PROJECT_COMMAND_MAX_TIMEOUT_SECONDS} 之间"), )); } let program = "npm".to_string(); let (executable, safe_path) = resolve_project_command_executable(root, &program) .map_err(|error| ProjectCommandError::new(ProjectCommandErrorStage::Validation, error))?; Ok(ProjectCommandSpec { program, executable, safe_path, arguments: vec!["install".to_string()], cwd_relative, cwd, timeout_seconds, verification_eligible: false, }) } fn resolve_project_command_spec_inner( root: &Path, program: &str, arguments: &[String], cwd: &str, timeout_seconds: u64, ) -> Result { validate_project_root(root)?; if !root.is_dir() { return Err("command.exec 要求项目目录已存在".to_string()); } if !(PROJECT_COMMAND_MIN_TIMEOUT_SECONDS..=PROJECT_COMMAND_MAX_TIMEOUT_SECONDS) .contains(&timeout_seconds) { return Err(format!( "command.exec timeoutSeconds 必须在 {PROJECT_COMMAND_MIN_TIMEOUT_SECONDS}-{PROJECT_COMMAND_MAX_TIMEOUT_SECONDS} 之间" )); } let program = normalize_project_command_program(program)?; validate_project_command_arguments(&program, arguments)?; let cwd_relative = normalize_project_command_cwd(cwd)?; let cwd = if cwd_relative == "." { root.to_path_buf() } else { resolve_local_project_path(root, &cwd_relative)? }; validate_project_command_cwd_components(root, &cwd_relative)?; if program == "node" { validate_project_command_node_test_files(&cwd, arguments)?; } let (executable, safe_path) = resolve_project_command_executable(root, &program)?; let verification_eligible = project_command_verification_eligible(&program, arguments); Ok(ProjectCommandSpec { program, executable, safe_path, arguments: arguments.to_vec(), cwd_relative, cwd, timeout_seconds, verification_eligible, }) } fn normalize_project_command_program(value: &str) -> Result { let value = value.trim(); #[cfg(target_os = "linux")] { if value.is_empty() || value.len() > 64 || !value.chars().all(|character| { character.is_ascii_alphanumeric() || matches!(character, '-' | '_' | '.' | '+') }) { return Err( "command.exec program 必须是 1-64 个 ASCII 字母、数字、点、下划线、加号或连字符组成的裸可执行名" .to_string(), ); } Ok(value.to_string()) } #[cfg(not(target_os = "linux"))] { let value = value.to_ascii_lowercase(); if !matches!(value.as_str(), "cargo" | "npm" | "node" | "git" | "rg") { return Err("command.exec program 只允许 cargo、npm、node、git 或 rg".to_string()); } Ok(value) } } fn validate_project_command_cwd_components(root: &Path, relative_path: &str) -> Result<(), String> { let mut current = root.to_path_buf(); validate_project_command_cwd_component(¤t)?; if relative_path != "." { for component in relative_path.split('/') { current.push(component); validate_project_command_cwd_component(¤t)?; } } if !current.is_dir() { return Err("command.exec cwd 必须是项目内普通目录".to_string()); } Ok(()) } fn validate_project_command_cwd_component(path: &Path) -> Result<(), String> { let metadata = fs::symlink_metadata(path) .map_err(|error| format!("command.exec cwd 不可用:{}: {error}", path.display()))?; if metadata.file_type().is_symlink() || project_command_metadata_is_reparse_point(&metadata) { return Err("command.exec cwd 的任一层级都不能是符号链接或 reparse point".to_string()); } if !metadata.is_dir() { return Err("command.exec cwd 必须是项目内普通目录".to_string()); } Ok(()) } #[cfg(windows)] fn project_command_metadata_is_reparse_point(metadata: &fs::Metadata) -> bool { use std::os::windows::fs::MetadataExt; const FILE_ATTRIBUTE_REPARSE_POINT: u32 = 0x0000_0400; metadata.file_attributes() & FILE_ATTRIBUTE_REPARSE_POINT != 0 } #[cfg(not(windows))] fn project_command_metadata_is_reparse_point(_metadata: &fs::Metadata) -> bool { false } fn validate_project_command_node_test_files( cwd: &Path, arguments: &[String], ) -> Result<(), String> { let files = arguments .get(1..) .filter(|files| !files.is_empty()) .ok_or_else(|| "command.exec node --test 至少需要一个项目内测试文件".to_string())?; for relative_path in files { if relative_path.starts_with('-') || relative_path.contains(['*', '?', '[', ']', '{', '}']) || relative_path.contains('\\') { return Err( "command.exec node --test 只接受精确的项目内测试文件路径,不接受额外 Node 选项或 glob" .to_string(), ); } let normalized = normalize_relative_path(relative_path)?; if normalized != *relative_path { return Err("command.exec node --test 文件路径必须是规范相对路径".to_string()); } let extension = Path::new(&normalized) .extension() .and_then(|value| value.to_str()) .unwrap_or_default() .to_ascii_lowercase(); if !matches!( extension.as_str(), "js" | "mjs" | "cjs" | "ts" | "mts" | "cts" ) { return Err( "command.exec node --test 只接受 JavaScript / TypeScript 测试文件".to_string(), ); } let mut current = cwd.to_path_buf(); for component in normalized.split('/') { current.push(component); let metadata = fs::symlink_metadata(¤t).map_err(|error| { format!( "command.exec node 测试文件不可用:{}: {error}", current.display() ) })?; if metadata.file_type().is_symlink() || project_command_metadata_is_reparse_point(&metadata) { return Err( "command.exec node 测试文件的任一层级都不能是符号链接或 reparse point" .to_string(), ); } } if !current.is_file() { return Err("command.exec node --test 目标必须是项目内普通文件".to_string()); } } Ok(()) } fn project_command_verification_eligible(program: &str, arguments: &[String]) -> bool { match program { "cargo" => matches!( arguments.first().map(String::as_str), Some("check" | "test" | "clippy" | "fmt" | "build") ), "npm" => match arguments.first().map(String::as_str) { Some("test") => true, Some("run") => arguments .get(1) .is_some_and(|script| project_command_npm_verification_script_allowed(script)), _ => false, }, "node" => arguments .first() .is_some_and(|argument| argument == "--test"), "git" | "rg" => false, _ => false, } } fn project_command_npm_verification_script_allowed(script: &str) -> bool { if matches!(script, "check" | "typecheck" | "test" | "lint" | "build") { return true; } [ "check:", "typecheck:", "test:", "lint:", "build:", "verify:", "validate:", ] .iter() .any(|prefix| { script .strip_prefix(prefix) .is_some_and(project_command_npm_verification_script_suffix_allowed) }) } fn project_command_npm_verification_script_suffix_allowed(suffix: &str) -> bool { suffix.split(':').all(|segment| { let mut characters = segment.chars(); characters .next() .is_some_and(|character| character.is_ascii_alphanumeric()) && characters.all(|character| { character.is_ascii_alphanumeric() || matches!(character, '-' | '_' | '.') }) }) } fn resolve_project_command_executable( root: &Path, program: &str, ) -> Result<(PathBuf, OsString), String> { #[cfg(target_os = "linux")] let path = std::env::join_paths([ PathBuf::from("/usr/local/sbin"), PathBuf::from("/usr/local/bin"), PathBuf::from("/usr/sbin"), PathBuf::from("/usr/bin"), PathBuf::from("/sbin"), PathBuf::from("/bin"), ]) .map_err(|error| format!("构造 command.exec 受信任系统 PATH 失败:{error}"))?; #[cfg(not(target_os = "linux"))] let path = std::env::var_os("PATH").ok_or_else(|| "command.exec 缺少 PATH".to_string())?; resolve_project_command_executable_from_path(root, program, &path) } fn resolve_project_command_executable_from_path( root: &Path, program: &str, path: &OsStr, ) -> Result<(PathBuf, OsString), String> { let root = fs::canonicalize(root) .map_err(|error| format!("解析 command.exec 项目根目录失败:{error}"))?; let mut safe_directories = Vec::new(); let mut seen_directories = HashSet::new(); let mut executable = None; for directory in std::env::split_paths(path) { if !directory.is_absolute() { continue; } let canonical_directory = match fs::canonicalize(&directory) { Ok(directory) if directory.is_absolute() && directory.is_dir() && !directory.starts_with(&root) => { directory } _ => continue, }; if !seen_directories.insert(canonical_directory.clone()) { continue; } safe_directories.push(canonical_directory.clone()); if executable.is_some() { continue; } for name in project_command_executable_names(program) { let candidate = canonical_directory.join(name); if !candidate.is_absolute() || candidate.starts_with(&root) { continue; } let canonical_candidate = match fs::canonicalize(&candidate) { Ok(candidate) if candidate.is_absolute() && candidate.is_file() && !candidate.starts_with(&root) => { candidate } _ => continue, }; let metadata = match fs::metadata(&canonical_candidate) { Ok(metadata) if metadata.is_file() => metadata, _ => continue, }; if !project_command_file_is_executable(&metadata) { continue; } let canonical_parent = canonical_candidate.parent().unwrap_or(&canonical_directory); if canonical_parent.starts_with(&root) { continue; } // Preserve argv[0] proxy semantics (notably rustup's cargo proxy) while // requiring both the absolute entry and its resolved target to stay // outside the project. executable = Some(candidate); break; } } let executable = executable.ok_or_else(|| format!("command.exec 找不到受信任的 {program} 可执行文件"))?; #[cfg(windows)] let safe_directories = safe_directories .into_iter() .map(|directory| { let directory = directory.to_string_lossy(); PathBuf::from(directory.strip_prefix(r"\\?\").unwrap_or(&directory)) }) .collect::>(); let safe_path = std::env::join_paths(safe_directories) .map_err(|error| format!("构造 command.exec 安全 PATH 失败:{error}"))?; Ok((executable, safe_path)) } #[cfg(windows)] fn project_command_executable_names(program: &str) -> Vec { match program { "npm" => vec!["npm.cmd".to_string()], _ => vec![format!("{program}.exe")], } } #[cfg(not(windows))] fn project_command_executable_names(program: &str) -> Vec { vec![program.to_string()] } #[cfg(unix)] fn project_command_file_is_executable(metadata: &fs::Metadata) -> bool { use std::os::unix::fs::PermissionsExt; metadata.permissions().mode() & 0o111 != 0 } #[cfg(not(unix))] fn project_command_file_is_executable(_metadata: &fs::Metadata) -> bool { true } fn normalize_project_command_cwd(value: &str) -> Result { let value = value.trim(); if value.is_empty() || value == "." { return Ok(".".to_string()); } if project_command_argument_is_external_path(value) || project_command_argument_contains_sensitive_path(value) { return Err("command.exec cwd 必须是项目内非敏感相对目录".to_string()); } let normalized = value.replace('\\', "/"); if normalized != value || normalized.ends_with('/') || normalized.contains("//") { return Err("command.exec cwd 必须使用规范正斜杠相对路径".to_string()); } Ok(normalized) } fn validate_project_command_arguments(program: &str, arguments: &[String]) -> Result<(), String> { #[cfg(not(target_os = "linux"))] if arguments.is_empty() { return Err("command.exec args 不能为空".to_string()); } if arguments.len() > PROJECT_COMMAND_MAX_ARGUMENTS { return Err(format!( "command.exec args 不能超过 {PROJECT_COMMAND_MAX_ARGUMENTS} 项" )); } let mut total_bytes = 0usize; for argument in arguments { if argument.is_empty() || argument.chars().count() > PROJECT_COMMAND_MAX_ARGUMENT_CHARS || argument.chars().any(char::is_control) { return Err(format!( "command.exec 每个 argv 必须是 1-{PROJECT_COMMAND_MAX_ARGUMENT_CHARS} 个无控制字符的字符" )); } total_bytes = total_bytes.saturating_add(argument.len()); #[cfg(not(target_os = "linux"))] if project_command_argument_is_external_path(argument) || project_command_argument_contains_sensitive_path(argument) { return Err("command.exec argv 不能引用项目外或敏感路径".to_string()); } } if total_bytes > PROJECT_COMMAND_MAX_ARGUMENT_BYTES { return Err(format!( "command.exec args 总长度不能超过 {PROJECT_COMMAND_MAX_ARGUMENT_BYTES} 字节" )); } #[cfg(target_os = "linux")] { let _ = program; Ok(()) } #[cfg(not(target_os = "linux"))] { match program { "cargo" => validate_cargo_arguments(arguments), "npm" => validate_npm_arguments(arguments), "node" => validate_node_arguments(arguments), "git" => validate_git_arguments(arguments), "rg" => validate_rg_arguments(arguments), _ => unreachable!("program whitelist checked above"), } } } fn project_command_argument_is_external_path(value: &str) -> bool { if Path::new(value).is_absolute() || value.starts_with("//") || value.starts_with("\\\\") || value.split(['/', '\\']).any(|component| component == "..") { return true; } let bytes = value.as_bytes(); bytes.len() >= 2 && bytes[0].is_ascii_alphabetic() && bytes[1] == b':' } fn project_command_argument_contains_sensitive_path(value: &str) -> bool { let normalized = value.replace('\\', "/").to_ascii_lowercase(); let components = normalized.split('/').collect::>(); if components.iter().any(|component| { matches!( *component, ".agent" | ".git" | ".agents" | ".codex" | ".hg" | ".svn" | ".ssh" | ".aws" | ".azure" | ".gnupg" | ".kube" | ".docker" | ".gcloud" | ".password-store" | ".secrets" | "secrets" | "credentials" ) }) { return true; } let file_name = components.last().copied().unwrap_or_default(); file_name == ".env" || file_name.starts_with(".env.") || matches!( file_name, ".npmrc" | ".pypirc" | ".netrc" | ".git-credentials" | "credentials.json" | "auth.json" | "secrets.json" | "cookies.json" | "game-creator.config.json" | "game-creator.config.local.json" ) || [ ".pem", ".p12", ".pfx", ".key", ".kdbx", ".sqlite", ".sqlite3", ".dump", ] .iter() .any(|suffix| file_name.ends_with(suffix)) } fn argument_matches_option(argument: &str, option: &str) -> bool { argument == option || argument.starts_with(&format!("{option}=")) } fn validate_cargo_arguments(arguments: &[String]) -> Result<(), String> { let subcommand = arguments.first().map(String::as_str).unwrap_or_default(); if !matches!( subcommand, "check" | "test" | "clippy" | "fmt" | "build" | "metadata" ) { return Err( "command.exec cargo 只允许 check、test、clippy、fmt、build 或 metadata".to_string(), ); } if arguments.iter().any(|argument| { [ "--config", "--manifest-path", "--target-dir", "--registry", "--index", ] .iter() .any(|option| argument_matches_option(argument, option)) }) { return Err("command.exec cargo 禁止覆盖配置、manifest、target 或 registry".to_string()); } if subcommand == "fmt" && !arguments.iter().any(|argument| argument == "--check") { return Err("command.exec cargo fmt 必须包含 --check".to_string()); } Ok(()) } fn validate_npm_arguments(arguments: &[String]) -> Result<(), String> { let subcommand = arguments.first().map(String::as_str).unwrap_or_default(); if !matches!(subcommand, "test" | "run") { return Err("command.exec npm 只允许 test 或 run".to_string()); } if subcommand == "run" && arguments .iter() .skip(1) .map(String::as_str) .find(|value| !value.starts_with('-')) .is_none() { return Err("command.exec npm run 缺少脚本名".to_string()); } if arguments.iter().any(|argument| { [ "--prefix", "--userconfig", "--script-shell", "--registry", "--global", "--location", "--cache", ] .iter() .any(|option| argument_matches_option(argument, option)) }) { return Err("command.exec npm 禁止覆盖 prefix、配置、shell、registry 或 cache".to_string()); } if arguments.iter().skip(1).any(|argument| { argument != "--" && (argument.chars().any(|character| { character.is_whitespace() || matches!( character, ';' | '&' | '|' | '<' | '>' | '`' | '$' | '(' | ')' | '{' | '}' ) }) || argument.contains("\\n") || argument.contains("\\r")) }) { return Err("command.exec npm argv 不能包含 shell 元字符或空白".to_string()); } Ok(()) } fn validate_node_arguments(arguments: &[String]) -> Result<(), String> { if arguments.first().map(String::as_str) != Some("--test") || arguments.len() < 2 { return Err("command.exec node 只允许精确的 node --test <项目内测试文件...>".to_string()); } if arguments.iter().skip(1).any(|argument| { argument.starts_with('-') || argument.contains(['*', '?', '[', ']', '{', '}']) || argument.contains('\\') }) { return Err( "command.exec node --test 只接受项目内普通测试文件,不接受额外 Node 选项或 glob" .to_string(), ); } Ok(()) } fn validate_git_arguments(arguments: &[String]) -> Result<(), String> { let subcommand = arguments.first().map(String::as_str).unwrap_or_default(); if !matches!( subcommand, "status" | "diff" | "log" | "show" | "grep" | "ls-files" | "rev-parse" ) { return Err("command.exec git 只允许只读审阅子命令".to_string()); } if arguments.iter().any(|argument| { [ "-c", "--config-env", "--git-dir", "--work-tree", "--exec-path", "--paginate", "--pager", "--ext-diff", "--textconv", "--output", "--pathspec-from-file", "--pathspec-file-nul", "--exclude-from", "--exclude-per-directory", "--show-signature", "--verify-signatures", "--use-mailmap", "--mailmap", "--alternate-refs", "--no-index", "--recurse-submodules", "--literal-pathspecs", "--glob-pathspecs", "--noglob-pathspecs", "--icase-pathspecs", ] .iter() .any(|option| argument_matches_option(argument, option)) || argument_matches_short_option(argument, 'O') || (subcommand == "grep" && argument_matches_short_option(argument, 'f')) || argument_matches_option(argument, "--open-files-in-pager") || argument.starts_with(':') || project_command_git_argument_contains_sensitive_object_path(argument) }) { return Err( "command.exec git 禁止间接读取、外部执行、pager、危险 pathspec 或敏感对象路径" .to_string(), ); } let mut pathspecs_started = false; for argument in arguments.iter().skip(1) { if argument == "--" { pathspecs_started = true; continue; } if pathspecs_started && project_command_git_path_is_sensitive(argument) { return Err("command.exec git 禁止读取敏感 pathspec".to_string()); } } Ok(()) } fn validate_rg_arguments(arguments: &[String]) -> Result<(), String> { if arguments.iter().any(|argument| { [ "--pre", "--pre-glob", "--hostname-bin", "--ignore-file", "--files-from", "--file", "--follow", "--hidden", "--search-zip", "--glob", "--iglob", "--type", "--type-not", "--type-add", "--type-clear", ] .iter() .any(|option| argument_matches_option(argument, option)) || argument.starts_with("--glob-") || argument == "--no-ignore" || argument.starts_with("--no-ignore=") || argument.starts_with("--no-ignore-") || ['L', 'u', 'z', 'g', 'f', 't', 'T', '.'] .iter() .any(|option| argument_matches_short_option(argument, *option)) }) { return Err( "command.exec rg 禁止跟随链接、隐藏/忽略绕过、压缩包、预处理器、外部文件或自定义 glob" .to_string(), ); } Ok(()) } fn argument_matches_short_option(argument: &str, option: char) -> bool { argument.starts_with('-') && !argument.starts_with("--") && argument.chars().skip(1).any(|value| value == option) } fn project_command_git_argument_contains_sensitive_object_path(argument: &str) -> bool { let Some((_, object_path)) = argument.split_once(':') else { return false; }; if object_path.is_empty() { return false; } project_command_argument_is_external_path(object_path) || project_command_git_path_is_sensitive(object_path) } fn project_command_git_path_is_sensitive(value: &str) -> bool { let normalized = value .replace('\\', "/") .trim_start_matches("./") .trim_start_matches('/') .to_ascii_lowercase(); if project_command_argument_contains_sensitive_path(&normalized) { return true; } let components = normalized .split('/') .filter(|component| !component.is_empty()) .collect::>(); if components.iter().any(|component| { matches!( *component, ".git" | ".env" | "key" | "keys" | "config" | "secrets" | "credentials" ) }) { return true; } let file_name = components.last().copied().unwrap_or_default(); file_name.starts_with(".env.") || file_name.starts_with("key.") || file_name.starts_with("config.") || file_name.ends_with(".key") } pub(crate) fn project_command_actual_arguments(spec: &ProjectCommandSpec) -> Vec { #[cfg(target_os = "linux")] { return spec.arguments.clone(); } #[cfg(not(target_os = "linux"))] match spec.program.as_str() { "npm" => std::iter::once("--ignore-scripts".to_string()) .chain(spec.arguments.iter().cloned()) .collect(), "git" => { let mut arguments = vec![ "-c".to_string(), "core.pager=cat".to_string(), "-c".to_string(), "diff.external=".to_string(), "-c".to_string(), "core.fsmonitor=false".to_string(), "-c".to_string(), "log.showSignature=false".to_string(), "-c".to_string(), "submodule.recurse=false".to_string(), "--no-pager".to_string(), ]; let subcommand = spec .arguments .first() .expect("validated git subcommand") .clone(); arguments.push(subcommand); if matches!( spec.arguments.first().map(String::as_str), Some("diff" | "log" | "show") ) { arguments.push("--no-ext-diff".to_string()); arguments.push("--no-textconv".to_string()); } arguments.extend(spec.arguments.iter().skip(1).cloned()); if matches!( spec.arguments.first().map(String::as_str), Some("diff" | "log" | "show" | "grep") ) { if !spec.arguments.iter().any(|argument| argument == "--") { arguments.push("--".to_string()); } arguments.extend( PROJECT_COMMAND_SENSITIVE_GIT_PATHSPECS .iter() .map(|pathspec| (*pathspec).to_string()), ); } arguments } "rg" => { let mut arguments = vec![ "--no-config".to_string(), "--no-follow".to_string(), "--no-hidden".to_string(), ]; let separator = spec.arguments.iter().position(|argument| argument == "--"); let user_options_end = separator.unwrap_or(spec.arguments.len()); arguments.extend(spec.arguments[..user_options_end].iter().cloned()); for glob in PROJECT_COMMAND_SENSITIVE_RG_GLOBS { arguments.push("--glob".to_string()); arguments.push((*glob).to_string()); } if let Some(separator) = separator { arguments.extend(spec.arguments[separator..].iter().cloned()); } arguments } _ => spec.arguments.clone(), } } pub(crate) fn prepare_project_command_launch_spec( root: &Path, spec: &ProjectCommandSpec, ) -> Result { let isolated_home = resolve_local_project_path(root, ".agent/runtime/command-env/home") .map_err(|error| ProjectCommandError::new(ProjectCommandErrorStage::Preflight, error))?; let isolated_tmp = resolve_local_project_path(root, ".agent/runtime/command-env/tmp") .map_err(|error| ProjectCommandError::new(ProjectCommandErrorStage::Preflight, error))?; let isolated_cache = resolve_local_project_path(root, ".agent/runtime/command-env/cache") .map_err(|error| ProjectCommandError::new(ProjectCommandErrorStage::Preflight, error))?; fs::create_dir_all(&isolated_home) .and_then(|()| fs::create_dir_all(&isolated_tmp)) .and_then(|()| fs::create_dir_all(&isolated_cache)) .map_err(|error| { ProjectCommandError::new( ProjectCommandErrorStage::Preflight, format!("创建 command.exec 隔离目录失败:{error}"), ) })?; let mut environment = vec![ (OsString::from("CI"), OsString::from("1")), (OsString::from("NO_COLOR"), OsString::from("1")), (OsString::from("FORCE_COLOR"), OsString::from("0")), (OsString::from("TERM"), OsString::from("dumb")), (OsString::from("HOME"), isolated_home.as_os_str().to_owned()), ( OsString::from("USERPROFILE"), isolated_home.as_os_str().to_owned(), ), ( OsString::from("TMPDIR"), isolated_tmp.as_os_str().to_owned(), ), (OsString::from("TEMP"), isolated_tmp.as_os_str().to_owned()), (OsString::from("TMP"), isolated_tmp.as_os_str().to_owned()), ( OsString::from("CARGO_HOME"), isolated_cache.join("cargo").into_os_string(), ), (OsString::from("CARGO_NET_OFFLINE"), OsString::from("true")), (OsString::from("CARGO_TERM_COLOR"), OsString::from("never")), // 受控命令不得继承用户级 Cargo rustc-wrapper(例如 sccache); // 隔离 HOME/CARGO_HOME 下这类包装器既不可复现,也可能无法启动。 (OsString::from("RUSTC_WRAPPER"), OsString::new()), (OsString::from("RUSTC_WORKSPACE_WRAPPER"), OsString::new()), (OsString::from("npm_config_audit"), OsString::from("false")), (OsString::from("npm_config_fund"), OsString::from("false")), ( OsString::from("npm_config_ignore_scripts"), OsString::from("true"), ), (OsString::from("npm_config_offline"), OsString::from("true")), ( OsString::from("npm_config_update_notifier"), OsString::from("false"), ), ( OsString::from("npm_config_cache"), isolated_cache.join("npm").into_os_string(), ), ( OsString::from("npm_config_userconfig"), isolated_home.join("empty-user.npmrc").into_os_string(), ), (OsString::from("GIT_CONFIG_NOSYSTEM"), OsString::from("1")), ( OsString::from("GIT_CONFIG_GLOBAL"), isolated_home.join("empty-gitconfig").into_os_string(), ), (OsString::from("GIT_PAGER"), OsString::from("cat")), (OsString::from("GIT_EXTERNAL_DIFF"), OsString::new()), (OsString::from("PAGER"), OsString::from("cat")), ( OsString::from("HTTP_PROXY"), OsString::from("http://127.0.0.1:9"), ), ( OsString::from("HTTPS_PROXY"), OsString::from("http://127.0.0.1:9"), ), ( OsString::from("ALL_PROXY"), OsString::from("http://127.0.0.1:9"), ), (OsString::from("NO_PROXY"), OsString::new()), (OsString::from("PATH"), spec.safe_path.clone()), ]; if spec .arguments .first() .is_some_and(|argument| argument == "install") { for (name, value) in &mut environment { match name.to_string_lossy().as_ref() { "npm_config_offline" => *value = OsString::from("false"), "HTTP_PROXY" | "HTTPS_PROXY" | "ALL_PROXY" => *value = OsString::new(), _ => {} } } } for name in ["SystemRoot", "PATHEXT", "RUSTUP_HOME"] { if let Some(value) = std::env::var_os(name) { environment.push((OsString::from(name), value)); } } #[cfg(target_os = "linux")] if !environment .iter() .any(|(name, _)| name == OsStr::new("RUSTUP_HOME")) { if let Some(home) = std::env::var_os("HOME") { let rustup_home = PathBuf::from(home).join(".rustup"); if rustup_home.is_dir() { environment.push((OsString::from("RUSTUP_HOME"), rustup_home.into_os_string())); } } } #[cfg(windows)] if let Some(system_root) = std::env::var_os("SystemRoot") { environment.push(( OsString::from("ComSpec"), PathBuf::from(system_root) .join("System32") .join("cmd.exe") .into_os_string(), )); } #[cfg(windows)] for (_, value) in &mut environment { let rendered = value.to_string_lossy(); if let Some(without_prefix) = rendered.strip_prefix(r"\\?\") { *value = OsString::from(without_prefix); } } let arguments = project_command_actual_arguments(spec); #[cfg(target_os = "linux")] { let sandbox = prepare_command_sandbox_launch( root, &spec.executable, &arguments, &spec.cwd, &environment, ) .map_err(|error| { ProjectCommandError::new( ProjectCommandErrorStage::Preflight, format!("command.exec sandbox unavailable:{error}"), ) })?; Ok(ProjectCommandLaunchSpec { executable: sandbox.executable, arguments: sandbox.arguments, cwd: sandbox.cwd, environment: sandbox.environment, sandbox_backend: sandbox.metadata.backend.to_string(), sandbox_mode: sandbox.metadata.mode.to_string(), network_access: sandbox.metadata.network.to_string(), sandbox_profile_version: sandbox.metadata.profile_version.to_string(), }) } #[cfg(not(target_os = "linux"))] { #[cfg(windows)] let (executable, arguments, cwd) = { fn without_windows_verbatim_prefix(path: PathBuf) -> PathBuf { let value = path.to_string_lossy(); PathBuf::from(value.strip_prefix(r"\\?\").unwrap_or(&value)) } let is_npm_batch = spec .executable .file_name() .and_then(|name| name.to_str()) .is_some_and(|name| name.eq_ignore_ascii_case("npm.cmd")); if is_npm_batch { let npm_directory = spec.executable.parent().ok_or_else(|| { ProjectCommandError::new( ProjectCommandErrorStage::Preflight, "command.exec 无法定位 Windows npm 安装目录", ) })?; let node_executable = npm_directory.join("node.exe"); let npm_cli = npm_directory.join("node_modules/npm/bin/npm-cli.js"); if !node_executable.is_file() || !npm_cli.is_file() { return Err(ProjectCommandError::new( ProjectCommandErrorStage::Preflight, "command.exec Windows npm 安装缺少 node.exe 或 npm-cli.js", )); } let node_executable = fs::canonicalize(node_executable).map_err(|error| { ProjectCommandError::new( ProjectCommandErrorStage::Preflight, format!("command.exec 定位 Windows node.exe 失败:{error}"), ) })?; let npm_cli = fs::canonicalize(npm_cli).map_err(|error| { ProjectCommandError::new( ProjectCommandErrorStage::Preflight, format!("command.exec 定位 Windows npm-cli.js 失败:{error}"), ) })?; let node_executable = without_windows_verbatim_prefix(node_executable); let npm_cli = without_windows_verbatim_prefix(npm_cli); let mut node_arguments = vec![npm_cli.into_os_string()]; node_arguments.extend(arguments.into_iter().map(OsString::from)); ( node_executable, node_arguments, without_windows_verbatim_prefix(spec.cwd.clone()), ) } else { ( without_windows_verbatim_prefix(spec.executable.clone()), arguments.into_iter().map(OsString::from).collect(), without_windows_verbatim_prefix(spec.cwd.clone()), ) } }; #[cfg(not(windows))] let (executable, arguments, cwd) = ( spec.executable.clone(), arguments.into_iter().map(OsString::from).collect(), spec.cwd.clone(), ); Ok(ProjectCommandLaunchSpec { executable, arguments, cwd, environment, sandbox_backend: "legacy-host-restricted".to_string(), sandbox_mode: "fixed-command".to_string(), network_access: "proxy-only".to_string(), sandbox_profile_version: "legacy-v1".to_string(), }) } } pub(crate) fn stage_project_command_launch_spec( spec: &ProjectCommandSpec, launch: ProjectCommandLaunchSpec, ) -> Result { #[cfg(target_os = "linux")] { let target_arguments = project_command_actual_arguments(spec) .into_iter() .map(OsString::from) .collect::>(); let staged = stage_command_sandbox_launch( CommandSandboxLaunch { executable: launch.executable, arguments: launch.arguments, cwd: launch.cwd, environment: launch.environment, metadata: command_sandbox_platform_metadata(), }, &spec.executable, &target_arguments, ) .map_err(|error| { ProjectCommandError::new( ProjectCommandErrorStage::Preflight, format!("command.exec sandbox staged launch 失败:{error}"), ) })?; Ok(StagedProjectCommandLaunchSpec { launch: ProjectCommandLaunchSpec { executable: staged.launch.executable, arguments: staged.launch.arguments, cwd: staged.launch.cwd, environment: staged.launch.environment, sandbox_backend: staged.launch.metadata.backend.to_string(), sandbox_mode: staged.launch.metadata.mode.to_string(), network_access: staged.launch.metadata.network.to_string(), sandbox_profile_version: staged.launch.metadata.profile_version.to_string(), }, gate: staged.gate, }) } #[cfg(not(target_os = "linux"))] { let _ = spec; Ok(StagedProjectCommandLaunchSpec { launch }) } } fn configure_project_command_process_group( command: &mut tokio::process::Command, launch: &ProjectCommandLaunchSpec, ) { #[cfg(unix)] { use std::os::unix::process::CommandExt; command.as_std_mut().process_group(0); } #[cfg(windows)] { let npm_cli_host = launch .arguments .first() .and_then(|argument| Path::new(argument).file_name()) .and_then(|name| name.to_str()) .is_some_and(|name| name.eq_ignore_ascii_case("npm-cli.js")); let _ = npm_cli_host; crate::configure_windows_background_tokio_command(command, true); } } pub(crate) async fn spawn_staged_project_command( staged: StagedProjectCommandLaunchSpec, durable_commit: F, ) -> Result where F: FnOnce() -> Result<(), String>, { #[cfg(not(target_os = "linux"))] durable_commit().map_err(|error| { ProjectCommandError::new(ProjectCommandErrorStage::DurableCommit, error) })?; #[cfg(target_os = "linux")] let mut staged = staged; let mut command = tokio::process::Command::new(&staged.launch.executable); command .args(&staged.launch.arguments) .current_dir(&staged.launch.cwd) .env_clear() .stdin(Stdio::null()) .stdout(Stdio::piped()) .stderr(Stdio::piped()) .kill_on_drop(true); for (name, value) in &staged.launch.environment { command.env(name, value); } configure_project_command_process_group(&mut command, &staged.launch); #[cfg(target_os = "linux")] staged .gate .install_on_command(command.as_std_mut()) .map_err(|error| ProjectCommandError::new(ProjectCommandErrorStage::Preflight, error))?; let child = match command.spawn() { Ok(child) => child, Err(error) => { #[cfg(target_os = "linux")] staged.gate.spawn_failed(); return Err(ProjectCommandError::new( ProjectCommandErrorStage::Spawn, format!("启动 staged project command 失败:{error}"), )); } }; #[cfg(target_os = "linux")] { let mut child = child; let ready_task = tokio::task::spawn_blocking(move || { let mut gate = staged.gate; let ready = gate .child_created() .and_then(|()| gate.wait_sandbox_ready(Duration::from_secs(3))); (gate, ready) }) .await; let (mut gate, ready) = match ready_task { Ok(result) => result, Err(error) => { let termination = terminate_project_command_process_group(&mut child).await; return Err(ProjectCommandError::new( ProjectCommandErrorStage::Preflight, project_command_launch_error_with_termination( format!("等待 sandbox-ready 任务失败:{error}"), termination, ), )); } }; if let Err(error) = ready { let termination = terminate_project_command_process_group(&mut child).await; return Err(ProjectCommandError::new( ProjectCommandErrorStage::Preflight, project_command_launch_error_with_termination(error, termination), )); } if let Err(error) = durable_commit() { let termination = terminate_project_command_process_group(&mut child).await; return Err(ProjectCommandError::new( ProjectCommandErrorStage::DurableCommit, project_command_launch_error_with_termination(error, termination), )); } if let Err(error) = gate.commit_exec() { let termination = terminate_project_command_process_group_after_commit(&mut child); return Err(ProjectCommandError::new( ProjectCommandErrorStage::LaunchUnknown, project_command_launch_error_with_termination(error, termination), )); } // No await is allowed between durable commit and the exec verdict. A // cancelled future must not erase the launch-unknown decision window. let exec = gate.wait_target_exec(Duration::from_secs(3)); match exec { Ok(TargetExecState::Established) => Ok(EstablishedProjectCommand { child, gate }), Ok(TargetExecState::Failed { errno }) => { let termination = terminate_project_command_process_group_after_commit(&mut child); Err(ProjectCommandError::new( ProjectCommandErrorStage::TargetExec, project_command_launch_error_with_termination( format!("command target exec 失败:errno={errno}"), termination, ), )) } Err(error) => { let termination = terminate_project_command_process_group_after_commit(&mut child); Err(ProjectCommandError::new( ProjectCommandErrorStage::LaunchUnknown, project_command_launch_error_with_termination(error, termination), )) } } } #[cfg(not(target_os = "linux"))] { Ok(EstablishedProjectCommand { child }) } } fn project_command_launch_error_with_termination( message: impl Into, termination: Result, ) -> String { match termination { Ok(summary) => format!("{};{summary}", message.into()), Err(error) => format!("{};进程终止与回收未确认:{error}", message.into()), } } #[cfg(target_os = "linux")] pub(crate) async fn wait_established_project_command_terminal( mut gate: LaunchGate, ) -> Result { tokio::task::spawn_blocking(move || gate.wait_terminal(Duration::from_secs(2))) .await .map_err(|error| { ProjectCommandError::new( ProjectCommandErrorStage::Execution, format!("等待 command target terminal 任务失败:{error}"), ) })? .map_err(|error| ProjectCommandError::new(ProjectCommandErrorStage::Execution, error)) } #[cfg(unix)] fn request_unix_project_command_process_group_termination( process_id: u32, ) -> Result<&'static str, String> { let result = unsafe { libc::kill(-(process_id as i32), libc::SIGKILL) }; if result == 0 { return Ok("已请求终止受控进程组"); } let error = std::io::Error::last_os_error(); if error.raw_os_error() == Some(libc::ESRCH) { return Ok("受控进程组已不存在"); } Err(format!("请求终止受控进程组失败:{error}")) } async fn request_project_command_process_group_termination( process_id: u32, ) -> Result<&'static str, String> { #[cfg(unix)] { return request_unix_project_command_process_group_termination(process_id); } #[cfg(windows)] { let system_root = std::env::var_os("SystemRoot") .ok_or_else(|| "请求终止受控进程组失败:缺少 SystemRoot".to_string())?; let taskkill = fs::canonicalize(PathBuf::from(&system_root).join("System32/taskkill.exe")) .map_err(|error| format!("请求终止受控进程组失败:定位 taskkill.exe 失败:{error}"))?; if !taskkill.is_absolute() || !taskkill.is_file() { return Err("请求终止受控进程组失败:taskkill.exe 不是绝对普通文件".to_string()); } let mut command = tokio::process::Command::new(taskkill); command .args(["/PID", &process_id.to_string(), "/T", "/F"]) .env_clear() .env("SystemRoot", &system_root) .stdin(Stdio::null()) .stdout(Stdio::null()) .stderr(Stdio::null()); crate::configure_windows_background_tokio_command(&mut command, false); let status = command .status() .await .map_err(|error| format!("请求终止受控进程组失败:启动 taskkill.exe 失败:{error}"))?; if !status.success() { return Err(format!( "请求终止受控进程组失败:taskkill.exe 退出码 {}", status .code() .map(|code| code.to_string()) .unwrap_or_else(|| "none".to_string()) )); } return Ok("已请求终止受控进程组"); } #[cfg(not(any(unix, windows)))] { let _ = process_id; Err("请求终止受控进程组失败:当前平台不支持受控进程组终止".to_string()) } } async fn terminate_project_command_process_group( child: &mut tokio::process::Child, ) -> Result { let process_id = child .id() .ok_or_else(|| "请求终止受控进程组失败:子进程缺少 pid".to_string())?; let group_result = request_project_command_process_group_termination(process_id).await; let child_kill_error = child.start_kill().err(); let wait_result = child.wait().await; if let Err(error) = &group_result { let fallback = match (&child_kill_error, &wait_result) { (_, Ok(_)) => "主进程已回收,但无法确认其余组内进程".to_string(), (Some(kill_error), Err(wait_error)) => { format!("主进程兜底终止失败:{kill_error};等待失败:{wait_error}") } (None, Err(wait_error)) => format!("等待主进程退出失败:{wait_error}"), }; return Err(format!("{error};{fallback}")); } wait_result.map_err(|error| format!("请求终止受控进程组后等待主进程失败:{error}"))?; Ok(format!( "{}并完成主进程回收", group_result.expect("group termination result checked") )) } #[cfg(target_os = "linux")] fn terminate_project_command_process_group_after_commit( child: &mut tokio::process::Child, ) -> Result { let process_id = child .id() .ok_or_else(|| "请求终止受控进程组失败:子进程缺少 pid".to_string())?; let group_result = request_unix_project_command_process_group_termination(process_id); let child_kill_error = child.start_kill().err(); let deadline = std::time::Instant::now() + Duration::from_secs(2); let wait_result = loop { match child.try_wait() { Ok(Some(status)) => break Ok(status), Ok(None) if std::time::Instant::now() < deadline => { std::thread::sleep(Duration::from_millis(5)); } Ok(None) => break Err("同步等待主进程退出超时".to_string()), Err(error) => break Err(format!("同步等待主进程退出失败:{error}")), } }; if let Err(error) = &group_result { let fallback = match (&child_kill_error, &wait_result) { (_, Ok(_)) => "主进程已回收,但无法确认其余组内进程".to_string(), (Some(kill_error), Err(wait_error)) => { format!("主进程兜底终止失败:{kill_error};{wait_error}") } (None, Err(wait_error)) => wait_error.clone(), }; return Err(format!("{error};{fallback}")); } wait_result?; Ok(format!( "{}并完成主进程回收", group_result.expect("group termination result checked") )) } async fn read_bounded_project_command_output( mut reader: R, ) -> Result where R: tokio::io::AsyncRead + Unpin, { let mut output = BoundedCommandBytes::new(PROJECT_COMMAND_OUTPUT_MAX_BYTES); let mut buffer = [0_u8; 4 * 1024]; loop { let read = reader .read(&mut buffer) .await .map_err(|error| format!("读取 command.exec 子进程输出失败:{error}"))?; if read == 0 { break; } output.push(&buffer[..read]); } Ok(output.finish()) } async fn collect_project_command_output_task( mut task: tokio::task::JoinHandle>, stream_name: &str, ) -> Result { match tokio::time::timeout(Duration::from_secs(2), &mut task).await { Ok(result) => { result.map_err(|error| format!("收集 command.exec {stream_name} 失败:{error}"))? } Err(_) => { task.abort(); Err(format!( "command.exec {stream_name} 收集超时,执行结果需要人工核对" )) } } } pub(crate) fn project_command_source_fingerprint(root: &Path) -> Result { validate_project_root(root)?; let mut entries_seen = 0usize; let mut files_seen = 0usize; let mut total_bytes = 0u64; let mut files = Vec::new(); let mut dirs = vec![root.to_path_buf()]; while let Some(dir) = dirs.pop() { for entry in fs::read_dir(&dir).map_err(|error| { format!("读取 command.exec 指纹目录失败:{}: {error}", dir.display()) })? { entries_seen = entries_seen.saturating_add(1); if entries_seen > PROJECT_COMMAND_FINGERPRINT_MAX_ENTRIES { return Err(format!( "command.exec 源码指纹超过目录项预算 {PROJECT_COMMAND_FINGERPRINT_MAX_ENTRIES}" )); } let entry = entry.map_err(|error| { format!("读取 command.exec 指纹条目失败:{}: {error}", dir.display()) })?; let file_type = entry.file_type().map_err(|error| { format!( "读取 command.exec 指纹文件类型失败:{}: {error}", entry.path().display() ) })?; if file_type.is_symlink() { continue; } let path = entry.path(); let relative_path = relative_project_path(root, &path)?; if should_skip_project_index_path(&relative_path) { continue; } if file_type.is_dir() { dirs.push(path); continue; } if !file_type.is_file() { continue; } files_seen = files_seen.saturating_add(1); if files_seen > PROJECT_COMMAND_FINGERPRINT_MAX_FILES { return Err(format!( "command.exec 源码指纹超过文件预算 {PROJECT_COMMAND_FINGERPRINT_MAX_FILES}" )); } let size = entry .metadata() .map_err(|error| { format!( "读取 command.exec 指纹元数据失败:{}: {error}", path.display() ) })? .len(); total_bytes = total_bytes.saturating_add(size); if total_bytes > PROJECT_COMMAND_FINGERPRINT_MAX_BYTES { return Err(format!( "command.exec 源码指纹超过字节预算 {PROJECT_COMMAND_FINGERPRINT_MAX_BYTES}" )); } let mut file = fs::File::open(&path).map_err(|error| { format!( "读取 command.exec 指纹文件失败:{}: {error}", path.display() ) })?; let mut file_digest = Sha256::new(); let mut buffer = [0u8; 64 * 1024]; loop { let read = file.read(&mut buffer).map_err(|error| { format!( "读取 command.exec 指纹文件失败:{}: {error}", path.display() ) })?; if read == 0 { break; } file_digest.update(&buffer[..read]); } files.push((relative_path, size, format!("{:x}", file_digest.finalize()))); } } files.sort_by(|left, right| left.0.cmp(&right.0)); let mut digest = Sha256::new(); for (path, size, checksum) in files { digest.update(path.as_bytes()); digest.update([0]); digest.update(size.to_le_bytes()); digest.update([0]); digest.update(checksum.as_bytes()); digest.update([b'\n']); } Ok(format!("{:x}", digest.finalize())) } async fn run_project_command_process( spec: &ProjectCommandSpec, staged: StagedProjectCommandLaunchSpec, durable_commit: F, ) -> Result where F: FnOnce() -> Result<(), String>, { let established = spawn_staged_project_command(staged, durable_commit).await?; let mut child = established.child; #[cfg(target_os = "linux")] let gate = established.gate; #[cfg(unix)] let process_id = child.id(); let stdout = match child.stdout.take() { Some(stdout) => stdout, None => { let termination = terminate_project_command_process_group(&mut child) .await .unwrap_or_else(|error| error); return Err(ProjectCommandError::new( ProjectCommandErrorStage::Execution, format!("读取 command.exec stdout 失败;{termination}"), )); } }; let stderr = match child.stderr.take() { Some(stderr) => stderr, None => { let termination = terminate_project_command_process_group(&mut child) .await .unwrap_or_else(|error| error); return Err(ProjectCommandError::new( ProjectCommandErrorStage::Execution, format!("读取 command.exec stderr 失败;{termination}"), )); } }; let stdout_task = tokio::spawn(read_bounded_project_command_output(stdout)); let stderr_task = tokio::spawn(read_bounded_project_command_output(stderr)); let wait = tokio::time::timeout(Duration::from_secs(spec.timeout_seconds), child.wait()).await; let (exit_code, timed_out, termination_summary) = match wait { Ok(Ok(status)) => { #[cfg(target_os = "linux")] let _terminal = wait_established_project_command_terminal(gate).await?; #[cfg(unix)] if let Some(process_id) = process_id { if let Err(error) = request_project_command_process_group_termination(process_id).await { stdout_task.abort(); stderr_task.abort(); return Err(ProjectCommandError::new( ProjectCommandErrorStage::Execution, format!( "command.exec 主进程退出后请求终止受控进程组失败,需要人工核对:{error}" ), )); } } (status.code(), false, None) } Ok(Err(error)) => { let termination = terminate_project_command_process_group(&mut child) .await .unwrap_or_else(|termination_error| termination_error); stdout_task.abort(); stderr_task.abort(); return Err(ProjectCommandError::new( ProjectCommandErrorStage::Execution, format!("等待 command.exec 子进程失败:{error};{termination}"), )); } Err(_) => { let termination = match terminate_project_command_process_group(&mut child).await { Ok(termination) => termination, #[cfg(windows)] Err(error) if child.try_wait().ok().flatten().is_some() => { format!( "请求终止受控进程组后主进程已回收(taskkill 未找到已退出进程:{error})" ) } Err(error) => { stdout_task.abort(); stderr_task.abort(); return Err(ProjectCommandError::new( ProjectCommandErrorStage::Execution, format!("command.exec 超时后无法确认受控进程组终止,需要人工核对:{error}"), )); } }; ( None, true, Some(format!("{termination};该终止请求不等同完整 OS sandbox")), ) } }; let (stdout, stderr) = tokio::join!( collect_project_command_output_task(stdout_task, "stdout"), collect_project_command_output_task(stderr_task, "stderr"), ); let stdout = stdout .map_err(|error| ProjectCommandError::new(ProjectCommandErrorStage::Execution, error))?; let stderr = stderr .map_err(|error| ProjectCommandError::new(ProjectCommandErrorStage::Execution, error))?; let mut sections = Vec::new(); if !stdout.text.trim().is_empty() { sections.push(format!("stdout:\n{}", stdout.text.trim())); } if !stderr.text.trim().is_empty() { sections.push(format!("stderr:\n{}", stderr.text.trim())); } if timed_out { sections.push(format!( "command.exec 在 {} 秒后超时;{}", spec.timeout_seconds, termination_summary .as_deref() .unwrap_or("已请求终止受控进程组") )); } else if let Some(exit_code) = exit_code.filter(|code| *code != 0) { sections.push(format!("command.exec 退出码:{exit_code}")); } if sections.is_empty() { sections.push("command.exec 未产生输出".to_string()); } let output = sanitize_project_verification_output(§ions.join("\n\n")); let capture_truncated = stdout.truncated || stderr.truncated || output.contains("... String { let subcommand = spec .arguments .first() .map(String::as_str) .unwrap_or("run") .chars() .map(|character| { if character.is_ascii_alphanumeric() || matches!(character, '-' | '_' | '.') { character } else { '-' } }) .collect::(); format!("command.exec.{}.{}", spec.program, subcommand) } pub(crate) async fn run_project_command_at( root: &Path, program: &str, arguments: &[String], cwd: &str, timeout_seconds: u64, ) -> Result { run_project_command_with_output_at(root, program, arguments, cwd, timeout_seconds, None).await } pub(crate) async fn run_project_command_with_output_at( root: &Path, program: &str, arguments: &[String], cwd: &str, timeout_seconds: u64, output_identity: Option, ) -> Result { let spec = resolve_project_command_spec_at(root, program, arguments, cwd, timeout_seconds)?; let launch = prepare_project_command_launch_spec(root, &spec)?; let staged = stage_project_command_launch_spec(&spec, launch)?; run_prepared_project_command_with_output_at(root, &spec, staged, output_identity, || Ok(())) .await } pub(crate) async fn run_project_bootstrap_command_at( root: &Path, timeout_seconds: u64, ) -> Result { let spec = resolve_project_bootstrap_spec_at(root, timeout_seconds)?; let launch = prepare_project_command_launch_spec(root, &spec)?; let staged = stage_project_command_launch_spec(&spec, launch)?; run_prepared_project_command_with_output_at(root, &spec, staged, None, || Ok(())).await } pub(crate) async fn run_prepared_project_command_with_output_at( root: &Path, spec: &ProjectCommandSpec, staged: StagedProjectCommandLaunchSpec, output_identity: Option, durable_commit: F, ) -> Result where F: FnOnce() -> Result<(), String>, { let launch_metadata = staged.launch.clone(); let source_fingerprint_before = project_command_source_fingerprint(root) .map_err(|error| ProjectCommandError::new(ProjectCommandErrorStage::Preflight, error))?; let started_at = std::time::Instant::now(); let process = run_project_command_process(spec, staged, durable_commit).await?; let duration_ms = u64::try_from(started_at.elapsed().as_millis()).unwrap_or(u64::MAX); let source_fingerprint_after = project_command_source_fingerprint(root).map_err(|error| { ProjectCommandError::new( ProjectCommandErrorStage::PostExecutionFingerprint, format!("command.exec 执行后无法复核项目源码指纹,需要人工核对:{error}"), ) })?; let source_changed = source_fingerprint_before != source_fingerprint_after; let completed = !process.timed_out && process.exit_code == Some(0) && !source_changed; let status = if completed { "completed" } else { "failed" }; let command_id = project_command_id(&spec); let updated_at = unix_timestamp(); let default_output_sha256 = format!("{:x}", Sha256::digest(process.output.as_bytes())); let default_total_lines = command_output_line_count(&process.output); let (output_ref, output_sha256, total_lines) = if let Some(identity) = output_identity { let transcript = build_command_output_transcript( identity, &command_id, &spec.program, &spec.arguments, &spec.cwd_relative, process.exit_code, process.timed_out, duration_ms, source_changed, process.capture_truncated, &process.output, updated_at, ) .map_err(|error| { ProjectCommandError::new( ProjectCommandErrorStage::OutputSidecar, format!("command.exec 执行后构建输出 sidecar 失败,需要人工核对:{error}"), ) })?; write_command_output_transcript_at(root, &transcript).map_err(|error| { ProjectCommandError::new( ProjectCommandErrorStage::OutputSidecar, format!("command.exec 执行后写入输出 sidecar 失败,需要人工核对:{error}"), ) })?; ( Some(transcript.output_ref), transcript.output_sha256, transcript.total_lines, ) } else { (None, default_output_sha256, default_total_lines) }; let log_path = resolve_local_project_path(root, ".agent/logs/command.log") .map_err(|error| ProjectCommandError::new(ProjectCommandErrorStage::AuditLog, error))?; if let Some(parent) = log_path.parent() { fs::create_dir_all(parent).map_err(|error| { ProjectCommandError::new( ProjectCommandErrorStage::AuditLog, format!("创建 command.exec 日志目录失败:{error}"), ) })?; } let argument_bytes = serde_json::to_vec(&spec.arguments).unwrap_or_default(); let log_entry = format!( "{updated_at} command.exec program={} argsSha256={:x} argsCount={} cwd={} {status} exitCode={} timedOut={} durationMs={} sourceChanged={} verificationEligible={} sandboxBackend={} sandboxMode={} networkAccess={} sandboxProfileVersion={}\n{}\n", spec.program, Sha256::digest(&argument_bytes), spec.arguments.len(), spec.cwd_relative, process .exit_code .map(|code| code.to_string()) .unwrap_or_else(|| "none".to_string()), process.timed_out, duration_ms, source_changed, spec.verification_eligible, launch_metadata.sandbox_backend, launch_metadata.sandbox_mode, launch_metadata.network_access, launch_metadata.sandbox_profile_version, process.output, ); fs::OpenOptions::new() .create(true) .append(true) .open(&log_path) .and_then(|mut file| file.write_all(log_entry.as_bytes())) .map_err(|error| { ProjectCommandError::new( ProjectCommandErrorStage::AuditLog, format!("command.exec 执行后写入命令日志失败,需要人工核对:{error}"), ) })?; record_command_run( root, GameCreationAppCommandRunState { command_id: command_id.clone(), status: if completed { GameCreationAppCommandRunStatus::Completed } else { GameCreationAppCommandRunStatus::Failed }, output: process.output.clone(), log_path: log_path.to_string_lossy().into_owned(), updated_at, }, ) .map_err(|error| { ProjectCommandError::new( ProjectCommandErrorStage::ManifestProjection, format!("command.exec 执行后写入命令投影失败,需要人工核对:{error}"), ) })?; Ok(ProjectCommandResult { command_id, program: spec.program.clone(), arguments: spec.arguments.clone(), cwd_relative: spec.cwd_relative.clone(), status: status.to_string(), exit_code: process.exit_code, timed_out: process.timed_out, duration_ms, output: process.output, capture_truncated: process.capture_truncated, output_ref, output_sha256, total_lines, source_fingerprint_before, source_fingerprint_after, source_changed, verification_eligible: spec.verification_eligible, sandbox_backend: launch_metadata.sandbox_backend, sandbox_mode: launch_metadata.sandbox_mode, network_access: launch_metadata.network_access, sandbox_profile_version: launch_metadata.sandbox_profile_version, log_path: log_path.to_string_lossy().into_owned(), updated_at, }) } #[cfg(test)] mod tests { use super::*; #[cfg(target_os = "linux")] #[tokio::test] async fn staged_launcher_never_commits_before_sandbox_ready() { use std::sync::atomic::{AtomicBool, Ordering}; use std::sync::Arc; let committed = Arc::new(AtomicBool::new(false)); let committed_for_callback = Arc::clone(&committed); let staged = StagedProjectCommandLaunchSpec { launch: ProjectCommandLaunchSpec { executable: PathBuf::from("/usr/bin/false"), arguments: Vec::new(), cwd: PathBuf::from("/"), environment: Vec::new(), sandbox_backend: "bubblewrap".to_string(), sandbox_mode: "workspace-write".to_string(), network_access: "disabled".to_string(), sandbox_profile_version: "workspace-v1".to_string(), }, gate: LaunchGate::new_for_sandbox_stdin(Path::new("/usr/bin/true"), &[]) .expect("create staged launch gate"), }; let error = spawn_staged_project_command(staged, move || { committed_for_callback.store(true, Ordering::SeqCst); Ok(()) }) .await .expect_err("launcher without bwrap status must fail before ready"); assert_eq!(error.stage(), ProjectCommandErrorStage::Preflight); assert!(!committed.load(Ordering::SeqCst)); } fn command_project(name: &str) -> tempfile::TempDir { let dir = tempfile::Builder::new() .prefix(&format!("game-creator-command-{name}-")) .tempdir() .expect("command tempdir"); init_local_game_project_at(dir.path(), "command-project", "命令测试") .expect("init command project"); dir } fn command_args(arguments: &[&str]) -> Vec { arguments .iter() .map(|argument| (*argument).to_string()) .collect() } #[cfg(unix)] fn write_fake_command_executable(path: &Path) { use std::os::unix::fs::PermissionsExt; fs::create_dir_all(path.parent().expect("fake executable parent")) .expect("create fake executable parent"); fs::write(path, "#!/bin/sh\nexit 0\n").expect("write fake executable"); let mut permissions = fs::metadata(path) .expect("fake executable metadata") .permissions(); permissions.set_mode(0o755); fs::set_permissions(path, permissions).expect("make fake executable executable"); } #[cfg(not(target_os = "linux"))] #[test] fn project_command_rejects_shells_paths_and_dangerous_options() { let dir = command_project("validation"); let root = dir.path(); for (program, args) in [ ("bash", vec!["-lc", "echo nope"]), ("cargo", vec!["test", "--manifest-path", "../Cargo.toml"]), ("npm", vec!["exec", "vite"]), ("node", vec!["-e", "process.exit(0)"]), ("git", vec!["commit", "-m", "nope"]), ("rg", vec!["--pre", "sh", "needle"]), ] { let args = args.into_iter().map(str::to_string).collect::>(); assert!( resolve_project_command_spec_at(root, program, &args, ".", 30).is_err(), "expected rejection for {program} {args:?}" ); } let absolute = vec![ "test".to_string(), std::env::temp_dir() .join("outside.rs") .to_string_lossy() .into_owned(), ]; assert!(resolve_project_command_spec_at(root, "cargo", &absolute, ".", 30).is_err()); let sensitive = vec!["status".to_string(), ".agent/agent.db".to_string()]; assert!(resolve_project_command_spec_at(root, "git", &sensitive, ".", 30).is_err()); } #[cfg(target_os = "linux")] #[test] fn project_command_accepts_general_programs_only_as_bare_names() { let dir = command_project("general-programs"); let root = dir.path(); let shell = resolve_project_command_spec_at( root, "bash", &command_args(&["-lc", "printf general-command"]), ".", 30, ) .expect("resolve sandboxed shell"); assert_eq!(shell.program, "bash"); for program in ["/bin/bash", "../bash", "tool/name", "bad name", ""] { assert!( resolve_project_command_spec_at(root, program, &[], ".", 30).is_err(), "expected bare program rejection for {program:?}" ); } } #[cfg(target_os = "linux")] #[tokio::test] async fn project_command_workspace_sandbox_blocks_host_controls_and_network() { let dir = command_project("workspace-sandbox"); let root = dir.path(); for name in [".git", ".agents", ".codex"] { fs::create_dir_all(root.join(name)).expect("create protected directory"); fs::write(root.join(name).join("marker"), name).expect("write protected marker"); } let outside = root .parent() .expect("sandbox project parent") .join(format!("outside-{}", std::process::id())); fs::write(&outside, "HOST_SECRET").expect("write outside sentinel"); let script = format!( r#"set -eu printf WORKSPACE_OK > workspace-write.txt test ! -r {outside:?} ! printf NO > {outside:?} for control in .git .agents .codex; do test -r "$control/marker" ! touch "$control/blocked-write" done ! test -r .agent/manifest.json bash -c 'test ! -r {outside:?}; ! touch .git/child-blocked' python3 -c 'import socket; s=socket.socket(); s.settimeout(0.2); code=0 try: s.connect(("1.1.1.1", 53)); code=1 except OSError: pass finally: s.close() raise SystemExit(code)' "#, outside = outside.to_string_lossy(), ); fs::write(root.join("sandbox-check.sh"), script).expect("write sandbox check script"); let result = run_project_command_at(root, "bash", &["sandbox-check.sh".to_string()], ".", 30) .await .expect("run workspace sandbox command"); assert_eq!(result.exit_code, Some(0), "{}", result.output); assert_eq!(result.sandbox_backend, "bubblewrap"); assert_eq!(result.sandbox_mode, "workspace-write"); assert_eq!(result.network_access, "disabled"); assert_eq!( fs::read_to_string(root.join("workspace-write.txt")).expect("workspace write"), "WORKSPACE_OK" ); assert_eq!( fs::read_to_string(&outside).expect("outside sentinel"), "HOST_SECRET" ); for name in [".git", ".agents", ".codex"] { assert!(!root.join(name).join("blocked-write").exists()); } fs::remove_file(outside).ok(); } #[cfg(target_os = "linux")] #[tokio::test] async fn project_command_runs_project_build_and_git_read_inside_sandbox() { let dir = command_project("sandbox-build-git"); let root = dir.path(); fs::create_dir_all(root.join("src")).expect("create Rust source directory"); fs::write( root.join("Cargo.toml"), "[package]\nname = \"sandbox-fixture\"\nversion = \"0.1.0\"\nedition = \"2021\"\n", ) .expect("write Cargo.toml"); fs::write( root.join("Cargo.lock"), "# This file is automatically @generated by Cargo.\n# It is not intended for manual editing.\nversion = 4\n\n[[package]]\nname = \"sandbox-fixture\"\nversion = \"0.1.0\"\n", ) .expect("write Cargo.lock"); fs::write(root.join("src/lib.rs"), "pub fn answer() -> u32 { 42 }\n") .expect("write Rust source"); let git_status = std::process::Command::new("git") .args(["init", "--quiet"]) .current_dir(root) .status() .expect("initialize Git repository"); assert!(git_status.success()); let build = run_project_command_at( root, "cargo", &command_args(&["check", "--quiet"]), ".", 120, ) .await .expect("run cargo check in sandbox"); assert_eq!(build.status, "completed", "{}", build.output); assert_eq!(build.sandbox_mode, "workspace-write"); assert_eq!(build.network_access, "disabled"); let git = run_project_command_at(root, "git", &command_args(&["status", "--short"]), ".", 30) .await .expect("run git status in sandbox"); assert_eq!(git.exit_code, Some(0), "{}", git.output); assert_eq!(git.sandbox_backend, "bubblewrap"); } #[cfg(unix)] #[test] fn project_command_rejects_symlinked_cwd_ancestors() { use std::os::unix::fs::symlink; let dir = command_project("cwd-symlink"); let outside = tempfile::tempdir().expect("outside command cwd"); fs::create_dir_all(outside.path().join("nested")).expect("outside nested cwd"); symlink(outside.path(), dir.path().join("linked-cwd")).expect("symlink command cwd"); let error = resolve_project_command_spec_at( dir.path(), "node", &["--test".to_string(), "sample.test.mjs".to_string()], "linked-cwd/nested", 30, ) .expect_err("reject symlinked cwd ancestor"); assert_eq!(error.stage(), ProjectCommandErrorStage::Validation); assert!(error.message().contains("符号链接")); } #[test] fn project_command_accepts_targeted_developer_commands() { let dir = command_project("allowed"); let root = dir.path(); fs::create_dir_all(root.join("tests")).expect("allowed tests dir"); fs::write(root.join("tests/sample.test.mjs"), "// test fixture\n") .expect("allowed node test fixture"); for (program, args) in [ ("cargo", vec!["test", "-p", "example", "specific_test"]), ("npm", vec!["run", "test:unit", "--", "sample"]), ("node", vec!["--test", "tests/sample.test.mjs"]), ("git", vec!["diff", "--stat"]), ("rg", vec!["-n", "needle"]), ] { let args = args.into_iter().map(str::to_string).collect::>(); let spec = resolve_project_command_spec_at(root, program, &args, ".", 30) .unwrap_or_else(|error| panic!("expected {program} to pass: {error}")); assert_eq!(spec.program, program); } } #[test] fn project_command_node_requires_exact_regular_test_files() { for arguments in [ command_args(&["--test"]), command_args(&["--test=tests/sample.test.mjs"]), command_args(&["--test", "--test-reporter=spec", "tests/sample.test.mjs"]), command_args(&[ "--test", "tests/sample.test.mjs", "--test-name-pattern=unit", ]), command_args(&["--test", "tests/*.test.mjs"]), command_args(&["--test", "tests/{one,two}.test.mjs"]), ] { assert!( validate_node_arguments(&arguments).is_err(), "expected strict node rejection for {arguments:?}" ); } assert!(validate_node_arguments(&command_args(&[ "--test", "tests/one.test.mjs", "tests/two.test.ts", ])) .is_ok()); } #[cfg(unix)] #[test] fn project_command_node_rejects_symlinked_test_files_and_ancestors() { use std::os::unix::fs::symlink; let dir = command_project("node-test-symlink"); let root = dir.path(); fs::create_dir_all(root.join("tests/real")).expect("real tests dir"); fs::write(root.join("tests/real/sample.test.mjs"), "// real test\n") .expect("real node test"); symlink( root.join("tests/real/sample.test.mjs"), root.join("tests/linked.test.mjs"), ) .expect("linked node test"); symlink(root.join("tests/real"), root.join("tests/linked-dir")) .expect("linked node test dir"); for test_path in ["tests/linked.test.mjs", "tests/linked-dir/sample.test.mjs"] { let error = resolve_project_command_spec_at( root, "node", &command_args(&["--test", test_path]), ".", 30, ) .expect_err("reject linked node test path"); assert_eq!(error.stage(), ProjectCommandErrorStage::Validation); assert!(error.message().contains("符号链接")); } } #[test] fn project_command_git_rejects_indirect_execution_and_sensitive_objects() { for arguments in [ command_args(&["grep", "-O", "less", "needle"]), command_args(&["grep", "-Oless", "needle"]), command_args(&["grep", "--open-files-in-pager=less", "needle"]), command_args(&["grep", "-fpatterns.txt"]), command_args(&["log", "--paginate"]), command_args(&["show", "--show-signature", "HEAD"]), command_args(&["status", "--pathspec-from-file=paths.txt"]), command_args(&["status", "--pathspec-file-nul"]), command_args(&["ls-files", "--exclude-from=paths.txt"]), command_args(&["diff", "--no-index", "left", "right"]), command_args(&["show", "HEAD:.env"]), command_args(&["show", "HEAD:.git/config"]), command_args(&["show", "HEAD:keys/private.key"]), command_args(&["show", "HEAD:config"]), command_args(&["grep", "needle", "--", ":(glob)**/.env"]), ] { assert!( validate_git_arguments(&arguments).is_err(), "expected strict git rejection for {arguments:?}" ); } assert!(validate_git_arguments(&command_args(&["grep", "-n", "needle"])).is_ok()); assert!(validate_git_arguments(&command_args(&["show", "HEAD:package.json"])).is_ok()); } #[test] fn project_command_rg_rejects_visibility_archive_and_glob_bypasses() { for arguments in [ command_args(&["-L", "needle"]), command_args(&["--follow", "needle"]), command_args(&["--hidden", "needle"]), command_args(&["-u", "needle"]), command_args(&["--no-ignore-vcs", "needle"]), command_args(&["-z", "needle"]), command_args(&["--search-zip", "needle"]), command_args(&["--pre=sh", "needle"]), command_args(&["--pre-glob=*.js", "needle"]), command_args(&["--hostname-bin=sh", "needle"]), command_args(&["--glob=**/.env", "needle"]), command_args(&["-g*.rs", "needle"]), command_args(&["--iglob", "*.rs", "needle"]), command_args(&["-fpatterns.txt"]), command_args(&["--type-add=secret:*.env", "-tsecret", "needle"]), command_args(&["--type", "rust", "needle"]), command_args(&["-trust", "needle"]), ] { assert!( validate_rg_arguments(&arguments).is_err(), "expected strict rg rejection for {arguments:?}" ); } assert!(validate_rg_arguments(&command_args(&["-n", "needle", "src"])).is_ok()); } #[test] fn project_command_verification_eligibility_is_conservative() { for (program, arguments) in [ ("cargo", command_args(&["check"])), ("cargo", command_args(&["test", "specific_test"])), ("cargo", command_args(&["clippy"])), ("cargo", command_args(&["fmt", "--check"])), ("cargo", command_args(&["build"])), ("npm", command_args(&["test"])), ("npm", command_args(&["run", "check:encoding"])), ("npm", command_args(&["run", "typecheck"])), ("node", command_args(&["--test", "tests/sample.test.mjs"])), ] { assert!( project_command_verification_eligible(program, &arguments), "expected verification eligible: {program} {arguments:?}" ); } for (program, arguments) in [ ("cargo", command_args(&["metadata"])), ("npm", command_args(&["run", "dev"])), ("npm", command_args(&["run", "start"])), ("git", command_args(&["status"])), ("rg", command_args(&["needle"])), ] { assert!( !project_command_verification_eligible(program, &arguments), "expected verification ineligible: {program} {arguments:?}" ); } } #[cfg(unix)] #[test] fn project_command_executable_resolution_ignores_project_and_relative_path_poisoning() { let dir = command_project("path-poisoning"); let root = dir.path(); let outside = tempfile::tempdir().expect("outside executable dir"); let executable_name = project_command_executable_names("node") .into_iter() .next() .expect("node executable name"); let project_bin = root.join("tools/bin"); let project_executable = project_bin.join(&executable_name); let outside_bin = outside.path().join("bin"); let outside_executable = outside_bin.join(&executable_name); write_fake_command_executable(&project_executable); write_fake_command_executable(&outside_executable); let poisoned_path = std::env::join_paths([ PathBuf::from("relative-bin"), project_bin.clone(), outside_bin.clone(), ]) .expect("poisoned PATH"); let (executable, safe_path) = resolve_project_command_executable_from_path(root, "node", &poisoned_path) .expect("resolve outside executable"); assert_eq!( executable, fs::canonicalize(&outside_executable).expect("canonical outside executable") ); let canonical_root = fs::canonicalize(root).expect("canonical command root"); let safe_directories = std::env::split_paths(&safe_path).collect::>(); assert!(!safe_directories.is_empty()); assert!(safe_directories .iter() .all(|directory| directory.is_absolute() && !directory.starts_with(&canonical_root))); assert!(!safe_directories .iter() .any(|directory| directory == &fs::canonicalize(&project_bin).unwrap())); let project_only_path = std::env::join_paths([project_bin]).expect("project-only poisoned PATH"); assert!( resolve_project_command_executable_from_path(root, "node", &project_only_path).is_err() ); } #[cfg(windows)] #[test] fn project_command_safe_path_uses_win32_compatible_directories() { let dir = command_project("windows-safe-path"); let raw_path = std::env::var_os("PATH").expect("PATH"); let (_, safe_path) = resolve_project_command_executable_from_path(dir.path(), "node", &raw_path) .expect("resolve node executable"); assert!(std::env::split_paths(&safe_path) .all(|directory| { !directory.as_os_str().to_string_lossy().starts_with(r"\\?\") })); } #[cfg(not(target_os = "linux"))] #[test] fn project_command_injects_git_safety_options_before_pathspec_separator() { let dir = command_project("git-arguments"); let spec = resolve_project_command_spec_at( dir.path(), "git", &[ "diff".to_string(), "--".to_string(), "game/index.html".to_string(), ], ".", 30, ) .expect("resolve git diff"); let arguments = project_command_actual_arguments(&spec); let separator = arguments .iter() .position(|argument| argument == "--") .expect("pathspec separator"); let no_ext_diff = arguments .iter() .position(|argument| argument == "--no-ext-diff") .expect("safe external diff option"); let no_textconv = arguments .iter() .position(|argument| argument == "--no-textconv") .expect("safe textconv option"); assert!(no_ext_diff < separator); assert!(no_textconv < separator); assert_eq!(arguments[separator + 1], "game/index.html"); for pathspec in PROJECT_COMMAND_SENSITIVE_GIT_PATHSPECS { let position = arguments .iter() .position(|argument| argument == pathspec) .unwrap_or_else(|| panic!("missing protected Git pathspec {pathspec}")); assert!(position > separator); } } #[cfg(not(target_os = "linux"))] #[test] fn project_command_injects_non_overridable_rg_sensitive_exclusions() { let dir = command_project("rg-arguments"); let spec = resolve_project_command_spec_at( dir.path(), "rg", &command_args(&["-n", "needle"]), ".", 30, ) .expect("resolve rg search"); let arguments = project_command_actual_arguments(&spec); assert_eq!(arguments.first().map(String::as_str), Some("--no-config")); assert!(arguments.iter().any(|argument| argument == "--no-follow")); assert!(arguments.iter().any(|argument| argument == "--no-hidden")); for glob in PROJECT_COMMAND_SENSITIVE_RG_GLOBS { let position = arguments .windows(2) .position(|pair| pair[0] == "--glob" && pair[1] == *glob); assert!(position.is_some(), "missing protected rg glob {glob}"); } let last_user_argument = arguments .iter() .position(|argument| argument == "needle") .expect("user rg pattern"); let first_protected_glob = arguments .windows(2) .position(|pair| { pair[0] == "--glob" && pair[1] == PROJECT_COMMAND_SENSITIVE_RG_GLOBS[0] }) .expect("first protected rg glob"); assert!(first_protected_glob > last_user_argument); assert!( validate_rg_arguments(&command_args(&["-n", "needle", "--glob", "**/.env",])).is_err() ); } #[test] fn project_command_npm_rejects_shell_metacharacters_in_forwarded_arguments() { for arguments in [ command_args(&["run", "test:unit", "--", ";touch-outside"]), command_args(&["run", "test:unit", "--", "$(id)"]), command_args(&["test", "--", "name with spaces"]), command_args(&["run", "test:unit", "--", "value|other"]), ] { assert!( validate_npm_arguments(&arguments).is_err(), "expected npm shell metacharacter rejection for {arguments:?}" ); } assert!( validate_npm_arguments(&command_args(&["run", "test:unit", "--", "sample.test",])) .is_ok() ); assert!(validate_npm_arguments(&command_args(&[ "run", "--silent", "--ignore-scripts", "test:unit", ])) .is_ok()); assert!( validate_npm_arguments(&command_args(&["run", "--silent", "--ignore-scripts",])) .is_err() ); } #[tokio::test] async fn project_command_reports_success_failure_timeout_redaction_and_source_changes() { let dir = command_project("execution"); let root = dir.path(); fs::create_dir_all(root.join("tests")).expect("tests dir"); fs::write( root.join("tests/pass.test.mjs"), "import test from 'node:test';\nimport assert from 'node:assert/strict';\ntest('pass', () => { console.log('PASS_MARKER'); assert.equal(1, 1); });\n", ) .expect("write passing test"); let success = run_project_command_at( root, "node", &["--test".to_string(), "tests/pass.test.mjs".to_string()], ".", 30, ) .await .expect("run passing test"); assert_eq!(success.status, "completed"); assert_eq!(success.exit_code, Some(0)); assert!(!success.source_changed); assert!(success.verification_eligible); assert!(success.output.contains("PASS_MARKER")); let secret = ["s", "k-command-", "secret-123456789"].concat(); fs::write( root.join("tests/fail.test.mjs"), format!( "import test from 'node:test';\nimport assert from 'node:assert/strict';\ntest('fail', () => {{ console.log({secret:?}); assert.equal(1, 2); }});\n" ), ) .expect("write failing test"); let failed = run_project_command_at( root, "node", &["--test".to_string(), "tests/fail.test.mjs".to_string()], ".", 30, ) .await .expect("run failing test"); assert_eq!(failed.status, "failed"); assert_ne!(failed.exit_code, Some(0)); assert!(!failed.output.contains(&secret)); fs::write( root.join("tests/change.test.mjs"), "import test from 'node:test';\nimport fs from 'node:fs';\ntest('change', () => { fs.writeFileSync('changed.txt', 'changed\\n'); });\n", ) .expect("write changing test"); let changed = run_project_command_at( root, "node", &["--test".to_string(), "tests/change.test.mjs".to_string()], ".", 30, ) .await .expect("run changing test"); assert!(changed.source_changed); assert_eq!(changed.status, "failed"); fs::write( root.join("tests/timeout.test.mjs"), "import test from 'node:test';\ntest('wait', async () => { await new Promise(resolve => setTimeout(resolve, 5000)); });\n", ) .expect("write timeout test"); let timed_out = run_project_command_at( root, "node", &["--test".to_string(), "tests/timeout.test.mjs".to_string()], ".", 1, ) .await .expect("run timeout test"); assert!(timed_out.timed_out); assert_eq!(timed_out.status, "failed"); assert!(timed_out.output.contains("请求终止受控进程组")); assert!(timed_out.output.contains("不等同完整 OS sandbox")); let command_log = root.join(".agent/logs/command.log"); fs::remove_file(&command_log).expect("remove command log before audit failure"); fs::create_dir(&command_log).expect("replace command log with directory"); let audit_error = run_project_command_at( root, "node", &["--test".to_string(), "tests/pass.test.mjs".to_string()], ".", 30, ) .await .expect_err("audit log failure after execution"); assert_eq!(audit_error.stage(), ProjectCommandErrorStage::AuditLog); assert!(audit_error.execution_started()); assert!(audit_error.needs_reconciliation()); } #[test] fn project_command_errors_distinguish_validation_from_started_execution() { let dir = command_project("error-stage"); let error = resolve_project_command_spec_at(dir.path(), "../bash", &[], ".", 30) .expect_err("reject executable path"); assert_eq!(error.stage(), ProjectCommandErrorStage::Validation); assert_eq!(error.stage().as_str(), "validation"); assert!(!error.execution_started()); assert!(!error.needs_reconciliation()); } #[test] fn bounded_command_output_keeps_head_and_tail() { let mut output = BoundedCommandBytes::new(30); output.push(b"HEAD-0123456789-MIDDLE-abcdefghij-TAIL"); let output = output.finish(); assert!(output.text.contains("HEAD")); assert!(output.text.contains("TAIL")); assert!(output.text.contains("omitted")); assert!(output.truncated); } }