③ 资源画布布局的读/写命令补项目权限门

- read_local_project_resource_canvas_layout 补 enforce_project_auto_permission_policy(root, "asset.list"),与紧邻的 read_local_project_resource_graph 完全同口径
- update_local_project_resource_canvas_layout 补 enforce_project_permission_policy(root, "asset.register"),与本文件 update_local_project_resource_classification / delete_local_project_asset / rename_local_project_asset 同口径
- 权限位依据:读路径是同一块资源画布的渲染读路径,邻居用 asset.list;写路径是「改动项目内资源相关持久化数据」,manifest 侧的分类写入本身就用 acquire_project_write_lock(root, "asset.register"),故沿用同一权限位,不新造权限名
- 读用 auto、写用普通门是刻意的:asset.list 是 Auto,auto 口径默认放行,只有策略显式 deny/confirm 才拒绝(读路径没有可插入的确认交互,要求确认等同拒绝);asset.register 默认是 Confirm,若写路径也用 auto 会直接打死默认路径
- tests/project.rs 补断言:默认策略下读与写都放行;deny asset.list 读失败、confirm asset.list 也读失败(钉住 auto 口径这一选择);deny asset.register 时读仍放行、写被拒且被拒的写不留任何落盘副作用(断言 sidecar revision 未变);恢复默认策略后写入照旧成功
This commit is contained in:
2026-09-11 21:18:41 +08:00
parent 6cc73b07ac
commit 4161888219
2 changed files with 152 additions and 2 deletions
@@ -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<ProjectResourceCanvasLayout, String> {
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<ProjectResourceCanvasPosition>,
) -> Result<UpdateProjectResourceCanvasLayoutResult, String> {
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,
@@ -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();