阻止命令输出污染 AGC 文件写入
Project CI / Repository checks (push) Successful in 3m22s
Project CI / Frontend tests (push) Successful in 4m2s
Project CI / Backend tests (push) Failing after 4m21s
Project CI / Native shell tests (push) Failing after 7m10s

明确 agc_write_file 只接受原始 UTF-8 文件正文

拒绝 Exit code、Wall time、Output 包装并补充回归测试
This commit is contained in:
2026-08-29 00:49:53 +08:00
parent 5460c13216
commit 63abea0b3e
3 changed files with 45 additions and 1 deletions
@@ -13,7 +13,7 @@ const MAX_DIRECT_HOME_ATTACHMENT_MEDIA_TYPE_CHARS: usize = 96;
const MIN_DIRECT_CLIENT_TURN_ID_CHARS: usize = 6;
const MAX_DIRECT_CLIENT_TURN_ID_CHARS: usize = 160;
const DIRECT_TAONIER_IDENTITY_GUIDANCE: &str = "对外身份合同:你是“陶泥儿”,是 Genarrative 的游戏创作助手。用户询问你是谁、你的名称或能力时,以陶泥儿的身份回答;不要把 Codex、ChatGPT、OpenAI、模型、通用 AI 助手或内部执行智能体当作自己的名称或对外身份。Codex app-server 仅是客户端内部执行技术;只有用户明确询问底层实现时才可如实说明,同时仍以陶泥儿自称。";
const DIRECT_AGC_ENGINEERING_GUIDANCE: &str = "AGC 工程合同(仅说明项目边界,不是流程门槛):当前 Codex cwd 是用户选择的项目目录(工作区根),源码、素材、音效和其它资源按项目现有结构放置;先按需读取当前 cwd 下适用的 `AGENTS.md`、README 或项目说明,把它们当作项目规范参考。原生文件工具、patch 和命令参数使用 cwd 相对路径,例如 `index.html`、`style.css`、`game.js`、`assets/hero.png`;如果 Codex 原生文件修改不可用,可以按需用客户端 `agc_write_file` 把文本写入项目相对路径。`../`、绝对路径、`.agent/`、`.git/`、密钥文件和 Runtime 控制面属于客户端边界,不能请求扩权或直接改写。DirectProject 提供 Codex 原生文件、搜索、命令、图片查看、Skill,以及经审核的 `agc_tools` MCP。你可以按需选择这些能力:`agc_write_file` 写入代码、配置、资源依赖清单或说明文件;`agc_generate_image` 生成普通图片、角色图、视觉规范图(icon-spec)、UI 设计图或发布宣传图;`agc_edit_image` 修改已登记图片;`taonier_prepare_game_art` 准备完整游戏美术包及可用的 canonical 切片;`agc_list_registered_assets`、`agc_list_project_files`、`agc_list_account_assets`、`agc_import_account_assets` 用于发现和接入资源依赖;`agc_create_or_derive_resource` 用于视频、角色动画、音效或背景音乐;`agc_browser_playtest` 用于需要时的本地试玩观察;`agc_read_skill_resource` 用于按需读取审核 Skill。切图、资源依赖、规范图和试玩都只是可选工具提示,不要求调用、固定顺序或特定产物,AGC 不会据此替你拆任务、编排 DAG、做强验收或阻止继续执行;不要等待 Supervisor、harness 或宿主规划器。不要读取或输出凭据、Token、Cookie、auth.json、.env 或宿主私密路径;项目锁、付费提交、幂等键、下载校验和客户端投影由客户端处理。游戏文件真实变化后客户端可登记资源和版本,Codex 不直接保存或伪造项目版本。";
const DIRECT_AGC_ENGINEERING_GUIDANCE: &str = "AGC 工程合同(仅说明项目边界,不是流程门槛):当前 Codex cwd 是用户选择的项目目录(工作区根),源码、素材、音效和其它资源按项目现有结构放置;先按需读取当前 cwd 下适用的 `AGENTS.md`、README 或项目说明,把它们当作项目规范参考。原生文件工具、patch 和命令参数使用 cwd 相对路径,例如 `index.html`、`style.css`、`game.js`、`assets/hero.png`;如果 Codex 原生文件修改不可用,可以按需用客户端 `agc_write_file` 把文本写入项目相对路径。调用 `agc_write_file` 时,content 必须是目标文件的完整原始 UTF-8 正文;不得把 command.exec 的 Exit code、Wall time、Output 包装、终端日志或解释文字一起复制进 content,命令结果只能用于判断,不能当作文件正文。`../`、绝对路径、`.agent/`、`.git/`、密钥文件和 Runtime 控制面属于客户端边界,不能请求扩权或直接改写。DirectProject 提供 Codex 原生文件、搜索、命令、图片查看、Skill,以及经审核的 `agc_tools` MCP。你可以按需选择这些能力:`agc_write_file` 写入代码、配置、资源依赖清单或说明文件;`agc_generate_image` 生成普通图片、角色图、视觉规范图(icon-spec)、UI 设计图或发布宣传图;`agc_edit_image` 修改已登记图片;`taonier_prepare_game_art` 准备完整游戏美术包及可用的 canonical 切片;`agc_list_registered_assets`、`agc_list_project_files`、`agc_list_account_assets`、`agc_import_account_assets` 用于发现和接入资源依赖;`agc_create_or_derive_resource` 用于视频、角色动画、音效或背景音乐;`agc_browser_playtest` 用于需要时的本地试玩观察;`agc_read_skill_resource` 用于按需读取审核 Skill。切图、资源依赖、规范图和试玩都只是可选工具提示,不要求调用、固定顺序或特定产物,AGC 不会据此替你拆任务、编排 DAG、做强验收或阻止继续执行;不要等待 Supervisor、harness 或宿主规划器。不要读取或输出凭据、Token、Cookie、auth.json、.env 或宿主私密路径;项目锁、付费提交、幂等键、下载校验和客户端投影由客户端处理。游戏文件真实变化后客户端可登记资源和版本,Codex 不直接保存或伪造项目版本。";
const DIRECT_CODEX_ART_SPEC_ASSET_PATH: &str = "assets/art-spec.png";
const DIRECT_CODEX_BACKGROUND_ASSET_PATH: &str = "assets/direct-game-background.png";
const DIRECT_CODEX_SPRITESHEET_ASSET_PATH: &str = "assets/art-spritesheet.png";
@@ -4407,6 +4407,8 @@ mod tests {
assert!(prompt.contains("AGC 工程合同(仅说明项目边界,不是流程门槛)"));
assert!(prompt.contains("先按需读取当前 cwd 下适用的 `AGENTS.md`"));
assert!(prompt.contains("agc_write_file"));
assert!(prompt.contains("content 必须是目标文件的完整原始 UTF-8 正文"));
assert!(prompt.contains("不得把 command.exec 的 Exit code、Wall time、Output 包装"));
assert!(prompt.contains("切图、资源依赖、规范图和试玩都只是可选工具提示"));
assert!(prompt.contains("不要求调用、固定顺序或特定产物"));
}
@@ -29,6 +29,23 @@ const DIRECT_TOOL_BRIDGE_MAX_RESOURCE_CALLS_PER_TURN: usize = 4;
const DIRECT_TOOL_BRIDGE_MAX_ACCOUNT_ASSET_ID_CHARS: usize = 512;
const DIRECT_TOOL_BRIDGE_MAX_LOCAL_ASSET_PATH_CHARS: usize = 512;
pub(crate) fn reject_command_output_wrapper(content: &str) -> Result<(), String> {
let mut lines = content.trim_start_matches('\u{feff}').lines();
let exit_line = lines.next().map(str::trim).unwrap_or_default();
let wall_time_line = lines.next().map(str::trim).unwrap_or_default();
let output_line = lines.next().map(str::trim).unwrap_or_default();
if exit_line.starts_with("Exit code:")
&& wall_time_line.starts_with("Wall time:")
&& output_line.eq_ignore_ascii_case("Output:")
{
return Err(
"工具参数 content 不能包含 command.exec 的 Exit code/Wall time/Output 包装;请只传原始 UTF-8 文件正文"
.to_string(),
);
}
Ok(())
}
struct DirectToolBridgeState {
root: PathBuf,
turn_authorization: StdMutex<DirectToolBridgeTurnAuthorization>,
@@ -1420,6 +1437,7 @@ fn bridge_write_file(root: &Path, arguments: &Value) -> Value {
if content.chars().any(|character| character == '\0') {
return Err("工具参数 content 不能包含 NUL".to_string());
}
reject_command_output_wrapper(content)?;
let _lock = acquire_project_write_lock(root, "direct-codex.file.write")?;
let written = write_local_project_file_at(root, &path, content)?;
let revision = advance_agent_runtime_project_revision_locked(root)?;
@@ -2475,6 +2493,18 @@ mod tests {
fs::read_to_string(temporary.path().join("game/index.html")).expect("read written"),
"<!doctype html><button>写入成功</button>"
);
let wrapped = bridge_write_file(
temporary.path(),
&json!({
"path": "game/index.html",
"content": "Exit code: 0\nWall time: 0.1 seconds\nOutput:\n<!doctype html><button>错误包装</button>"
}),
);
assert_eq!(wrapped.get("isError").and_then(Value::as_bool), Some(true));
assert_eq!(
fs::read_to_string(temporary.path().join("game/index.html")).expect("read unchanged"),
"<!doctype html><button>写入成功</button>"
);
assert!(
bridge_write_file(
temporary.path(),
@@ -77,6 +77,7 @@ fn direct_tools_mcp_specs_for(controlled_web_search: bool) -> Value {
},
"content": {
"type": "string",
"description": "目标文件的完整原始 UTF-8 正文;不要包含 command.exec 的 Exit code、Wall time、Output 包装、终端日志或解释文字",
"maxLength": DIRECT_TOOLS_MCP_MAX_WRITE_CONTENT_BYTES
}
},
@@ -435,6 +436,7 @@ fn validate_write_file_arguments(arguments: &Value) -> Result<(), String> {
if content.chars().any(|character| character == '\0') {
return Err("工具参数 content 不能包含 NUL".to_string());
}
reject_command_output_wrapper(content)?;
// Keep path normalization in the client bridge as the final authority;
// this early check only gives Codex a quick, deterministic argument error.
normalize_relative_path(&path).map(|_| ())
@@ -1279,6 +1281,16 @@ mod tests {
"content": "<html></html>"
}))
.is_ok());
assert!(validate_write_file_arguments(&json!({
"path": "game/index.html",
"content": "Exit code: 0\nWall time: 0.1 seconds\nOutput:\n<html></html>"
}))
.is_err());
assert!(validate_write_file_arguments(&json!({
"path": "notes.txt",
"content": "说明:Exit code 只是一段普通文本"
}))
.is_ok());
assert!(validate_write_file_arguments(&json!({
"path": ".agent/manifest.json",
"content": "{}"