# ADR-0004: 远端路径中波浪号（~）的解析模型

**状态**: 已采纳
**日期**: 2026-04-27
**参与者**: roger, Claude

## 背景

`PlatformProfile`（[ADR-0003](0003-openclaw-comprehensive-evaluation.md) / spec §6.3）把 OpenClaw 在 Linux/macOS 上的逻辑路径定义为字面量含 `~` 的字符串：

- `LINUX.paths["openclaw_home"] = "~/.openclaw/"`
- `MACOS.paths["openclaw_home"] = "~/Library/Application Support/openclaw/"`
- `ssh_policy.yaml` 的 `allowed_paths` 同样使用 `~/.openclaw/` 等字面 `~/` 前缀。

Stage E 第一个把路径作为 argv 传到远端 shell 的 check（`filesystem`，commit `2d10af0`）暴露了这个模型的两个并存的语义盲点：

**问题 1（生产语义沉默失效）**：`command_policy.py:95` 用 `shlex.quote(t)` 渲染每个 argv token。`shlex.quote('~/.openclaw/')` 返回 `"'~/.openclaw/'"`（单引号包裹）。远端 shell 因为单引号不展开 `~`；`find` / `stat` 自身也不解析 `~`。结果：

```
argv = ["find", "~/.openclaw/", "-maxdepth", "4", "-type", "f", "-perm", "/o+w"]
rendered = "find '~/.openclaw/' -maxdepth 4 -type f -perm /o+w"
remote stderr = "find: '~/.openclaw/': No such file or directory"
remote exit_code = 1
```

而 `find` 在"无匹配"也返回 1，当前 check 把两者折叠为"clean tree"，沉默返回 0 finding。**FilesystemCheck（以及任何把含 `~` 的路径作为 argv 的 check）在生产部署中永远无效。**

**问题 2（policy 验证假设 evaluator 与远端同 home）**：`path_matcher.py:30` 的 `_resolve(p)` 调用 `os.path.expanduser(p)`，把 `~/.openclaw/` 展开成 *evaluator 当前进程* 的 home（如 `/Users/roger/.openclaw/`）。当 `find` 的 stdout 返回远端绝对路径 `/home/ubuntu/.openclaw/notes.txt` 给后续 `stat` argv 时，prefix 匹配会因 home 不同而拒绝合法路径。即使问题 1 修了（让 `~` 在远端展开），问题 2 也会让所有"找文件 → stat 它"流程失败。

CI 测试是绿的，因为 `_ScriptedExecutor` 拿 argv 字面比较，不经过 policy 渲染或远端 shell；`PathMatcher` 在测试机和远端假设的 home 在测试中也不会冲突。

## 决策

**在 server-audit orchestrator 启动后、检测平台后、跑任何 check 前，解析一次远端 SSH 用户的绝对 home，把所有 `~/` 字面量物化为绝对路径。**

具体契约：

1. 新增 `audit/remote_home.py:resolve_remote_home(user: str, profile: PlatformProfile) -> str`，根据 SSH 用户名和平台返回远端 home 的绝对路径。v0.2.0 用硬约定：
   - `linux` profile：`root` → `/root`；其他 → `/home/<user>`
   - `macos` profile：`root` → `/var/root`；其他 → `/Users/<user>`
   - 末尾不带 `/`（调用方 join 时再加）。
2. 新增 `audit/platform_profile.py:materialize_paths(profile: PlatformProfile, remote_home: str) -> PlatformProfile`，返回新 profile，其中 `paths` 把所有 `~/` 前缀替换为 `<remote_home>/`。`services` 与 `listening_check` 不变（它们不含路径）。
3. `PathMatcher` 接收一个新的可选构造参数 `remote_home: str | None`：
   - 当为 `None`（默认）时保持现有行为（`os.path.expanduser` 用 evaluator 进程的 home），用于不需要远端语义的单元测试。
   - 当传入字符串时，`_resolve` 用它替换 `~/` 前缀，跳过 `os.path.expanduser`。
4. `CommandPolicy.from_yaml(path)` 增加可选 `remote_home: str | None` 参数，构造时传给 `PathMatcher`。
5. CLI 的 `server_audit` 命令在 `detect_platform` 之后立刻调用：
   ```python
   remote_home = resolve_remote_home(user, profile)
   profile = materialize_paths(profile, remote_home)
   policy = CommandPolicy.from_yaml(policy_path, remote_home=remote_home)
   ```
   `executor` 在此之后使用更新后的 policy 重新构造，或者 `SSHExecutor` 接受 policy 替换。
6. `server-audit-report.md` 元数据多加一行 `<!-- meta:remote_home value:/home/ubuntu -->`，让 Stage F 的 ingest 看到运行时实际解析结果。

## 理由

