Skip to content

fix(fence): reach and grant write roots as checked, never through a planted link - #573

Merged
futrime merged 1 commit into
mainfrom
fix/fence-write-roots
Nov 6, 2026
Merged

futrime merged 1 commit into
mainfrom
fix/fence-write-roots

Conversation

@futrime

@futrime futrime commented Nov 6, 2026

Copy link
Copy Markdown
Member

Summary

A fenced agent that may write the directory holding one of its write roots (its scratch under $TMPDIR at local=all, a state directory in a written workdir, a root nested in another root it writes) could swap that root for a symlink out of the wall in one turn. The next turn's fence granted the root by name: Landlock opened the path, and Seatbelt resolved it. Either way it granted writing wherever the link led, so the agent escaped the fence. Ported from the internal fork's fix (8b1ece81 and follow-ups), adapted to main's layout.

  • Fence.reach(path, make=) walks from / one name at a time (O_PATH/O_SEARCH, O_NOFOLLOW) and makes missing directories 0700 where asked. It follows a symlink only when the link sits in a directory that:

    • the fence does not write,
    • is not beneath a directory the fence writes,
    • is not /tmp, /var/tmp, $TMPDIR or the scratch.

    Directories are compared by device and inode, so a different spelling of the same path does not get past the check. Any other link refuses the turn with exit 126: <path>: it is a link where a fenced program could have made one, which no fence follows to write. A fence that writes / follows every link.

  • Fence.ready() reaches every write root and the scratch where the fence is put up:

    • hmz internal fence and the supervised anchor's _Walls grant Landlock the descriptors they opened, through the new Ruleset.held.
    • Seatbelt is granted the paths that were reached.
  • The agent's _ready, abroad.ready and the serving half's _scratch now make directories through the same walk.

  • Docs: agents.md › Enforcement, remote-execution.md, cli.md.

Left out of this port

  • Spec clause 6.4.18. The internal fork added it; it is not added here because specs/** is off limits. The refusal follows from 6.4.1 ("confine … to the grant").
  • Later hardening from the fork:
    • mount-table / bind-mount awareness on Linux;
    • nosymfollow;
    • fs.protected_symlinks;
    • automount lookups;
    • a target being told the agent machine's mutable roots.

Test plan

  • New tests/integration/test_anchor_fence_writes.py. The wrapper variant fails on main and passes with the fix, on both macOS (Seatbelt) and Linux (Landlock, in Docker). The supervisor variant was run on Linux in the same way and also fails on main, passes with the fix.
  • Unit tests for Fence.reach / Fence.ready and for the serving scratch.
  • uv run pytest passes on macOS. On Linux in Docker (without extras), the only failures are the dsh/agy tests that fail on main in the same environment.
  • uv run --all-extras pre-commit run --all-files passes.

…lanted link

A fenced agent that may write the directory holding one of its write
roots (its scratch under $TMPDIR at local=all, a state directory in a
written workdir, a root nested in another root it writes) could replace
that root with a symlink out of the wall in one turn. The next turn's
fence granted the root by name -- Landlock opening it, Seatbelt resolving
it -- so it granted writing wherever the link led.

Fence.reach now walks a written path from / one name at a time, making
what is missing 0700 where asked, and follows a symbolic link only in a
directory the fence does not write, that is not beneath one it writes,
and that is not /tmp, /var/tmp, $TMPDIR or the scratch, told by device
and inode. A link anywhere else refuses the turn. A fence that writes /
follows every link. Fence.ready reaches every write root and the
scratch where the fence is put up: hmz internal fence and the supervised
anchor's walls grant Landlock the descriptors opened, and Seatbelt the
paths reached. The agent's and the target's state directories and the
serving half's scratch are made through the same walk.

Tests: an integration regression of the escape through the wrapper and
the supervisor, failing on main (Seatbelt and Landlock), and unit tests
of the link rule, of making the roots, and of the serving scratch.
@futrime
futrime added this pull request to the merge queue Nov 6, 2026
Merged via the queue into main with commit 981bef5 Nov 6, 2026
15 checks passed
@futrime
futrime deleted the fix/fence-write-roots branch November 6, 2026 03:10
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant