diff --git a/apps/ai-game-creator-shell/src-tauri/src/config.rs b/apps/ai-game-creator-shell/src-tauri/src/config.rs index 80e1c2181..efec12d8b 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/config.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/config.rs @@ -1362,24 +1362,15 @@ pub(crate) fn harden_new_game_creator_private_path( path.display() )); } - // A newly-created object normally inherits the creator's security - // descriptor. AGC-owned roots may request the one-shot UAC repair; - // a user-selected path is still hardened strictly after creation so - // a race cannot turn an attacker-owned object into a credential file. - if game_creator_private_path_allows_auto_elevation(path) { - secure_windows_game_creator_path_for_current_user_with_auto_elevation( - path, - is_directory, - true, - )?; - } else { - secure_windows_game_creator_path_for_current_user_with_owner_policy( - path, - is_directory, - true, - true, - )?; - } + // This invocation created the object, so its owner is the current + // user. Tighten the inherited descriptor in-process; UAC repair is + // reserved for existing, externally-owned objects. + secure_windows_game_creator_path_for_current_user_with_owner_policy( + path, + is_directory, + true, + true, + )?; } #[cfg(unix)] { @@ -2533,6 +2524,34 @@ pub(crate) fn consume_windows_acl_repair_authorization( Ok(()) } +#[cfg(windows)] +fn windows_command_line_quote(value: &str) -> String { + format!("\"{}\"", value.replace('"', "\\\"")) +} + +#[cfg(windows)] +fn windows_acl_repair_argument_list( + path: &str, + target_user_sid: &str, + nonce: &str, + scope: WindowsAclRepairScope, +) -> String { + [ + "--repair-private-acl", + path, + "--target-user-sid", + target_user_sid, + "--authorization", + nonce, + "--scope", + scope.wire_name(), + ] + .into_iter() + .map(windows_command_line_quote) + .collect::>() + .join(" ") +} + /// Starts a one-shot elevated copy of the current executable. The elevated /// process performs only the allow-listed ACL repair command and exits with a /// truthful status; UAC cancellation is never treated as success. @@ -2557,12 +2576,15 @@ fn attempt_elevated_windows_acl_repair( let repair_path = windows_acl_repair_target(path, scope); let nonce = create_windows_acl_repair_authorization(&repair_path, target_user_sid, scope)?; let escaped_executable = executable.to_string_lossy().replace('\'', "''"); - let escaped_path = repair_path.to_string_lossy().replace('\'', "''"); - let escaped_target_user_sid = target_user_sid.replace('\'', "''"); - let escaped_nonce = nonce.replace('\'', "''"); + let arguments = windows_acl_repair_argument_list( + &repair_path.to_string_lossy(), + target_user_sid, + &nonce, + scope, + ) + .replace('\'', "''"); let script = format!( - "$ErrorActionPreference = 'Stop'; try {{ $p = Start-Process -Verb RunAs -Wait -PassThru -FilePath '{escaped_executable}' -ArgumentList @('--repair-private-acl','{escaped_path}','--target-user-sid','{escaped_target_user_sid}','--authorization','{escaped_nonce}','--scope','{}'); if ($null -eq $p) {{ exit 1223 }}; exit $p.ExitCode }} catch {{ exit 1223 }}", - scope.wire_name() + "$ErrorActionPreference = 'Stop'; try {{ $p = Start-Process -Verb RunAs -Wait -PassThru -FilePath '{escaped_executable}' -ArgumentList '{arguments}'; if ($null -eq $p) {{ exit 1223 }}; exit $p.ExitCode }} catch {{ exit 1223 }}" ); use std::os::windows::process::CommandExt; let status = std::process::Command::new("powershell.exe") @@ -4127,6 +4149,24 @@ mod private_path_elevation_policy_tests { assert!(windows_acl_error_may_need_elevation(detail)); } + #[cfg(windows)] + #[test] + fn acl_repair_argument_list_keeps_space_containing_path_quoted() { + let path = r"C:\Users\lingh\Documents\Genarrative GameAgent\gameagent-f84a5353\.agent\project.lock"; + let arguments = windows_acl_repair_argument_list( + path, + "S-1-5-21-1-2-3-1001", + "0123456789abcdef0123456789abcdef", + WindowsAclRepairScope::Managed, + ); + assert_eq!( + arguments, + format!( + "\"--repair-private-acl\" \"{path}\" \"--target-user-sid\" \"S-1-5-21-1-2-3-1001\" \"--authorization\" \"0123456789abcdef0123456789abcdef\" \"--scope\" \"managed\"" + ) + ); + } + #[cfg(windows)] #[test] fn custom_runtime_config_path_uses_explicit_user_selected_scope() { diff --git a/apps/ai-game-creator-shell/src-tauri/src/project/filesystem.rs b/apps/ai-game-creator-shell/src-tauri/src/project/filesystem.rs index 05b5dde7d..ca1a45b15 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/project/filesystem.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/project/filesystem.rs @@ -178,6 +178,28 @@ fn project_write_lock_treats_windows_target_races_as_contention() { } } +#[cfg(all(test, windows))] +#[test] +fn project_write_lock_hardens_space_containing_path_in_process() { + let parent = tempfile::tempdir().expect("create spaced lock parent"); + let root = parent + .path() + .join("Genarrative GameAgent") + .join("gameagent-space"); + fs::create_dir_all(&root).expect("create spaced project root"); + let lock = acquire_project_write_lock(&root, "planning.v2.approval") + .expect("acquire project lock under a space-containing path"); + let lock_path = root.join(".agent").join("project.lock"); + assert!(lock_path.is_file(), "project lock must exist while held"); + crate::secure_windows_game_creator_path_for_current_user(&lock_path, false, false) + .expect("new project lock must already satisfy the private DACL contract"); + drop(lock); + assert!( + !lock_path.exists(), + "project lock must be removed when the guard is dropped" + ); +} + fn resolve_project_write_lock_path(root: &Path) -> Result { let normalized = normalize_relative_path(PROJECT_WRITE_LOCK_PATH)?; let (parent_relative, file_name) = normalized @@ -222,17 +244,33 @@ pub(crate) fn acquire_project_write_lock( } match options.open(&path) { Ok(mut file) => { - if let Err(error) = harden_new_game_creator_private_path(&path, false, "项目写锁") - { - drop(file); - let _ = fs::remove_file(&path); - return Err(error); - } if let Err(error) = file.write_all(content.as_bytes()) { + drop(file); let _ = fs::remove_file(&path); return Err(format!("写入项目写锁失败:{}: {error}", path.display())); } - prepare_game_creator_private_path_for_read(&path, false, "项目写锁")?; + if let Err(error) = file.sync_all() { + drop(file); + let _ = fs::remove_file(&path); + return Err(format!("落盘项目写锁失败:{}: {error}", path.display())); + } + drop(file); + if let Err(error) = harden_new_game_creator_private_path(&path, false, "项目写锁") + { + let _ = fs::remove_file(&path); + return Err(error); + } + let actual = match fs::read_to_string(&path) { + Ok(actual) => actual, + Err(error) => { + let _ = fs::remove_file(&path); + return Err(format!("读取项目写锁失败:{}: {error}", path.display())); + } + }; + if actual != content { + let _ = fs::remove_file(&path); + return Err(format!("项目写锁内容校验失败:{}", path.display())); + } return Ok(ProjectWriteLock { path, content: content.clone(), diff --git a/docs/project-memory/shared-memory/decision-log.md b/docs/project-memory/shared-memory/decision-log.md index a5443bff4..9e11982bb 100644 --- a/docs/project-memory/shared-memory/decision-log.md +++ b/docs/project-memory/shared-memory/decision-log.md @@ -15,6 +15,14 @@ - 关联文档:相关 PRD、技术文档、提交或 Issue ``` +## 2026-09-05 本进程新建 Windows 私有对象不因继承 DACL 自动 UAC + +- 背景:#211 要求 sidecar 满足当前用户独占、禁止继承的 DACL。新建文件会先继承父目录 ACE,生产路径把这种短暂不合格送进 UAC;`project.lock` 还在独占句柄上 harden。含空格项目路径上提权 ArgumentList 被拆开,修复以 exit 1 失败。GDD 审批改意见因此弹权限,V1 锁创建不会。 +- 决策:`harden_new_game_creator_private_path` 只在本进程收紧 owner/DACL,失败则删除刚创建的对象,不 UAC 接管。项目锁先写再释放句柄再 harden,并用内容回读防换绑;UAC 仍只用于允许范围内的已有外人本对象。提权 helper 的 ArgumentList 改为一条按 Windows 规则加引号的字符串。 +- 影响范围:`config.rs` 的新建 harden 与提权命令行、`filesystem.rs` 的项目锁创建;不改变锁竞争、失效回收、Drop 删除,也不放宽 symlink / reparse / 外人本 fail-closed。 +- 验证方式:Windows 定向测试覆盖 `Genarrative GameAgent\gameagent-*` 取锁与私有 DACL,以及带空格路径的 quoted ArgumentList。 +- 关联文档:`docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md`、`docs/project-memory/shared-memory/pitfalls.md`。 + ## 2026-09-05 Planning V2 的 3 轮策略与 8 个问题门禁有意不对称 - 决策:模型提示最多提问 3 轮,并在达到 3 后要求出稿;Runtime `question_limit` 默认 8,对偏离模型策略的合法问题保留接收空间,达到 8 才拒绝新 question。前者是模型行为指令,后者是运行时接收边界,数值有意不同,不是缺陷或配置不一致。 diff --git a/docs/project-memory/shared-memory/pitfalls.md b/docs/project-memory/shared-memory/pitfalls.md index 6793a3707..d98a319e1 100644 --- a/docs/project-memory/shared-memory/pitfalls.md +++ b/docs/project-memory/shared-memory/pitfalls.md @@ -2,6 +2,14 @@ > 当前口径:本文件保留可复用的排障经验;历史条目的旧路由、旧版本和已删除文档仅作根因背景,不得据此恢复退役入口。当前命令、路由和 schema 以代码与 `docs/README.md` 为准。 +## 2026-09-05 新建项目锁不要把继承 DACL 当成 UAC 事件 + +- **现象**:策划 V2 在 GDD 审批提交修改意见时弹出权限窗口,目标是 `Documents\Genarrative GameAgent\gameagent-*\.agent\project.lock`,随后 `AGC ACL 提权修复未成功(exit code Some(1))`。 +- **原因**:#211 把新建 sidecar 纳入私有 DACL 门禁。父目录已是当前用户独占且禁止继承时,刚 `create_new` 的锁文件仍会短暂带继承 ACE;生产路径把这类 DACL 不合格送进 `--repair-private-acl`。独占句柄还会妨碍本进程 `SetNamedSecurityInfoW`。提权再用 `Start-Process -ArgumentList` 数组,含空格路径被拆开,helper 参数个数不对并以 1 退出。这不是 V2 审批协议或 Provider 权限请求。 +- **处理**:本进程新建对象只在进程内收紧 DACL,不因继承 ACE 自动 UAC。项目锁先写入并释放独占句柄,再 harden,回读内容校验后返回;不再对这把新锁走 `prepare_for_read`。UAC 仍留给允许范围内的外人本对象;提权命令行改为一条已加引号的 ArgumentList。 +- **排查顺序**:先看错误是否点名 `project.lock` 且含 `禁止继承` / `exit code Some(1)`;不要当成策划 V2 或 Provider 鉴权问题。含空格的 `Genarrative GameAgent` 项目根是复现条件,不是业务失败。 +- **验证**:Windows 定向覆盖含空格项目根取锁、新锁已满足私有 DACL、Drop 删除,以及提权参数把带空格路径保留为一个 quoted token。 + ## 2026-09-04 Planning V2 不可变 GDD 创建后不能当没提交 - **现象**:`gdd.vN.json` 已 create-only 落盘,但 index / Markdown / conversation / session 任一步失败后,session 停在 `provider_failed` 且 `current_artifact_version` 仍指向旧版本。重试会用新 UUID/时间戳再写同一版本号,命中“已存在且内容不同”。 diff --git a/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md b/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md index 8e886e8a4..ef41f6920 100644 --- a/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md +++ b/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md @@ -183,7 +183,7 @@ Supervisor 认领该回执后,由父 run 自己为每个原 delivery 逐一创 - Windows AppData 安全迁移:首次创建客户端 AppData 时必须以进程 `TokenUser` SID 显式设置 owner,并写入当前用户私有 DACL,不能把可能为 Administrators 的 `TokenOwner` 当作用户身份。发现历史目录 owner 不属于当前 `TokenUser` 时,不在原目录上放宽权限,而是拒绝 reparse point / junction / symlink 后,将旧目录原子重命名到同级唯一 `.owner-mismatch-backup-*` 备份,再新建并验证当前用户 owner 与私有 DACL;迁移或备份失败必须失败关闭,不覆盖旧配置。 - Windows 私有文件初始化:父目录已归当前 `TokenUser` 后,新建 `.agent/.manifest.json.lock`、`agent-runner.lock`、endpoint 临时文件、project-owner 诊断临时文件与 real-E2E 私有文件的 owner 仍可能采用 token 默认 owner `Administrators`。manifest 固定锁和 Runner 固定 stale lock 只有在 Windows 不共享独占句柄已取得、且句柄确认普通文件、非 reparse point、链接数为一时才允许初始化或修复为当前 `TokenUser`,随后必须再次复核句柄并按既有 owner/DACL 门禁验证;其它临时文件只允许在本进程 `create_new` 成功且仍持有同一独占句柄时初始化 `TokenUser` owner / DACL,再写入、原子安装并严格复核,初始化失败必须清理刚创建的文件。既有 durable endpoint / diagnostic 读取不得自动接管;活锁不得截断,只有 sharing / lock violation `32/33` 表示占用,access denied 等其它错误立即返回。父进程观察到 Runner 子进程退出后立即返回错误,不等待完整 30 秒 deadline。 -- Windows ACL 提权边界:自定义 `--config-dir` 的启动前置检查必须把 `managed / user-selected` scope 一并传入提权子进程,不能依赖父进程内存中的配置目录覆盖;native picker 返回的文件或项目目录在同一进程登记短时授权,后续导入 / 项目操作只对登记路径(目录可覆盖其后代)允许 `user-selected` 自动提权,直接伪造 IPC 绝对路径不得获得该能力。项目文件列表 / 索引递归逐项拒绝 symlink 与 Windows reparse point,并在 metadata / read 前先完成 ACL 准备。 +- Windows ACL 提权边界:自定义 `--config-dir` 的启动前置检查必须把 `managed / user-selected` scope 一并传入提权子进程,不能依赖父进程内存中的配置目录覆盖;native picker 返回的文件或项目目录在同一进程登记短时授权,后续导入 / 项目操作只对登记路径(目录可覆盖其后代)允许 `user-selected` 自动提权,直接伪造 IPC 绝对路径不得获得该能力。项目文件列表 / 索引递归逐项拒绝 symlink 与 Windows reparse point,并在 metadata / read 前先完成 ACL 准备。本进程刚创建的普通文件或目录只在当前进程收紧 owner / 私有 DACL,不因继承 ACE 自动 UAC;UAC 只修复允许范围内、owner 不属于当前用户的已有对象。提权 `Start-Process -ArgumentList` 必须是一条按 Windows 命令行规则加引号的字符串,不能把带空格路径拆成多个 argv。 - 启动恢复和续跑边界:本条取代上一条中“只有 accepted 才可恢复”的窄口径。若进程在 Supervisor 用户消息已持久、accepted 未持久之间崩溃,只读 preflight 可以把该 `preparing` 识别为可恢复,但不改写 task/conversation;真实 resume 持有 Agent 锁后必须先幂等补写 accepted,再提升为 `pending / queued`。用户消息或 accepted conversation 已落盘而辅助审计失败时,以 conversation 为公开真相继续入队,不留下“已接收但永不执行”的任务;根终态首次公开写入的瞬时失败必须在终态投影后用相同 message ID 重试。receipt / isolated-join 等带 parent 的 Supervisor continuation 不再另写 Session 终态,只保留单一后端公开事件;`runtime-task-*` 与 `runtime-public-status-*` 共享同 run 的不透明关联摘要,秒级时间戳下多个连续任务必须按实际 run 对应的 `user -> accepted -> terminal` 顺序交错展示。 - ready-task 启动活性:`background_task.queued`、`autonomous_ready_task.scheduled`、Runner heartbeat 或执行锁已移交都不等于 child 已启动。实际持有执行权的 Runner 必须在释放项目写锁后同步写入 child 的 running task、`turn.started` 与 started journal,再把已启动 state 和 per-Agent 执行锁交给已确认开始轮询的独立 execution worker;同步启动或 worker 接管失败时,要在仍持有执行锁期间依次把 child 和 manifest Graph 节点明确落为 failed,再释放锁并让 parent 收到调度错误。`autonomous_ready_task.scheduled` 只作诊断审计,其写入失败不能阻断 durable child 启动;external client 只 wake Runner,不在客户端抢占执行。Supervisor 进度卡通过 durable `startedAt`(旧 Run 从完整 task journal 恢复,最新 task-record fallback 保持 0)显示真实持续时间,并以父 Run 与当前关联专业 Agent 的最大事件时间计算运行态活跃度:运行超过 5 分钟无新事件时显示“运行中 · 疑似停滞”和静默时长;等待用户、等待确认、Provider retry、视觉资产、进程会话、pausing 与 paused 不误报。父 Run terminal 后,持续时间冻结在父 Run 自身最后活动,不随 child 晚到收口事件增长。消息时间统一校验为 JavaScript 可表示的 Date;越界值显示“时间未知”且不写无效 `datetime`。实时回复只显示 response stream 自己的 `updatedAt`,缺失时同样显示“时间未知”,不能借用其它 Runtime 活动时间或随前端时钟漂移。该提示只提供可观测性,不改变 Runtime/manifest 正式状态。 - ready-task 对账取消续跑:未知工具结果仍停在 `needs-reconciliation` 且禁止自动重放;人工核对后显式取消原 child,保留 cancel tombstone,旧 child 和旧父 Run 按真实终态收口。若随后创建同 Session、同 Supervisor source、同有效任务语义的 continuation,新完成合同只对同时具有历史 `failed / needs-reconciliation`、最终 `cancelled` 和 durable tombstone 的 ready-task,把当前 manifest 对应 failed 节点恢复为 pending,并由 scheduler 创建全新 child Run。manifest 的读取、failed 筛选、每任务一次的 child journal 索引、证据重验和写回必须位于同一项目写锁域;较新的无 child 根 Run 只有在 durable journal 精确表明为旧 failed Graph 在进入调度前即失败时才能跨过,scheduler 自身失败必须阻断借用更老 tombstone。普通失败、无 tombstone、不同 source/Session/任务语义或证据冲突均保持失败关闭;不得复活旧 pending action、补造 observation 或把取消任务标成 completed。