修复 Game Agent 项目外读取的父目录链接绕过
Project CI / Backend tests (pull_request) Has been cancelled
Project CI / Native shell tests (pull_request) Has been cancelled
Project CI / Frontend tests (pull_request) Has been cancelled
Project CI / Repository checks (pull_request) Has been cancelled
Project CI / AI game creator shell web tests (pull_request) Has been cancelled
Project CI / AI game creator shell Rust smoke (pull_request) Has been cancelled
Project CI / AI game creator shell Rust crates (pull_request) Has been cancelled
Project CI / AI game creator shell Rust lane 1/2 (pull_request) Has been cancelled
Project CI / AI game creator shell Rust lane 2/2 (pull_request) Has been cancelled

文件读取和目录列表逐段拒绝符号链接及 Windows 重解析点
新增受保护目录别名与文件链接回归测试,保留普通外部读取
同步技术方案与排障记录,明确真实路径要求
This commit is contained in:
2026-09-29 13:34:59 +01:00
parent 5454062423
commit 0dacfec429
4 changed files with 80 additions and 7 deletions
@@ -193,6 +193,23 @@ pub(super) fn external_read_is_protected(display: &str) -> bool {
.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(&current).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<Vec<FileRequest>, String> {
let input: BatchRequest = serde_json::from_value(arguments.clone())
.map_err(|_| "批量读取参数只接受 files,以及 path/startLine/maxLines".to_string())?;
@@ -245,9 +262,7 @@ fn bounded_bytes(root: &Path, raw: &str) -> Result<Vec<u8>, &'static str> {
if external_read_is_protected(&target.display) {
return Err("sensitive-path");
}
if !target.absolute.exists() {
return Err("file-not-found");
}
reject_external_read_links(&target.absolute)?;
target.absolute
};
let (file, metadata) = open_project_snapshot_regular_file(&path, "项目批量读取")
@@ -539,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)
}
@@ -564,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();
@@ -704,7 +748,7 @@ mod tests {
assert_eq!(outside["files"][1]["status"], "error");
let absolute = read_project_context(
&root,
&json!({"files":[{"path": _temp.path().join("outside.txt").to_string_lossy()}]}),
&json!({"files":[{"path": _temp.path().canonicalize().unwrap().join("outside.txt").to_string_lossy()}]}),
)
.await
.unwrap();
@@ -1157,12 +1157,16 @@ 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<Vec<(String, u64)>, 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(&current)
.map_err(|code| format!("读取目录失败:{code}"))?;
let entries = std::fs::read_dir(&current)
.map_err(|error| format!("读取目录失败:{}: {error}", current.display()))?;
for entry in entries {
@@ -4006,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 唯一能提交登记的入口)。
///
@@ -4111,7 +4139,7 @@ mod tests {
assert_eq!(importability.get("assets/vector.svg"), Some(&true));
let sibling = tempfile::tempdir().expect("outside root");
let outside = sibling.path().join("outside");
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(
@@ -2,6 +2,7 @@
## 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` 沙箱不变。
@@ -205,7 +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`、凭据文件名、符号链接和硬链接;项目外读取同样拒绝这些受保护名字和链接。`agc_write_file`、`agc_apply_patch`、素材导入和原生补丁仍限定在当前项目。Codex 进程沙箱仍是 `read-only`。提示词未改。
- 2026-09-29:`agc_read_project_context` 与 `agc_list_project_files` 可以读取绝对路径,以及用 `..` 离开当前项目的路径。项目内相对路径仍拒绝 `.agent`、凭据文件名、符号链接和硬链接;项目外读取同样拒绝这些受保护名字和链接。项目外文件打开与目录列表须逐段检查访问路径(包括列表起始目录及其父目录),拒绝符号链接与 Windows 重解析点,不能只检查末段文件;目录扫描在进入子目录前再次检查。含系统目录别名的外部路径也遵守该规则,调用方应提供不含链接的真实路径。`agc_write_file`、`agc_apply_patch`、素材导入和原生补丁仍限定在当前项目。Codex 进程沙箱仍是 `read-only`。提示词未改。
- 模型计划进度保存到同一回合的宿主状态,仅作展示,不等于验收通过;计划更新和长资源调用可以同时推进。真正共享资源的修改仍保持必要顺序。
### 验收