diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_project_context.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_project_context.rs index 5e00818dd..0b85cef6a 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_project_context.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_project_context.rs @@ -6,9 +6,7 @@ use serde_json::{json, Value}; use sha2::{Digest, Sha256}; use std::collections::HashSet; use std::io::Read; -use std::path::Path; -#[cfg(test)] -use std::path::PathBuf; +use std::path::{Path, PathBuf}; use std::sync::Arc; const MAX_FILES: usize = 8; @@ -17,6 +15,7 @@ const MAX_FILE_BYTES: usize = 1024 * 1024; const MAX_ITEM_BYTES: usize = 32 * 1024; const MAX_RESPONSE_BYTES: usize = 256 * 1024; const PREFETCH_BYTES: usize = 32 * 1024; +pub(super) const GAME_AGENT_READ_PATH_MAX_CHARS: usize = 4096; #[derive(Clone, Debug, Deserialize)] #[serde(rename_all = "camelCase", deny_unknown_fields)] @@ -66,7 +65,152 @@ fn context_identity(root: &Path) -> Result { }) } -fn normalize_requests(arguments: &Value) -> Result, String> { +pub(super) struct GameAgentReadTarget { + pub display: String, + pub absolute: PathBuf, + pub project_relative: Option, +} + +pub(super) fn resolve_game_agent_read_path( + root: &Path, + raw: &str, +) -> Result { + let trimmed = raw.trim(); + if trimmed.is_empty() + || trimmed.contains('\0') + || trimmed.chars().any(char::is_control) + || trimmed.chars().count() > GAME_AGENT_READ_PATH_MAX_CHARS + { + return Err("读取路径无效".to_string()); + } + let slash = trimmed.replace('\\', "/"); + if Path::new(&slash).is_absolute() || slash.split('/').any(|part| part == "..") { + let path = if Path::new(&slash).is_absolute() { + PathBuf::from(&slash) + } else { + let mut path = root.to_path_buf(); + for part in slash.split('/') { + match part { + "" | "." => {} + ".." => { + path.pop(); + } + other => path.push(other), + } + } + path + }; + if path.as_os_str().is_empty() { + return Err("读取路径无效".to_string()); + } + let project_relative = project_relative_read_path(root, &path); + let display = match project_relative.as_deref() { + Some("") => ".".to_string(), + Some(relative) => relative.to_string(), + None => path.to_string_lossy().replace('\\', "/"), + }; + return Ok(GameAgentReadTarget { + display, + absolute: path, + project_relative, + }); + } + let relative = normalize_relative_path(trimmed)?; + let absolute = resolve_local_project_path(root, &relative)?; + Ok(GameAgentReadTarget { + display: relative.clone(), + absolute, + project_relative: Some(relative), + }) +} + +fn project_relative_read_path(root: &Path, path: &Path) -> Option { + if let Some(relative) = portable_project_relative(root, path) { + return Some(relative); + } + let root_key = std::fs::canonicalize(root).ok()?; + if !path.exists() { + return None; + } + let path_key = std::fs::canonicalize(path).ok()?; + portable_project_relative(&root_key, &path_key) +} + +/// 空字符串表示路径就是项目根。`None` 表示路径在项目外。 +fn portable_project_relative(root: &Path, path: &Path) -> Option { + let relative = path.strip_prefix(root).ok()?; + if relative.as_os_str().is_empty() { + return Some(String::new()); + } + if relative + .components() + .any(|component| !matches!(component, std::path::Component::Normal(_))) + { + return None; + } + let text = relative.to_string_lossy().replace('\\', "/"); + if text.is_empty() + || text + .split('/') + .any(|part| part.is_empty() || part == "." || part == "..") + { + return None; + } + Some(text) +} + +pub(super) fn external_read_is_protected(display: &str) -> bool { + if reject_sensitive_project_file_read(display).is_err() { + return true; + } + let parts = display + .split('/') + .filter(|part| !part.is_empty()) + .collect::>(); + if parts.iter().any(|part| { + matches!( + part.to_ascii_lowercase().as_str(), + ".agent" + | ".git" + | ".ssh" + | ".aws" + | ".azure" + | ".gnupg" + | ".kube" + | ".docker" + | ".gcloud" + | ".terraform" + | ".password-store" + | ".secrets" + | "secrets" + | "credentials" + ) + }) { + return true; + } + parts + .last() + .is_some_and(|name| should_skip_project_snapshot_path(name)) +} + +/// O_NOFOLLOW 只保护末段;项目外路径还必须逐段拒绝目录链接与 Windows 重解析点。 +pub(super) fn reject_external_read_links(path: &Path) -> Result<(), &'static str> { + let mut current = PathBuf::new(); + for component in path.components() { + current.push(component.as_os_str()); + // Windows 盘符 / UNC 前缀需与 RootDir 合并后再查元数据。 + if matches!(component, std::path::Component::Prefix(_)) { + continue; + } + let metadata = std::fs::symlink_metadata(¤t).map_err(|_| "file-not-found")?; + if metadata.file_type().is_symlink() || windows_metadata_is_reparse_point(&metadata) { + return Err("linked-path"); + } + } + Ok(()) +} + +fn normalize_requests(root: &Path, arguments: &Value) -> Result, String> { let input: BatchRequest = serde_json::from_value(arguments.clone()) .map_err(|_| "批量读取参数只接受 files,以及 path/startLine/maxLines".to_string())?; if input.files.is_empty() || input.files.len() > MAX_FILES { @@ -75,9 +219,10 @@ fn normalize_requests(arguments: &Value) -> Result, String> { let mut seen = HashSet::new(); let mut files = Vec::new(); for mut file in input.files { - file.path = normalize_relative_path(&file.path)?; - if file.path.len() > 512 || file.start_line == 0 || !(1..=2000).contains(&file.max_lines) { - return Err("批量读取路径过长,或行号/行数无效".into()); + let target = resolve_game_agent_read_path(root, &file.path)?; + file.path = target.display; + if file.start_line == 0 || !(1..=2000).contains(&file.max_lines) { + return Err("批量读取行号或行数无效".into()); } let identity = if cfg!(windows) { file.path.to_ascii_lowercase() @@ -92,22 +237,34 @@ fn normalize_requests(arguments: &Value) -> Result, String> { Ok(files) } -fn bounded_bytes(root: &Path, relative: &str) -> Result, &'static str> { - reject_agent_runtime_private_control_path(relative).map_err(|_| "private-control-path")?; - reject_sensitive_project_file_read(relative).map_err(|_| "sensitive-path")?; - if should_skip_project_snapshot_path(relative) { - return Err("excluded-project-path"); - } - let path = resolve_local_project_path(root, relative).map_err(|_| "unsafe-project-path")?; - // 检查每段目录,避免父目录 junction/symlink 绕过叶子 O_NOFOLLOW。 - let mut component = root.to_path_buf(); - for segment in relative.split('/') { - component.push(segment); - let metadata = std::fs::symlink_metadata(&component).map_err(|_| "file-not-found")?; - if metadata.file_type().is_symlink() || windows_metadata_is_reparse_point(&metadata) { - return Err("linked-path"); +fn bounded_bytes(root: &Path, raw: &str) -> Result, &'static str> { + let target = resolve_game_agent_read_path(root, raw).map_err(|_| "unsafe-project-path")?; + let path = if let Some(relative) = target.project_relative.as_deref() { + if relative.is_empty() { + return Err("not-safe-regular-file"); } - } + reject_agent_runtime_private_control_path(relative).map_err(|_| "private-control-path")?; + reject_sensitive_project_file_read(relative).map_err(|_| "sensitive-path")?; + if should_skip_project_snapshot_path(relative) { + return Err("excluded-project-path"); + } + // 检查每段目录,避免父目录 junction/symlink 绕过叶子 O_NOFOLLOW。 + let mut component = root.to_path_buf(); + for segment in relative.split('/') { + component.push(segment); + let metadata = std::fs::symlink_metadata(&component).map_err(|_| "file-not-found")?; + if metadata.file_type().is_symlink() || windows_metadata_is_reparse_point(&metadata) { + return Err("linked-path"); + } + } + component + } else { + if external_read_is_protected(&target.display) { + return Err("sensitive-path"); + } + reject_external_read_links(&target.absolute)?; + target.absolute + }; let (file, metadata) = open_project_snapshot_regular_file(&path, "项目批量读取") .map_err(|_| "not-safe-regular-file")?; if metadata.len() > MAX_FILE_BYTES as u64 { @@ -335,7 +492,7 @@ where pub(super) async fn read_project_context(root: &Path, arguments: &Value) -> Result { read_batch_with( root, - normalize_requests(arguments)?, + normalize_requests(root, arguments)?, MAX_RESPONSE_BYTES, read_file, ) @@ -397,7 +554,7 @@ mod tests { use super::*; fn project() -> (tempfile::TempDir, PathBuf) { let temp = tempfile::tempdir().unwrap(); - let root = temp.path().join("project"); + let root = temp.path().canonicalize().unwrap().join("project"); init_local_game_project_at(&root, "batch-read-project", "批读测试").unwrap(); (temp, root) } @@ -422,6 +579,35 @@ mod tests { ); assert_eq!(reader.position(), MAX_FILE_BYTES as u64 + 1); } + #[cfg(unix)] + #[test] + fn external_reads_reject_directory_and_file_links() { + use std::os::unix::fs::symlink; + let temp = tempfile::tempdir().unwrap(); + let base = temp.path().canonicalize().unwrap(); + let root = base.join("project"); + std::fs::create_dir(&root).unwrap(); + let private = base.join(".ssh"); + std::fs::create_dir(&private).unwrap(); + std::fs::write(private.join("config"), "test-only-private-data").unwrap(); + symlink(&private, base.join("alias")).unwrap(); + symlink(private.join("config"), base.join("linked.txt")).unwrap(); + for path in [ + base.join("alias/config"), + PathBuf::from("../alias/config"), + base.join("linked.txt"), + ] { + assert_eq!( + bounded_bytes(&root, &path.to_string_lossy()), + Err("linked-path") + ); + } + std::fs::write(base.join("ordinary.txt"), "ordinary").unwrap(); + assert_eq!( + bounded_bytes(&root, "../ordinary.txt").unwrap(), + b"ordinary" + ); + } #[tokio::test] async fn hard_links_and_control_files_are_not_project_context() { let (_temp, root) = project(); @@ -449,7 +635,7 @@ mod tests { let swap = Arc::clone(&owner); let result = read_batch_with( &root, - normalize_requests(&json!({"files":[{"path":"code.js"}]})).unwrap(), + normalize_requests(&root, &json!({"files":[{"path":"code.js"}]})).unwrap(), MAX_RESPONSE_BYTES, move |r, f, b| { let result = read_file(r, f, b); @@ -510,7 +696,7 @@ mod tests { ) .unwrap(); } - let files=normalize_requests(&json!({"files":(0..4).map(|i|json!({"path":format!("source-{i}.js")})).collect::>()})).unwrap(); + let files=normalize_requests(&root,&json!({"files":(0..4).map(|i|json!({"path":format!("source-{i}.js")})).collect::>()})).unwrap(); let barrier = Arc::new((std::sync::Mutex::new(0usize), std::sync::Condvar::new())); let result = tokio::time::timeout( std::time::Duration::from_secs(10), @@ -549,10 +735,39 @@ mod tests { assert_eq!(result["files"][i]["status"], "error"); } assert_eq!(result["files"][4]["code"], "file-too-large"); - assert!(normalize_requests(&json!({"files":[{"path":"../escape"}]})).is_err()); - assert!( - normalize_requests(&json!({"files":[{"path":"same.js"},{"path":"same.js"}]})).is_err() - ); + std::fs::write(_temp.path().join("outside.txt"), "outside-body\n").unwrap(); + std::fs::write(_temp.path().join(".env"), "SECRET=1\n").unwrap(); + let outside = read_project_context( + &root, + &json!({"files":[{"path":"../outside.txt"},{"path":"../.env"}]}), + ) + .await + .unwrap(); + assert_eq!(outside["files"][0]["status"], "ok"); + assert_eq!(outside["files"][0]["content"], "outside-body\n"); + assert_eq!(outside["files"][1]["status"], "error"); + let absolute = read_project_context( + &root, + &json!({"files":[{"path": _temp.path().canonicalize().unwrap().join("outside.txt").to_string_lossy()}]}), + ) + .await + .unwrap(); + assert_eq!(absolute["files"][0]["status"], "ok"); + assert_eq!(absolute["files"][0]["content"], "outside-body\n"); + let inside = read_project_context( + &root, + &json!({"files":[{"path": root.join("说明.js").to_string_lossy()}]}), + ) + .await + .unwrap(); + assert_eq!(inside["files"][0]["status"], "ok"); + assert_eq!(inside["files"][0]["path"], "说明.js"); + assert_eq!(inside["files"][0]["content"], "中文一\n中文二\n中文三\n"); + assert!(normalize_requests( + &root, + &json!({"files":[{"path":"same.js"},{"path":"same.js"}]}) + ) + .is_err()); } #[tokio::test] async fn serialized_budget_paginates_whole_lines_without_looping() { @@ -579,7 +794,7 @@ mod tests { std::fs::write(root.join("code.js"), "before\n").unwrap(); let value = read_batch_with( &root, - normalize_requests(&json!({"files":[{"path":"code.js"}]})).unwrap(), + normalize_requests(&root, &json!({"files":[{"path":"code.js"}]})).unwrap(), MAX_RESPONSE_BYTES, |r, f, b| { let result = read_file(r, f, b); diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_tool_bridge.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_tool_bridge.rs index 93b0d5741..00fd15be0 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_tool_bridge.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_tool_bridge.rs @@ -1154,6 +1154,50 @@ fn bridge_project_file_is_hidden_control_path(path: &str) -> bool { }) } +const EXTERNAL_READ_LIST_MAX_FILES: usize = 2000; + +fn list_external_read_files(dir: &Path) -> Result, String> { + super::direct_project_context::reject_external_read_links(dir) + .map_err(|code| format!("读取目录失败:{code}"))?; + if !dir.is_dir() { + return Err(format!("读取目录失败:{} 不是目录", dir.display())); + } + let mut files = Vec::new(); + let mut dirs = vec![dir.to_path_buf()]; + while let Some(current) = dirs.pop() { + super::direct_project_context::reject_external_read_links(¤t) + .map_err(|code| format!("读取目录失败:{code}"))?; + let entries = std::fs::read_dir(¤t) + .map_err(|error| format!("读取目录失败:{}: {error}", current.display()))?; + for entry in entries { + let entry = entry.map_err(|error| format!("读取目录失败:{error}"))?; + let path = entry.path(); + let metadata = std::fs::symlink_metadata(&path) + .map_err(|error| format!("读取文件元数据失败:{}: {error}", path.display()))?; + if metadata.file_type().is_symlink() || windows_metadata_is_reparse_point(&metadata) { + continue; + } + let display = path.to_string_lossy().replace('\\', "/"); + if super::direct_project_context::external_read_is_protected(&display) { + continue; + } + if metadata.is_dir() { + dirs.push(path); + continue; + } + if !metadata.is_file() { + continue; + } + files.push((display, metadata.len())); + if files.len() > EXTERNAL_READ_LIST_MAX_FILES { + return Err("目录条目过多".to_string()); + } + } + } + files.sort_by(|left, right| left.0.cmp(&right.0)); + Ok(files) +} + fn bridge_list_project_files(root: &Path, arguments: &Value) -> Value { let result = (|| { bridge_reject_unknown_fields(arguments, &["path", "query", "kind", "offset", "limit"])?; @@ -1161,18 +1205,20 @@ fn bridge_list_project_files(root: &Path, arguments: &Value) -> Value { let scope = bridge_optional_bounded_string( arguments, "path", - DIRECT_TOOL_BRIDGE_MAX_LOCAL_ASSET_PATH_CHARS, + super::direct_project_context::GAME_AGENT_READ_PATH_MAX_CHARS, )? - .map(|path| normalize_relative_path(&path)) + .map(|path| super::direct_project_context::resolve_game_agent_read_path(root, &path)) .transpose()?; - if scope - .as_deref() - .is_some_and(bridge_project_file_is_hidden_control_path) - { - return Err("工具参数 path 不得访问受保护项目控制面".to_string()); - } - if let Some(scope) = scope.as_deref() { - reject_sensitive_project_file_read(scope)?; + if let Some(scope) = scope.as_ref() { + if let Some(relative) = scope.project_relative.as_deref() { + if bridge_project_file_is_hidden_control_path(relative) + || reject_sensitive_project_file_read(relative).is_err() + { + return Err("工具参数 path 不得访问受保护项目控制面".to_string()); + } + } else if super::direct_project_context::external_read_is_protected(&scope.display) { + return Err("工具参数 path 不得访问受保护路径".to_string()); + } } let query = bridge_optional_bounded_string(arguments, "query", 120)? .map(|value| value.to_lowercase()); @@ -1192,8 +1238,58 @@ fn bridge_list_project_files(root: &Path, arguments: &Value) -> Value { .iter() .map(|asset| (asset.local_path.clone(), asset.id.clone())) .collect::>(); + if scope + .as_ref() + .is_some_and(|scope| scope.project_relative.is_none()) + { + let scope = scope.expect("external list scope"); + let mut files = list_external_read_files(&scope.absolute)? + .into_iter() + .filter(|(path, _)| { + let (category, _) = bridge_project_file_class(path); + requested_kind == "all" || requested_kind == category + }) + .filter(|(path, _)| { + query + .as_deref() + .is_none_or(|query| path.to_lowercase().contains(query)) + }) + .collect::>(); + files.sort_by(|left, right| left.0.cmp(&right.0)); + let total = files.len(); + let page = files + .drain(..) + .skip(offset) + .take(limit) + .map(|(path, size)| { + let (category, media_type) = bridge_project_file_class(&path); + json!({ + "path": path, + "sizeBytes": size, + "kind": category, + "mediaType": media_type, + "assetImportable": false, + "registered": false, + "localAssetId": Value::Null, + }) + }) + .collect::>(); + let next_offset = (offset + page.len() < total).then_some(offset + page.len()); + return Ok(json!({ + "status": "completed", + "total": total, + "offset": offset, + "limit": limit, + "nextOffset": next_offset, + "files": page, + "next": "这些路径在当前项目外,可用 agc_read_project_context 读取。" + })); + } + let scope_relative = scope + .and_then(|scope| scope.project_relative) + .filter(|path| !path.is_empty()); let listed = list_local_project_files_at(root)?; - let scope_prefix = scope.as_ref().map(|path| format!("{path}/")); + let scope_prefix = scope_relative.as_ref().map(|path| format!("{path}/")); let mut files = listed .files .into_iter() @@ -1202,7 +1298,7 @@ fn bridge_list_project_files(root: &Path, arguments: &Value) -> Value { .filter(|file| !should_skip_project_snapshot_path(&file.path)) .filter(|file| reject_sensitive_project_file_read(&file.path).is_ok()) .filter(|file| { - scope.as_ref().is_none_or(|scope| { + scope_relative.as_ref().is_none_or(|scope| { file.path == *scope || scope_prefix .as_ref() @@ -3914,6 +4010,30 @@ mod tests { } } + #[cfg(unix)] + #[test] + fn external_listing_rejects_linked_scope_and_ancestors() { + use std::os::unix::fs::symlink; + let temp = tempfile::tempdir().unwrap(); + let base = temp.path().canonicalize().unwrap(); + let private = base.join(".ssh"); + fs::create_dir_all(private.join("nested")).unwrap(); + fs::write(private.join("nested/config"), "test-only-private-data").unwrap(); + symlink(&private, base.join("alias")).unwrap(); + for path in [base.join("alias"), base.join("alias/nested")] { + assert!(list_external_read_files(&path) + .unwrap_err() + .contains("linked-path")); + } + let ordinary = base.join("ordinary"); + fs::create_dir(&ordinary).unwrap(); + fs::write(ordinary.join("note.txt"), "ordinary").unwrap(); + symlink(&private, ordinary.join("alias")).unwrap(); + let files = list_external_read_files(&ordinary).unwrap(); + assert_eq!(files.len(), 1); + assert!(files[0].0.ends_with("/ordinary/note.txt")); + } + /// Cocos Creator 资源在发现层必须同时满足两件事:给出可筛选的类别、且 `mediaType` /// 非空(`assetImportable` 由它推导,是 Agent 唯一能提交登记的入口)。 /// @@ -4017,6 +4137,60 @@ mod tests { assert_eq!(importability.get("assets/hero.png"), Some(&true)); assert_eq!(importability.get("assets/preview.gif"), Some(&true)); assert_eq!(importability.get("assets/vector.svg"), Some(&true)); + + let sibling = tempfile::tempdir().expect("outside root"); + let outside = sibling.path().canonicalize().unwrap().join("outside"); + fs::create_dir(&outside).expect("create outside directory"); + fs::write(outside.join("note.txt"), b"hello").expect("write outside note"); + let listed = bridge_list_project_files( + temporary.path(), + &json!({ "path": outside.to_string_lossy(), "limit": 10 }), + ); + assert_eq!(listed.get("isError").and_then(Value::as_bool), Some(false)); + let payload: Value = serde_json::from_str( + listed + .pointer("/content/0/text") + .and_then(Value::as_str) + .expect("outside listing text"), + ) + .expect("parse outside listing"); + let paths = payload["files"] + .as_array() + .expect("outside files") + .iter() + .filter_map(|file| file["path"].as_str()) + .collect::>(); + assert!(paths.iter().any(|path| path.ends_with("/outside/note.txt"))); + assert!(payload["files"].as_array().unwrap().iter().all(|file| { + file["registered"].as_bool() == Some(false) + && file["assetImportable"].as_bool() == Some(false) + })); + + let rooted = bridge_list_project_files( + temporary.path(), + &json!({ + "path": temporary.path().to_string_lossy(), + "kind": "image", + "limit": 10 + }), + ); + assert_eq!(rooted.get("isError").and_then(Value::as_bool), Some(false)); + let rooted_payload: Value = serde_json::from_str( + rooted + .pointer("/content/0/text") + .and_then(Value::as_str) + .expect("project-root listing text"), + ) + .expect("parse project-root listing"); + assert_eq!( + rooted_payload["files"] + .as_array() + .expect("project-root files") + .iter() + .find(|file| file["path"] == "assets/hero.png") + .and_then(|file| file["assetImportable"].as_bool()), + Some(true) + ); } #[tokio::test] diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_tools_mcp.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_tools_mcp.rs index 28c506608..56fb8c721 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_tools_mcp.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_tools_mcp.rs @@ -413,7 +413,7 @@ fn direct_tools_mcp_specs_for_plugins( "path": { "type": "string", "minLength": 1, - "maxLength": 512, + "maxLength": (super::direct_project_context::GAME_AGENT_READ_PATH_MAX_CHARS), "description": prompt_text!("directTools.agc_list_project_files.parameters.path") }, "query": { @@ -601,7 +601,7 @@ fn direct_tools_mcp_specs_for_plugins( "name":"agc_read_project_context", "description":prompt_text!("directTools.agc_read_project_context.description"), "inputSchema":{"type":"object","properties":{"files":{"type":"array","minItems":1,"maxItems":8,"items":{ - "type":"object","properties":{"path":{"type":"string","maxLength":512},"startLine":{"type":"integer","minimum":1},"maxLines":{"type":"integer","minimum":1,"maximum":2000}}, + "type":"object","properties":{"path":{"type":"string","maxLength":4096},"startLine":{"type":"integer","minimum":1},"maxLines":{"type":"integer","minimum":1,"maximum":2000}}, "required":["path"],"additionalProperties":false }}},"required":["files"],"additionalProperties":false} }), @@ -908,16 +908,23 @@ fn validate_registered_assets_arguments(arguments: &Value) -> Result<(), String> fn validate_project_file_list_arguments(arguments: &Value) -> Result<(), String> { validate_tool_object_fields(arguments, &["path", "query", "kind", "offset", "limit"])?; if arguments.get("path").is_some() { - let path = bounded_tool_string(arguments, "path", 512)?; - let path = normalize_relative_path(&path)?; - if path - .split('/') - .next() - .is_some_and(|part| part.eq_ignore_ascii_case(".agent")) - { - return Err("工具参数 path 不得访问 .agent 控制面".to_string()); + let path = bounded_tool_string( + arguments, + "path", + super::direct_project_context::GAME_AGENT_READ_PATH_MAX_CHARS, + )?; + let slash = path.replace('\\', "/"); + if !Path::new(&slash).is_absolute() && !slash.split('/').any(|part| part == "..") { + let path = normalize_relative_path(&path)?; + if path + .split('/') + .next() + .is_some_and(|part| part.eq_ignore_ascii_case(".agent")) + { + return Err("工具参数 path 不得访问 .agent 控制面".to_string()); + } + reject_sensitive_project_file_read(&path)?; } - reject_sensitive_project_file_read(&path)?; } if arguments.get("query").is_some() { bounded_tool_string(arguments, "query", 120)?; @@ -3295,7 +3302,7 @@ mod tests { assert!(validate_project_file_list_arguments(&json!({ "path": "../outside" })) - .is_err()); + .is_ok()); assert!(validate_project_file_list_arguments(&json!({ "kind": "secret" })) diff --git a/docs/project-memory/shared-memory/decision-log.md b/docs/project-memory/shared-memory/decision-log.md index de57c0a07..ba85d09b1 100644 --- a/docs/project-memory/shared-memory/decision-log.md +++ b/docs/project-memory/shared-memory/decision-log.md @@ -12,6 +12,12 @@ - 验证:`npx vitest run apps/ai-game-creator-shell/tests` → 191 passed / 1 skipped;`npx tsc -p apps/ai-game-creator-shell/tsconfig.json --noEmit`;`eslint`(改动文件)、`prettier --check`(改动代码文件)、`npm run check:encoding`、`npm run check:doc-index`、`npm run typecheck`、`git diff --check`。 - 边界:本次只改这一条状态通道的呈现。首页那行**带动作**的提示留在原地——建项已落盘但没进项目时的「已创建的工作区:<路径>」+「打开已创建的工作区」按钮要能点,不能飘走;设置弹层里「项目创建目录」选择器的「已取消」仍是弹层内自己的状态文案,未一并改动;浮层外观没有在真机窗口里目测过(取消原生选择器需要 Tauri 客户端窗口)。 +## 2026-09-29 Game Agent 读取工具接受绝对路径和项目外路径 + +- 决策:`agc_read_project_context` 与 `agc_list_project_files` 可以读取绝对路径,以及用 `..` 离开当前项目的路径。项目内相对路径仍拒绝 `.agent`、凭据文件名、符号链接和硬链接。项目外读取同样拒绝这些受保护名字和链接,但不因路径落在项目外而失败。 +- 范围:`agc_write_file`、`agc_apply_patch`、素材导入和原生补丁仍限定在当前项目。共享 `normalize_relative_path` 不放宽。Codex 进程沙箱仍是 `read-only`。提示词本轮未改。 +- 关联:`apps/ai-game-creator-shell/src-tauri/src/agent/direct_project_context.rs`、`direct_tools_mcp.rs`、`direct_tool_bridge.rs`。 + ## 2026-09-29 安装钩子的可执行逻辑只允许待在 !macro 里 - 背景:dev 渠道改名迁移钩子的第一版把迁移写成顶层 `Function`,并在函数体里调 `nsis_tauri_utils::KillProcess`;Jenkins 打 Windows 包时 makensis 在 `installer-hooks.nsh` 第 68 行报 `Plugin not found` 并中断(模板第 28 行 include 钩子,早于模板常量与 `!addplugindir`)。 diff --git a/docs/project-memory/shared-memory/pitfalls.md b/docs/project-memory/shared-memory/pitfalls.md index 8c9ed77f7..26c2e3a52 100644 --- a/docs/project-memory/shared-memory/pitfalls.md +++ b/docs/project-memory/shared-memory/pitfalls.md @@ -1,5 +1,13 @@ # 踩坑与排障记录 +## 2026-09-29 Game Agent 读工具被项目相对路径规则拦住 + +- 项目外读取不能只依赖末段 `O_NOFOLLOW`:父目录符号链接可隐藏 `.ssh` 等受保护名字。文件读取和目录列表在访问前逐段检查原始路径,拒绝符号链接与 Windows 重解析点;目录扫描对子目录再次检查。系统临时目录若含平台别名(例如 macOS `/var`),普通读取测试使用临时目录的 canonical 路径,不能通过 canonicalize 待读路径来抹掉待检测链接。 +- 现象:`agc_read_project_context` 或 `agc_list_project_files` 对绝对路径返回「项目文件路径不能是绝对路径」,对 `..` 返回「项目文件路径非法」。 +- 原因:这两条读取入口以前直接调用共享 `normalize_relative_path`。该函数同时服务项目浏览、快照和写入,不能放宽。 +- 处理:读取入口改走 `resolve_game_agent_read_path`。绝对路径和离开项目的 `..` 可以读;项目内的 `.agent`、凭据文件名、符号链接和硬链接仍然拒绝。写入、补丁、素材导入和 `read-only` 沙箱不变。 +- 关联:`apps/ai-game-creator-shell/src-tauri/src/agent/direct_project_context.rs`。 + ## 2026-09-29 NSIS 安装钩子被 include 在模板常量与插件目录之前(Jenkins 打包在 makensis 处中断) - **现象**:`Genarrative-Agc-Windows-Build` 打 dev 渠道 Windows 包时,Rust 编译过了,makensis 却报 `Plugin not found, cannot call nsis_tauri_utils::KillProcess`、`!include: error in script: "...\installer-hooks.nsh" on line 68`、`Error in script "...\installer.nsi" on line 28 -- aborting creation process`,Tauri 最后只补一句 `failed to bundle project 'The system cannot find the file specified. (os error 2)'`,退出码 1。 diff --git a/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md b/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md index 449d71f30..bed8b3a6c 100644 --- a/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md +++ b/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md @@ -205,6 +205,7 @@ UI 编辑器的“分析参考图”步骤、Rust 命令 `suggest_ui_design_sema - OAuth 在目录捕获与模型进程内产生的轮换结果仅在宿主私有 runtime 中延续,按原始认证来源指纹、路由、项目及稳定账户/用户身份绑定后复制到下一回合的新私有 HOME;不回写用户原始认证文件,不缓存 API Key。来源、路由或身份改变时不继承,迟到的旧实例不得覆盖新回合结果;轮换结果未确认时禁止重用旧 token。 - 实例退役与验收完成使用不同判据:Windows 仍要求完整 Job 退出;Unix 已确认主进程退出且所控进程组为空时可退役并允许下一显式用户回合,但 group-only 证明不能使原会话从 Interrupted 升为 Completed,不能自动重放旧操作。主进程、所属组或退出状态仍未知时继续阻断新实例。 - 补丁完整复用固定版本官方语法解析与执行语义。宿主枚举每个源和目标(包括所有 Move 和重复操作),检查项目边界、受保护路径和链接,在本地短写事务中复核后执行;取消、预算、退出证明和未知结果仍走统一宿主控制。失败可能已有部分修改,不能声称全批回滚或自动原样重放。 +- 2026-09-29:`agc_read_project_context` 与 `agc_list_project_files` 可以读取绝对路径,以及用 `..` 离开当前项目的路径。项目内相对路径仍拒绝 `.agent`、凭据文件名、符号链接和硬链接;项目外读取同样拒绝这些受保护名字和链接。项目外文件打开与目录列表须逐段检查访问路径(包括列表起始目录及其父目录),拒绝符号链接与 Windows 重解析点,不能只检查末段文件;目录扫描在进入子目录前再次检查。含系统目录别名的外部路径也遵守该规则,调用方应提供不含链接的真实路径。`agc_write_file`、`agc_apply_patch`、素材导入和原生补丁仍限定在当前项目。Codex 进程沙箱仍是 `read-only`。提示词未改。 - 模型计划进度保存到同一回合的宿主状态,仅作展示,不等于验收通过;计划更新和长资源调用可以同时推进。真正共享资源的修改仍保持必要顺序。 ### 验收