Skip to content

Atomic writes resolve parent directories by name, so follow_symlinks=False does not stop a symlinked parent #593

Description

@pbean

platform_util._atomic_write — the shared body behind atomic_write_text and atomic_write_bytes
resolves the target's parent directories by name, at both the stage and the publish step:

target = path.resolve() if follow_symlinks else path
fd, tmp_name = tempfile.mkstemp(dir=str(target.parent), prefix=target.name + ".", suffix=".tmp")
...
atomic_replace(tmp, target)

follow_symlinks=False skips the resolve, so the final component is replaced rather than written
through — that part is sound, and its docstring argues it well. But mkstemp(dir=target.parent) and
the os.replace destination are still ordinary path lookups. If any directory above the file is a
symlink, both the temp and the published file land wherever that link points, and the no-follow buys
nothing.

Concretely: follow_symlinks=False at .bmad-loop/policy.toml stops a link planted at
policy.toml
. It does not stop a link planted at .bmad-loop/. mkdir(parents=True, exist_ok=True), which several of these callers run first, succeeds against a symlink-to-a-directory,
so a planted parent survives the setup step too.

Why this matters at these specific call sites

The reason follow_symlinks=False was chosen for them (#379, #590) is that they are machine-minted
files under a session-writable root — runsetup documents .bmad-loop/policy.toml as a path a
driven session can write. The escalation being closed is a sandboxed session getting the unsandboxed
orchestrator to write to a path of its choosing. A symlinked parent reopens exactly that escalation;
it just costs the attacker a directory swap instead of a file swap.

Affected: every follow_symlinks=False call site — .bmad-loop/decisions.json, the two per-run
sweep writes, .bmad-loop/policy.toml (three writers), story specs (three writers), and the park
records (three writers). The follow_symlinks=True sites have the same parent exposure, but there
the file itself is operator-curated and the threat model is different.

The machinery already exists, and has one consumer

platform_util already ships the correct primitives, added for tui/launch.py's control-window
record:

  • open_dir_confined(root, target) walks each component O_NOFOLLOW | O_DIRECTORY relative to the
    one above it and hands back a descriptor, not a verdict — deliberately, because a boolean
    "is this path confined?" is stale the instant it returns.
  • atomic_write_text_at(dir_fd, name, text) then never names a path again: O_CREAT|O_EXCL at
    0600 with an unguessable temp name, fsync before the replace, os.replace(..., src_dir_fd=, dst_dir_fd=).

tui/launch.py is currently the only consumer. So this is not "design a hardening story from
scratch" — it is "extend an established one to the rest of the no-follow cohort."

The blocker, and why this is not a small change

DIR_FD_ANCHORED_WRITES is hasattr(os, "O_DIRECTORY")POSIX only. atomic_write_text_at's
own docstring says a caller reaching it "is on POSIX by construction," and open_dir_confined's says
callers "need a fallback for win32, which has no *at() family to anchor against." Windows has no
equivalent, and this repo treats win32 parity as load-bearing (two Windows CI legs).

So adopting this cannot be a swap inside _atomic_write; it needs a decision about what win32 does:

  1. Per-caller adoption with a documented win32 fallback, as tui/launch.py does. Narrow, matches
    existing precedent, but repeats the branch at every site.
  2. A no-follow-anchored variant of the helper (atomic_write_bytes_at, plus a text sibling that
    takes bytes) that callers opt into, with _atomic_write unchanged for everyone else.
  3. Fold it into _atomic_write behind follow_symlinks=False — smallest call-site diff, largest
    blast radius, and it silently changes behaviour for ~12 existing sites plus win32.

Also unresolved: atomic_write_text_at is text-only and forces UTF-8 with newline="". Three of the
no-follow sites (policy.write_mux_backend, the two frontmatter writers) are byte-verbatim on
purpose
, to preserve a CRLF file's line endings, so they need a bytes-anchored variant that does
not exist yet.

Provenance and scope note

Raised by CodeRabbit on #590 (src/bmad_loop/verify.py:1015, consolidated onto verify.py:1412 and
engine.py:2674) and deferred there. #590 does not introduce this and strictly reduces the
surrounding exposure: the four sites it converted from bare write_text/write_bytes previously
followed a symlinked parent and a symlinked final component; they now follow only the parent. The
eight temp-and-replace sites resolved parents by name before and after. Fixing it means changing a
shared helper used well beyond the PR's brief, which AGENTS.md forbids widening into.

Severity

P2 rather than P1: it needs an actively hostile writer under the project root, and the orchestrator
and the driven session normally run as the same user, so the escalation is real only where the CLI
sandboxes session writes to the project directory. It is not P3 because where that sandbox does hold,
this is a write-anywhere escape, and it defeats a protection the codebase has already decided is
worth having.

Metadata

Metadata

Assignees

No one assigned

    Labels

    P2Real defect - workaround exists or blast radius is narrowarea:engineOrchestrator engine and run lifecyclebugSomething isn't workingneeds-designAwaiting a maintainer design decision before code

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions