diff --git a/apps/ai-game-creator-shell/src-tauri/src/commands.rs b/apps/ai-game-creator-shell/src-tauri/src/commands.rs index a140eb6d9..9d07e6ed3 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/commands.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/commands.rs @@ -935,12 +935,21 @@ pub(crate) fn get_local_game_manifest( read_manifest_for_project_with_godot_root_calibration(root) } +/// 读取资源画布的持久化布局(`.agent/workbench/resource-layouts/*.json`)。 +/// +/// 权限门与紧邻的 [`read_local_project_resource_graph`] 同口径:同一块资源画布、同一条渲染读路径, +/// 用的都是 `asset.list` 的 auto 口径。`asset.list` 在 `GAME_CREATION_APP_COMMANDS` 里是 +/// `GameCreationAppPermission::Auto`,auto 口径默认就放行;只有项目策略显式把 `asset.list` 写进 +/// `denyCommands` 或 `confirmCommands` 才拒绝。这里必须用 auto 而不是普通读门,是因为这条读路径 +/// 由画布装载直接触发、没有可插入的确认交互——要求确认等同于拒绝,而普通读门会静默放行。 #[tauri::command] pub(crate) fn read_local_project_resource_canvas_layout( project_path: String, mode: ProjectResourceCanvasLayoutMode, ) -> Result { - read_project_resource_canvas_layout_at(Path::new(project_path.trim()), mode) + let root = Path::new(project_path.trim()); + enforce_project_auto_permission_policy(root, "asset.list")?; + read_project_resource_canvas_layout_at(root, mode) } #[tauri::command] @@ -954,6 +963,15 @@ pub(crate) fn read_local_project_resource_graph( read_project_resource_graph_at(&root, expected_project_id.trim(), resources) } +/// 保存资源画布的持久化布局。 +/// +/// 权限位取 `asset.register`,依据是同批「改动项目内资源相关持久化数据」的既有用法:本文件里 +/// [`update_local_project_resource_classification`]、[`delete_local_project_asset`]、 +/// [`rename_local_project_asset`] 全部用 `enforce_project_permission_policy(root, "asset.register")`, +/// 被改写的分类写入本身也用 `acquire_project_write_lock(root, "asset.register")`(见 +/// `project/manifest.rs` 的 `update_manifest_asset_classification_at`)。这里刻意用普通写门而不是 +/// auto 口径:`asset.register` 默认为 `Confirm`,auto 口径会把它当成「必须确认却无人确认」而拒绝, +/// 直接打死默认路径;拖动排版本来就是可以在 UI 里弹确认的用户动作。 #[tauri::command] pub(crate) fn update_local_project_resource_canvas_layout( project_path: String, @@ -962,8 +980,10 @@ pub(crate) fn update_local_project_resource_canvas_layout( expected_revision: u64, positions: Vec, ) -> Result { + let root = Path::new(project_path.trim()); + enforce_project_permission_policy(root, "asset.register")?; update_project_resource_canvas_layout_at( - Path::new(project_path.trim()), + root, mode, &expected_project_id, expected_revision, diff --git a/apps/ai-game-creator-shell/src-tauri/src/tests/project.rs b/apps/ai-game-creator-shell/src-tauri/src/tests/project.rs index 3c916ad80..0a65be672 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/tests/project.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/tests/project.rs @@ -2856,6 +2856,136 @@ fn local_game_manifest_reads_respect_declared_command_policy() { fs::remove_dir_all(root).ok(); } +fn resource_canvas_layout_position(resource_id: &str) -> ProjectResourceCanvasPosition { + ProjectResourceCanvasPosition { + resource_id: resource_id.to_string(), + section: shared_contracts::game_creation_app::ProjectResourceCanvasSection::Scene, + x: 32, + y: 48, + manually_placed: true, + } +} + +/// 资源画布布局的读写命令必须和紧邻的 `read_local_project_resource_graph` 一样过项目权限策略: +/// 读用 `asset.list` 的 auto 口径,写用 `asset.register` 的普通写门(与 +/// `update_local_project_resource_classification` 同口径)。assertions 同时钉住两条口径的区别, +/// 以及「被拒绝的写不产生任何落盘副作用」。 +#[test] +fn local_project_resource_canvas_layout_commands_respect_declared_command_policy() { + let root = unique_project_path(); + init_local_game_project_at(&root, "project-1", "像素动作原型").expect("project init"); + let project_path = root.to_string_lossy().into_owned(); + let layout_path = root.join(".agent/workbench/resource-layouts/type.json"); + + // 默认策略:`asset.list` 是 auto、`asset.register` 是 confirm,读与写都必须照旧放行。 + let layout = read_local_project_resource_canvas_layout( + project_path.clone(), + ProjectResourceCanvasLayoutMode::Type, + ) + .expect("default policy must allow the layout read"); + assert_eq!(layout.revision, 0); + update_local_project_resource_canvas_layout( + project_path.clone(), + "project-1".to_string(), + ProjectResourceCanvasLayoutMode::Type, + 0, + vec![resource_canvas_layout_position("asset-hero")], + ) + .expect("default policy must allow the layout write"); + assert!(layout_path.is_file(), "默认策略下布局应已落盘"); + + write_project_permission_policy_at( + &root, + ProjectPermissionPolicy { + denied_commands: vec!["asset.list".to_string()], + confirm_commands: Vec::new(), + agent_policies: BTreeMap::new(), + }, + ) + .expect("write denied read policy"); + + let read_error = read_local_project_resource_canvas_layout( + project_path.clone(), + ProjectResourceCanvasLayoutMode::Type, + ) + .expect_err("denied asset.list must reject the layout read"); + assert!( + read_error.contains("项目权限策略拒绝执行:asset.list"), + "unexpected error: {read_error}" + ); + + // 读路径没有可插入的确认交互,所以 auto 口径下「要求确认」等同于拒绝。 + write_project_permission_policy_at( + &root, + ProjectPermissionPolicy { + denied_commands: Vec::new(), + confirm_commands: vec!["asset.list".to_string()], + agent_policies: BTreeMap::new(), + }, + ) + .expect("write confirm read policy"); + + let confirm_error = read_local_project_resource_canvas_layout( + project_path.clone(), + ProjectResourceCanvasLayoutMode::Type, + ) + .expect_err("confirm-required asset.list must reject the layout read"); + assert!( + confirm_error.contains("项目权限策略要求用户确认:asset.list"), + "unexpected error: {confirm_error}" + ); + + write_project_permission_policy_at( + &root, + ProjectPermissionPolicy { + denied_commands: vec!["asset.register".to_string()], + confirm_commands: Vec::new(), + agent_policies: BTreeMap::new(), + }, + ) + .expect("write denied write policy"); + + read_local_project_resource_canvas_layout( + project_path.clone(), + ProjectResourceCanvasLayoutMode::Type, + ) + .expect("only asset.register is denied, so the layout read must still succeed"); + let write_error = update_local_project_resource_canvas_layout( + project_path.clone(), + "project-1".to_string(), + ProjectResourceCanvasLayoutMode::Type, + 1, + vec![resource_canvas_layout_position("asset-hero")], + ) + .expect_err("denied asset.register must reject the layout write"); + assert!( + write_error.contains("项目权限策略拒绝执行:asset.register"), + "unexpected error: {write_error}" + ); + let persisted: ProjectResourceCanvasLayout = + serde_json::from_str(&fs::read_to_string(&layout_path).expect("read persisted layout")) + .expect("parse persisted layout"); + assert_eq!( + persisted.revision, 1, + "被权限门拒绝的写入不得留下任何落盘副作用" + ); + + // 恢复默认策略后写入照旧成功:新增的门只在策略显式拒绝时生效。 + write_project_permission_policy_at(&root, ProjectPermissionPolicy::default()) + .expect("reset policy"); + let updated = update_local_project_resource_canvas_layout( + project_path, + "project-1".to_string(), + ProjectResourceCanvasLayoutMode::Type, + 1, + vec![resource_canvas_layout_position("asset-hero")], + ) + .expect("default policy must allow the layout write again"); + assert_eq!(updated.layout.revision, 2); + + fs::remove_dir_all(root).ok(); +} + #[test] fn local_project_file_commands_reject_unsafe_paths() { let root = unique_project_path();