Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 24 additions & 0 deletions .agents/docs/2026-07-24-issue273-containment-plan.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
# 批次三:#273 沙盒围栏 + CI wine 缓存 — Implementation Plan

> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.

**Goal:** 修复 #273(patchelf_walk 经符号链接逃逸沙盒、损坏真实 `~/.xlings` payload)+ cross-build CI 的 wine 安装缓存;单 PR → CI 全绿 → bypass squash 合入 → 与批次二一起作为 0.0.104 release → xlings 生态验证。

**Architecture:**(实现中依整体评估修正为非启发式设计)信任根 = `cfg.registryDir` 这一**系统一等事实**,入口 `containment_root()` canonical 化一次后作为显式参数穿透到所有改写者(`patchelf_walk`/`fixup_gcc_specs`/`fixup_clang_cfg`),绝不从 payload 路径反推(先 canonical 化 payload 会把恶意符号链接解析掉,围栏沦为恒真式)。评估中发现入口层其实**已有** ownership guard 与内容指纹 marker(#109 起),本批次修复其三处残余缺口:①guard 复用 error_code(第二次成功清掉第一次失败)、②裸字符串前缀比较无组件边界(`registry-evil` 能穿过)、③binutils 兄弟目录的 walk 完全在 guard 之外。判定谓词独立导出并单测覆盖;`13_toolchain_pin.sh` 保留 symlink 种子作为真实环境金丝雀。

## Global Constraints

- 既有 e2e(含 152–160)与单测全绿;walker 的「补丁副本+原子 rename」行为不变。
- guard 覆盖三个写入者:`patchelf_walk`、`fixup_gcc_specs`、`fixup_clang_cfg`。
- 信任根解析失败时**fail-closed**(空根 → 一切视为逃逸,拒绝改写)。
- 跳过逃逸文件时 verbose 日志留痕。
- PR body 带 `Fixes #273`;合入后 issue 自动关闭。

## Tasks

- [ ] **T1 围栏谓词 + 三写入者接入**:post_install.cppm 导出 `containment_root(dir)`(拼写路径取 registry 父 + weakly_canonical)与 `escapes_containment(file, root)`(weakly_canonical 后前缀比较);patchelf_walk 入口算根、每候选 ELF 判定;fixup_gcc_specs/fixup_clang_cfg 写文件前同判。
- [ ] **T2 单测**:tests/unit/test_post_install_containment.cpp——真实临时目录 + `std::filesystem::create_directory_symlink` 复刻 issue 逃逸拓扑,断言:直连文件不逃逸、经 symlink 目录前缀的文件逃逸、无 registry 组件 → 无围栏、相对/`..` 路径归一。
- [ ] **T3 13_toolchain_pin.sh 注释更新**:说明 symlink 种子曾是 #273 事故向量、现受围栏保护并有意保留为金丝雀;引用 issue。
- [ ] **T4 CI wine deb 缓存**(已在工作树):首跑下载 .deb 闭包入 cache,命中跑 dpkg -i,apt-get -f 兜底。
- [ ] **T5 PR + CI + 合入**:单 PR(Fixes #273),15 检查全绿后 `gh pr merge --squash --admin`。
- [ ] **T6 release 0.0.104 + 生态验证**:按 release.yml 流程出版本(批次二已定版 0.0.104,本批次并入);xlings 安装链路/d2mcpp/d2x 以 release 产物复验;结果回记。
22 changes: 20 additions & 2 deletions .github/workflows/cross-build-test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -205,11 +205,29 @@ jobs:
restore-keys: |
xlings-${{ runner.os }}-v2-

# wine 的包集固定不变 —— 缓存整个 .deb 依赖闭包,命中时跳过 apt update
# 与下载(每轮省 ~1-2min)。镜像月度更新可能改变依赖缺口,dpkg -i 失败时
# 由 apt-get -f 兜底并重新回填缓存。
- name: Cache wine debs
uses: actions/cache@v4
with:
path: ~/wine-debs
key: wine-debs-${{ runner.os }}-ubuntu24.04-v1

- name: Install wine
run: |
sudo dpkg --add-architecture i386 || true
sudo apt-get update -qq
sudo apt-get install -y wine64 wine || sudo apt-get install -y wine
if ls ~/wine-debs/*.deb >/dev/null 2>&1; then
sudo dpkg -i ~/wine-debs/*.deb 2>/dev/null \
|| { sudo apt-get update -qq; sudo apt-get install -f -y; }
else
sudo apt-get update -qq
sudo apt-get install -y --download-only wine64 wine \
|| sudo apt-get install -y --download-only wine
mkdir -p ~/wine-debs
cp /var/cache/apt/archives/*.deb ~/wine-debs/ 2>/dev/null || true
sudo apt-get install -y wine64 wine || sudo apt-get install -y wine
fi
wine --version

- name: Bootstrap mcpp via xlings
Expand Down
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@

### 修复

- **#273 沙盒围栏**:post_install 的所有改写者(`patchelf_walk`/`fixup_gcc_specs`/`fixup_clang_cfg`)以显式信任根(`cfg.registryDir` canonical 化一次后穿透)逐文件设防——物理位置(符号链接解析后)在根外的一律拒绝改写并 fail-closed。修复既有入口 guard 的三处残余缺口:error_code 复用导致的错误掩蔽、裸字符串前缀无组件边界(`registry-evil` 可穿过)、binutils 兄弟目录 walk 不在 guard 覆盖内。`13_toolchain_pin.sh` 的 symlink 种子保留为金丝雀。单测 test_post_install_containment 覆盖事故拓扑。
- **嵌套 mcpp 的 `LD_LIBRARY_PATH` 段错误**:外层 `mcpp run` 为其子进程指向私有 glibc payload 的 loader 路径,子进程再 spawn mcpp 时(如课程 Provider 驱动 `mcpp test`),毒化值继续流入内层 mcpp 的工具子进程——sandbox ninja/gcc 加载错配 libc 后在动态链接器里段错误(残片签名 `__vdso_time`)。现在 `merged_environ` 从继承的 `LD_LIBRARY_PATH`/`DYLD_LIBRARY_PATH` 里**只剥离** `xim-x-glibc` payload 条目:用户自己的条目保留,per-child 显式 override 一如既往优先。下游(d2x `platform.cppm`、d2mcpp `runner.cppm`)的 `unsetenv` workaround 可随本版删除。

### 备注
Expand All @@ -33,6 +34,7 @@
- 首个下游消费者已完成端到端接入:d2mcpp「练习即测试」迁移(104 练习 zh/en 52/52 全绿)+ d2x checker 引导链路,全程使用本分支 musl 静态二进制。
- docs 措辞修正:"(gtest style)" → 框架无关表述;记录合成测试名含 `/` 的命名豁免(zh+en)。
- `BuildOptions::ninjaTargets`(构建计划子集)为支撑性通用接口,空 = 原行为。
- cross-build CI 的 wine 安装改为缓存 .deb 依赖闭包(首跑回填,命中免 apt update/下载)。
- musl 静态构建(`--target x86_64-linux-musl`)已验证:五个新 e2e 全绿;静态 mcpp 本体对 loader 毒化免疫,与上述修复共同覆盖嵌套链路的两端。

## [0.0.103] — 2026-07-22
Expand Down
105 changes: 86 additions & 19 deletions src/toolchain/post_install.cppm
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,43 @@ import mcpp.xlings;

namespace mcpp::toolchain {

// ── #273 sandbox containment ─────────────────────────────────────────────
// patchelf_walk once escaped its sandbox through a symlinked payload
// directory (an e2e sandbox seeded `registry/data/xpkgs/xim-x-gcc` from the
// user's real ~/.xlings via `ln -s`) and rewrote PT_INTERP/RUNPATH of the
// REAL installation against loader paths inside a soon-deleted mktemp dir.
//
// Containment rule: every rewrite in this module is fenced by ONE explicit
// trust root — the owning sandbox's registry (`cfg.registryDir`), resolved
// once at the entry point via `containment_root` and threaded down as a
// parameter. The root is a first-class fact of the system; it is never
// re-derived from the payload path (canonicalizing the payload first would
// resolve the malicious symlink and collapse the fence into a tautology).
export std::filesystem::path
containment_root(const std::filesystem::path& registryDir) {
std::error_code ec;
auto canon = std::filesystem::weakly_canonical(registryDir, ec);
if (ec) return {}; // empty root → escapes_containment fails closed
return canon;
}

// True when `file`'s physical (symlink-resolved) location falls outside
// `rootCanon`. Fails CLOSED: an empty root or an unresolvable path counts
// as escaping — never patch what cannot be proven contained. Comparison is
// per path component (a `registry-evil` sibling sharing the string prefix
// is outside).
export bool escapes_containment(const std::filesystem::path& file,
const std::filesystem::path& rootCanon) {
if (rootCanon.empty()) return true;
std::error_code ec;
auto real = std::filesystem::weakly_canonical(file, ec);
if (ec) return true;
auto r = rootCanon.generic_string();
auto f = real.generic_string();
if (f == r) return false;
return !(f.size() > r.size() && f.starts_with(r) && f[r.size()] == '/');
}

// Run patchelf on every dynamic ELF in `dir` (recursively):
// - Set PT_INTERP to `loader` (the sandbox-local glibc loader).
// - Set RUNPATH to `rpath` (colon-separated list of sandbox lib dirs).
Expand All @@ -36,7 +73,8 @@ namespace mcpp::toolchain {
export void patchelf_walk(const std::filesystem::path& dir,
const std::filesystem::path& loader,
const std::string& rpath,
const std::filesystem::path& patchelfBin)
const std::filesystem::path& patchelfBin,
const std::filesystem::path& fenceRoot)
{
if (!std::filesystem::exists(dir) || !std::filesystem::exists(patchelfBin))
return;
Expand All @@ -54,6 +92,14 @@ export void patchelf_walk(const std::filesystem::path& dir,
if (!is || m[0] != 0x7f || m[1] != 'E' || m[2] != 'L' || m[3] != 'F')
continue;
is.close();
// #273 fence: never rewrite files that physically live outside the
// sandbox (payload reached through a symlink → foreign installation).
if (escapes_containment(path, fenceRoot)) {
mcpp::log::verbose("toolchain", std::format(
"patchelf_walk: skip (outside sandbox, #273 fence): {}",
path.string()));
continue;
}
// Probe PT_INTERP — skip static binaries (no interp).
auto probe = std::format("{} --print-interpreter {} 2>/dev/null",
mcpp::platform::shell::quote(patchelfBin.string()),
Expand Down Expand Up @@ -152,8 +198,16 @@ export std::string detect_baked_loader(const std::string& specsContent) {

void fixup_gcc_specs(const std::filesystem::path& gccPkgRoot,
const std::filesystem::path& glibcLibDir,
const std::filesystem::path& gccLibDir)
const std::filesystem::path& gccLibDir,
const std::filesystem::path& fenceRoot)
{
// #273 fence: a payload reached through a symlink is a foreign
// installation — its specs file must not be rewritten either.
if (escapes_containment(gccPkgRoot, fenceRoot)) {
mcpp::log::verbose("toolchain",
"fixup_gcc_specs: skip (payload outside sandbox, #273 fence)");
return;
}
std::filesystem::path specsParent;
std::error_code ec;
for (auto it = std::filesystem::directory_iterator(gccPkgRoot / "lib" / "gcc", ec);
Expand Down Expand Up @@ -215,7 +269,14 @@ void fixup_gcc_specs(const std::filesystem::path& gccPkgRoot,
// C++ only: -nostdinc++ -stdlib=libc++ + payload libc++ headers/libs
// On macOS the C library comes from the SDK: --sysroot=<sdk> + libc++ headers.
export void fixup_clang_cfg(const std::filesystem::path& payloadRoot,
const std::filesystem::path& glibcLibDir) {
const std::filesystem::path& glibcLibDir,
const std::filesystem::path& fenceRoot) {
// #273 fence — same rule as fixup_gcc_specs above.
if (escapes_containment(payloadRoot, fenceRoot)) {
mcpp::log::verbose("toolchain",
"fixup_clang_cfg: skip (payload outside sandbox, #273 fence)");
return;
}
auto binDir = payloadRoot / "bin";
if (!std::filesystem::exists(binDir)) return;

Expand Down Expand Up @@ -344,15 +405,20 @@ void gcc_post_install_fixup(const mcpp::config::GlobalConfig& cfg,

mcpp::log::verbose("toolchain", std::format(
"gcc fixup: patchelf_walk rpath='{}'", rpath));
// Single trust root for every walk below — including the binutils
// SIBLING payloads, which the entry-point ownership guard does not
// cover (it only vets the gcc payload itself).
auto fence = containment_root(cfg.registryDir);
auto binutilsRoot = mcpp::xlings::paths::xim_tool_root(xlEnv, "binutils");
if (std::filesystem::exists(binutilsRoot)) {
for (auto& v : std::filesystem::directory_iterator(binutilsRoot))
patchelf_walk(v.path(), loader, rpath, patchelfBin);
patchelf_walk(v.path(), loader, rpath, patchelfBin, fence);
}
patchelf_walk(payloadRoot, loader, rpath, patchelfBin);
patchelf_walk(payloadRoot, loader, rpath, patchelfBin, fence);

mcpp::log::verbose("toolchain", "gcc fixup: fixup_gcc_specs");
fixup_gcc_specs(payloadRoot, glibcLibDir, gccLibDir);
fixup_gcc_specs(payloadRoot, glibcLibDir, gccLibDir,
containment_root(cfg.registryDir));
} else {
mcpp::ui::warning(
"could not locate sandbox glibc/gcc/patchelf paths; "
Expand Down Expand Up @@ -385,10 +451,11 @@ void llvm_post_install_fixup(const mcpp::config::GlobalConfig& cfg,
rpath += llvmLib.string() + ":" + glibcLibDir.string();
mcpp::log::verbose("toolchain", std::format(
"llvm fixup: patchelf_walk lib/ rpath='{}'", rpath));
patchelf_walk(llvmLib, loader, rpath, patchelfBin);
patchelf_walk(llvmLib, loader, rpath, patchelfBin,
containment_root(cfg.registryDir));
}
mcpp::log::verbose("toolchain", "llvm fixup: fixup_clang_cfg");
fixup_clang_cfg(payloadRoot, glibcLibDir);
fixup_clang_cfg(payloadRoot, glibcLibDir, containment_root(cfg.registryDir));
}

// ── the single fixup pipeline entry ──────────────────────────────────────
Expand Down Expand Up @@ -416,17 +483,17 @@ export void ensure_post_install_fixup(const mcpp::config::GlobalConfig& cfg,
// are not ours to patch — their owner already ran the fixup, and patching
// through the symlink would rewrite the canonical files against OUR
// (possibly ephemeral) paths, bricking the owner's toolchain.
{
std::error_code ec;
auto canonicalRoot = std::filesystem::weakly_canonical(payloadRoot, ec);
auto homeRegistry = std::filesystem::weakly_canonical(cfg.registryDir, ec);
if (!ec && !canonicalRoot.string().starts_with(homeRegistry.string())) {
mcpp::log::verbose("toolchain", std::format(
"skip {} fixup: payload '{}' resolves outside this home ('{}') — "
"inherited payload, owner is responsible for its fixup",
kind, payloadRoot.string(), canonicalRoot.string()));
return;
}
// Rewritten on the shared containment predicate (#273): the old inline
// check reused one error_code across both canonicalizations (the second
// success cleared the first failure) and compared raw strings without a
// component boundary (a `registry-evil` sibling passed). The predicate
// fails closed on any resolution error.
if (escapes_containment(payloadRoot, containment_root(cfg.registryDir))) {
mcpp::log::verbose("toolchain", std::format(
"skip {} fixup: payload '{}' resolves outside this home — "
"inherited payload, owner is responsible for its fixup",
kind, payloadRoot.string()));
return;
}

auto xlEnv = mcpp::config::make_xlings_env(cfg);
Expand Down
6 changes: 6 additions & 0 deletions tests/e2e/13_toolchain_pin.sh
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,12 @@ trap "rm -rf $TMP" EXIT

SANDBOX_HOME="$TMP/mcpp-home"
mkdir -p "$SANDBOX_HOME/registry/data/xpkgs"
# #273: this symlink seed was once the incident vector — post-install fixup
# walked through it and rewrote the REAL ~/.xlings gcc against the throwaway
# sandbox's loader paths. Every rewrite is now fenced by the containment
# predicate (physical location must resolve inside cfg.registryDir), so the
# seed is deliberately KEPT: it doubles as a live canary — if the fence ever
# regresses, the machine's gcc breaks loudly and immediately.
if [[ -d "$HOME/.xlings/data/xpkgs/xim-x-gcc/16.1.0" ]]; then
ln -s "$HOME/.xlings/data/xpkgs/xim-x-gcc" \
"$SANDBOX_HOME/registry/data/xpkgs/xim-x-gcc"
Expand Down
Loading
Loading