放开 Game Agent 读取工具的绝对路径和项目外路径
Project CI / AI game creator shell Rust crates (pull_request) Successful in 1m32s
Project CI / Backend tests (pull_request) Failing after 20s
Project CI / AI game creator shell Rust smoke (pull_request) Successful in 1m56s
Project CI / Frontend tests (pull_request) Successful in 2m26s
Project CI / Repository checks (pull_request) Failing after 15s
Project CI / AI game creator shell web tests (pull_request) Successful in 1m42s
Project CI / AI game creator shell Rust lane 2/2 (pull_request) Has been cancelled
Project CI / AI game creator shell Rust lane 1/2 (pull_request) Has been cancelled
Project CI / Native shell tests (pull_request) Has been cancelled
Project CI / AI game creator shell Rust crates (pull_request) Successful in 1m32s
Project CI / Backend tests (pull_request) Failing after 20s
Project CI / AI game creator shell Rust smoke (pull_request) Successful in 1m56s
Project CI / Frontend tests (pull_request) Successful in 2m26s
Project CI / Repository checks (pull_request) Failing after 15s
Project CI / AI game creator shell web tests (pull_request) Successful in 1m42s
Project CI / AI game creator shell Rust lane 2/2 (pull_request) Has been cancelled
Project CI / AI game creator shell Rust lane 1/2 (pull_request) Has been cancelled
Project CI / Native shell tests (pull_request) Has been cancelled
- agc_read_project_context 与 agc_list_project_files 改走 resolve_game_agent_read_path,绝对路径和离开项目的 .. 可以读 - 落在项目内的路径仍按项目相对路径处理,继续拒绝 .agent、凭据文件名、符号链接和硬链接 - 项目外读取同样拒绝这些受保护名字和链接,目录列表标记为未登记且不可导入 - agc_write_file、agc_apply_patch、素材导入和共享 normalize_relative_path 仍限定在当前项目 - Codex 进程沙箱仍是 read-only,提示词未改 - 决策记录、实施计划和 pitfalls 同步该口径
This commit is contained in:
@@ -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,135 @@ fn context_identity(root: &Path) -> Result<ContextIdentity, String> {
|
||||
})
|
||||
}
|
||||
|
||||
fn normalize_requests(arguments: &Value) -> Result<Vec<FileRequest>, String> {
|
||||
pub(super) struct GameAgentReadTarget {
|
||||
pub display: String,
|
||||
pub absolute: PathBuf,
|
||||
pub project_relative: Option<String>,
|
||||
}
|
||||
|
||||
pub(super) fn resolve_game_agent_read_path(
|
||||
root: &Path,
|
||||
raw: &str,
|
||||
) -> Result<GameAgentReadTarget, String> {
|
||||
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<String> {
|
||||
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<String> {
|
||||
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::<Vec<_>>();
|
||||
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))
|
||||
}
|
||||
|
||||
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())?;
|
||||
if input.files.is_empty() || input.files.len() > MAX_FILES {
|
||||
@@ -75,9 +202,10 @@ fn normalize_requests(arguments: &Value) -> Result<Vec<FileRequest>, 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 +220,36 @@ fn normalize_requests(arguments: &Value) -> Result<Vec<FileRequest>, String> {
|
||||
Ok(files)
|
||||
}
|
||||
|
||||
fn bounded_bytes(root: &Path, relative: &str) -> Result<Vec<u8>, &'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<Vec<u8>, &'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");
|
||||
}
|
||||
if !target.absolute.exists() {
|
||||
return Err("file-not-found");
|
||||
}
|
||||
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 +477,7 @@ where
|
||||
pub(super) async fn read_project_context(root: &Path, arguments: &Value) -> Result<Value, String> {
|
||||
read_batch_with(
|
||||
root,
|
||||
normalize_requests(arguments)?,
|
||||
normalize_requests(root, arguments)?,
|
||||
MAX_RESPONSE_BYTES,
|
||||
read_file,
|
||||
)
|
||||
@@ -449,7 +591,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 +652,7 @@ mod tests {
|
||||
)
|
||||
.unwrap();
|
||||
}
|
||||
let files=normalize_requests(&json!({"files":(0..4).map(|i|json!({"path":format!("source-{i}.js")})).collect::<Vec<_>>()})).unwrap();
|
||||
let files=normalize_requests(&root,&json!({"files":(0..4).map(|i|json!({"path":format!("source-{i}.js")})).collect::<Vec<_>>()})).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 +691,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().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 +750,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);
|
||||
|
||||
@@ -1154,6 +1154,46 @@ 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> {
|
||||
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() {
|
||||
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 +1201,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 +1234,58 @@ fn bridge_list_project_files(root: &Path, arguments: &Value) -> Value {
|
||||
.iter()
|
||||
.map(|asset| (asset.local_path.clone(), asset.id.clone()))
|
||||
.collect::<BTreeMap<_, _>>();
|
||||
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::<Vec<_>>();
|
||||
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::<Vec<_>>();
|
||||
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 +1294,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()
|
||||
@@ -4017,6 +4109,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().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::<Vec<_>>();
|
||||
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]
|
||||
|
||||
@@ -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"
|
||||
}))
|
||||
|
||||
@@ -1,5 +1,11 @@
|
||||
# 决策记录
|
||||
|
||||
## 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`)。
|
||||
|
||||
@@ -1,5 +1,12 @@
|
||||
# 踩坑与排障记录
|
||||
|
||||
## 2026-09-29 Game Agent 读工具被项目相对路径规则拦住
|
||||
|
||||
- 现象:`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。
|
||||
|
||||
@@ -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`、凭据文件名、符号链接和硬链接;项目外读取同样拒绝这些受保护名字和链接。`agc_write_file`、`agc_apply_patch`、素材导入和原生补丁仍限定在当前项目。Codex 进程沙箱仍是 `read-only`。提示词未改。
|
||||
- 模型计划进度保存到同一回合的宿主状态,仅作展示,不等于验收通过;计划更新和长资源调用可以同时推进。真正共享资源的修改仍保持必要顺序。
|
||||
|
||||
### 验收
|
||||
|
||||
Reference in New Issue
Block a user