- **零 SSH 额外 round-trip**：`resolve_remote_home` 用本地确定性逻辑算路径，不依赖 `getent passwd` 或 `pwd`。policy 不需要新增 argv 条目。
- **零 policy 渲染层改动**：`shlex.quote` 仍然正确地引用所有 argv token；让物化后的绝对路径走通常的 quote 路径反而更安全。
- **测试可控**：`_ScriptedExecutor` 等单元测试通过显式构造一个含已解析路径的 `PlatformProfile` fixture 即可覆盖物化后的语义；不需要为每个测试都模拟"`~` 在远端展开"行为。
- **与 spec §6.3 兼容**：spec 把 profile.paths 定义为"逻辑路径"，没有规定字面量必须含 `~`。物化后的绝对路径仍然是逻辑路径的合法表示。
- **path_matcher 同步收敛**：通过 `remote_home` 注入消除了 evaluator/远端 home 不一致的隐含假设，问题 2 一并解决。

## 备选方案

**B. 让 policy 在 path token 不加引号**：改 `command_policy.py:95` 让 `kind: path` 的 token 不走 `shlex.quote`，依赖 `must_match_allowed_paths` + `metachar_guard` 已有的安全约束。

- **拒绝原因**：动 OE-AUD3-008 第四步（render 后 quote）是动安全核心。当前模型的关键不变量是"渲染后的字符串里每个 token 都被 quote，所以 shell 不可能因为 token 内容做出意外解释"。把 path 设为例外会让威胁模型出现一道"path 永远来自受信任的 PlatformProfile / find stdout"的隐式假设，下一次有人写新 check 时容易破。

**C. 仅止血（当前的 `(0, 1)` 容错收紧）**：`find` 失败时区分 stderr 含 `No such file` → `skipped`，否则当 `0 findings`。

- **拒绝原因**：只是把"沉默失效"变成"明确 skipped"。生产部署中 5+ 个含 `~` 路径的 check 仍然全部 skipped，等于 Stage E 的功能集没有交付。可以作为 A 的临时兜底（在 ADR 落地前先合一个补丁），但单独采用不发版。

**D. 在远端跑一次 `pwd` 拿 home**：新增 `["pwd"]` 到 policy.allowed_argv，orchestrator 启动时调用一次拿到远端实际 PWD（SSH 默认登录后 cwd 是 home）。

- **拒绝原因**：相比 A 多一次 SSH round-trip 而收益有限。只有 SSH 用户通过 `ChrootDirectory` 或自定义 ForceCommand 改了 home 时才比 A 准确，而 OpenClaw 部署默认不会这么配。等到出现真实场景再升级到 D 即可（vNext）。

**E. 让 PlatformProfile 在定义时就用绝对路径，不用 `~`**：直接把 `LINUX.paths["openclaw_home"] = "/home/ubuntu/.openclaw/"`。

- **拒绝原因**：把 SSH 用户名硬编码进类常量。无法支持非 `ubuntu` 的部署。本质上把"远端 home 在哪"这个问题从 orchestrator 推到了 PlatformProfile 工厂里，仍然要解决，只是位置不同。

## 后果

- **现有 5 个 check 中含 `~` 路径的会自动正确**：`filesystem`（已在 E.1.b 落地）、即将到来的 `credential_audit`（E.1.d）、`log_review`（E.3.b，macOS 路径 `~/Library/Logs/openclaw/`）。问题 1 + 2 一次性收敛。
- **`PathMatcher` 在没有 `remote_home` 参数时行为不变**，所有现有单元测试不需要改。
- **新增依赖：CLI 必须先 `detect_platform` 才能 `resolve_remote_home` → `materialize_paths`**。如果 `uname -s` 失败（policy 拒绝、SSH 断开），整个流程立即退出（exit_code=2），不会带着错的 home 继续跑。
- **v0.2.0 不支持非常规 home**：用户在 `/etc/passwd` 里把 home 改到 `/var/lib/openclaw` 这种部署需要等 vNext 的方案 D。CHANGELOG 要记一笔。
- **`server-audit-report.md` 多一行 `meta:remote_home`**，运维一眼可见 evaluator 解析出来的 home 是什么——出问题时立刻能区分"home 算错了"和"check 真的没匹配"。
- **新增 Stage E 子任务 E.0.f**（plan 已追加），落地这个 ADR。E.1.c 起的所有后续 check 都假设 `ctx.profile.paths` 已经是绝对路径。
- **现有 commit `2d10af0`（filesystem）保留**：实现按 plan 写正确、test 绿；E.0.f 落地后 filesystem 在生产里自动正确，无需 revert 或 amend。
- **Stage F 的 fingerprint dedup 仍然按绝对路径**：跨 platform/host 不会自动合并同一逻辑文件的 finding，由 Stage F 自行决定是否做 logical-path 二次归并。当下不影响 v0.2.0。
