修复新建项目锁误触发 Windows UAC
Project CI / Repository checks (pull_request) Failing after 15s
Project CI / Backend tests (pull_request) Failing after 16s
Project CI / Frontend tests (pull_request) Successful in 2m39s
Project CI / Native shell tests (pull_request) Successful in 17m50s

新建 sidecar 改为本进程收紧 DACL,不再因继承 ACE 自动提权。
项目锁先写入并释放独占句柄后再 harden,回读内容校验,不再对这把新锁走 prepare_for_read。
提权 ArgumentList 改为一条按 Windows 规则加引号的字符串,避免含空格路径被拆开。
补充含空格项目根取锁与 quoted ArgumentList 定向测试。
同步 ACL 提权边界、决策记录和排障记录。
This commit is contained in:
2026-09-05 10:53:15 +00:00
parent 1019403f40
commit 4127686e18
5 changed files with 125 additions and 31 deletions
@@ -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::<Vec<_>>()
.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() {
@@ -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<PathBuf, String> {
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(),
@@ -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。前者是模型行为指令,后者是运行时接收边界,数值有意不同,不是缺陷或配置不一致。
@@ -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/时间戳再写同一版本号,命中“已存在且内容不同”。
@@ -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 自动 UACUAC 只修复允许范围内、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。