Feat/design agent simple #305
@@ -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 自动 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。
|
||||
|
||||
Reference in New Issue
Block a user