Skip to content

cathedral start is blocked on any system where the trusted python is a symlink #10

Description

@ai-hpc

Symptom

cathedral start returns blocked and starts nothing:

status  = blocked                     exit = 10 (NOT_READY)
code    = install.engine_missing
message = the active release changed after verification; nothing was started
detail  = the verified program python3 could not be opened:
          [Errno 40] Too many levels of symbolic links: '/usr/bin/python3'

The errno is misleading. There is no symlink loop — /usr/bin/python3 -> python3.12 is a single, ordinary link that resolves fine:

$ namei -l /usr/bin/python3
 lrwxrwxrwx root root python3 -> python3.12
 -rwxr-xr-x root root   python3.12

ELOOP is also what O_NOFOLLOW raises when the final component simply is a symlink. That is what is happening here.

Cause

cathedral_node/engines/installer.py:1989

fd = os.open(str(bind_program), os.O_RDONLY | os.O_NOFOLLOW)

bind_program is the trusted interpreter. On Debian/Ubuntu /usr/bin/python3 is a symlink to python3.12, and inside a virtualenv .venv/bin/python is a symlink too. So this refuses the default layout on essentially every Linux system.

The post-spawn re-check has the matching assumption at installer.py:2017:

current = os.stat(str(bind_program), follow_symlinks=False)
if (current.st_dev, current.st_ino) != (held.st_dev, held.st_ino):

held came from fstat on the opened file; current is an lstat of the path. Those two agree only when the path is not a symlink. So the inode-binding design assumes bind_program is a real regular file throughout — that assumption is simply never stated or enforced, and is false by default.

Why this went unnoticed

setup needs a signed release that is not published yet, so the launch path has had no external exercise. In the Gate 0 suite it surfaces as two failures that look like flaky process-lifecycle tests:

TestPublicStartLauncherCrash::test_a_crashed_public_start_blocks_public_prune_and_uninstall
TestDetachedDescendantThroughPublicCommands::test_a_detached_child_blocks_stop_deletion_and_a_second_start

Both fail via self.fail(f"...: {launcher.stderr.read()}") — but the launcher reports on stdout, so the assertion prints an empty string and the real cause is invisible. Worth fixing alongside: the diagnostic should read both streams.

Suggested fix, and the part that needs a decision

Resolving the symlink and keeping O_NOFOLLOW on the resolved path preserves the security property and fixes the default layout:

program = Path(os.path.realpath(bind_program))
fd = os.open(str(program), os.O_RDONLY | os.O_NOFOLLOW)
...
current = os.stat(str(program), follow_symlinks=False)

But the spawn must then use program, not bind_program. If the child is still executed via the symlink path, an attacker who re-points the link between resolution and exec gets a binary that was never hashed, and the post-spawn lstat of the resolved path would still pass. Resolve once, then open, hash, spawn and re-check all against that one resolved path.

Filing rather than patching because that is a change to what the exec path trusts, and getting it wrong opens the hole the binding exists to close. Happy to implement it if the above is the intended shape.

Reproduction

Standard Ubuntu 24.04, Python 3.12, no unusual configuration:

python3.12 -m pytest -q -o addopts= \
  "tests/test_gate0.py::TestDetachedDescendantThroughPublicCommands::test_a_detached_child_blocks_stop_deletion_and_a_second_start"

Current Gate 0 on merged main: 2 failed, 364 passed — both failures are this issue.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions