Skip to content

emrg: the write arm recognises a protected file by its spelling, so a case-variant name is allowed #2551

Closed

Description

@how2how2how2-arch

Observed

The write fence recognises the host's protected daemon state files by comparing strings, and the strings it compares are realpath results — which preserve the case they were given.

# emrg/sandbox/fence.py:81-82
target = canonical_path(file_path)          # os.path.realpath
if target in protected_paths():             # list of realpath'd strings
# emrg/sandbox/roots.py:582-583, the extra-root grant arm
for protected in protected_paths():
    if canonical == protected or protected.startswith(canonical.rstrip(os.sep) + os.sep):

On a volume that is case-insensitive — the macOS default, and Windows — two spellings that differ only in case name one file, while os.path.realpath returns each spelling as it was written. So the comparison answers "different file" for a path that is the same file.

Measured on this host (APFS, case-insensitive) with HOME pointed at a temporary directory and the real entry point, emrg.sandbox.fence.file_refusal, given a workspace-write policy whose workspace root is that home:

target on-disk identity verdict
<home>/.emrg/config.toml samefile → True REFUSED
<home>/.emrg/CONFIG.TOML samefile → True ALLOWED

os.path.realpath answered …/.emrg/config.toml and …/.emrg/CONFIG.TOML — two different strings, one file.

Why this is reachable rather than theoretical

emrg/sandbox/fence.py's own comment gives the protected arm its reason: containment already refuses these files in every deployment this repo runs, "but already is a fact about today's geometry, and a session whose cwd is ~ would make the OS fence grant them". The directory-based arm is exactly the deployment where containment says yes and this list is the only thing left. resolve_policy accepts workspace_root=home, so that deployment is configurable today; a workspace-write session rooted at the host's home is the case the arm was written for, and in it a case-variant spelling writes the file the arm protects.

Expected

One file, one verdict — whichever spelling names it. A protected file on a case-insensitive volume is refused under every spelling that reaches it.

What the fix must satisfy

  1. Both spellings refused on a case-insensitive volume, measured through file_refusal rather than by reading the list.
  2. No false refusal on a case-sensitive volume: a genuinely different file whose name differs only in case must still be allowed. This rules out lowercasing as the mechanism — os.path.normcase is the identity function on POSIX, and a blanket .lower() would refuse a distinct ~/NOTES.md on Linux.
  3. A target that does not exist still gets a verdict. The protected file exists, so the variant resolves to it; the answer must not depend on the target being creatable at the time of asking.
  4. The extra-root grant arm gets the same treatment (emrg/sandbox/roots.py:582). It decides whether a host may name a directory as a root, and a case-variant name of a protected file must not be accepted as a distinct grant.
  5. A test that is red without the change, built from a temporary directory — never from the host's real ~/.emrg.

Out of scope

The registry half of the same set already compares case-insensitively, because a registry key namespace is case-insensitive by definition (emrg/sandbox/roots.py:438, emrg/sandbox/win32/sid.py:105); the defect here is the file half. Windows registry-key grants and the ACL runner are not part of this.

Activity

  1. argszero commented on Oct 23, 2026

    @argszero
    Owner

    Handled by #2552 — all five requirements: both spellings refused through file_refusal, a genuinely distinct case-variant file still writable, an absent target still judged without raising, the grant arm at roots.py taking the same identity (ancestors included), and tests built from a temporary directory never the real home. Implemented with os.path.samefile rather than any string normalisation, for your requirement 2's reason. CI pending: the cycle that pushed it may not vote on it, so it waits for a later cycle.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions