Take the daemon socket out of /tmp, and check who owns it - #64
Merged
Merged
Conversation
/tmp/pylon.sock was chosen because /tmp exists on every Unix. It is also the
one directory every other user on the machine can write to, and the daemon
authenticates nobody — whatever reaches that socket can run `secret set`,
`do`, and `listen`, which opens the microphone.
Nothing checked who owned the socket either. On a shared machine that is
enough: bind /tmp/pylon.sock first — before Pylon's first run, or after the
daemon exits and removes it — and the victim's `pylon secret set gemini
<api-key>` arrives in plaintext. Their daemon cannot even take the name back,
because the sticky bit stops it removing a file it does not own.
Three changes, because the first alone is not proof:
- The socket moves to a directory only its owner may write to:
$XDG_RUNTIME_DIR/pylon/ on Linux, /tmp/pylon-<uid>/ where there is none.
That is the shape tmux and gpg-agent use, for this reason.
- secureDir refuses to run in a directory that belongs to someone else and
tightens one that is merely loose. MkdirAll succeeds on an existing
directory whoever made it, which is the case worth catching, since the
fallback name is predictable.
- The socket is chmod 0600, not whatever the umask left — connecting to a
Unix socket needs write permission on it, so that mode is the last gate,
and 022 vs 002 should not be what decides.
- Clients check the socket's owner before dialling. The daemon's own
protections are no help if the thing answering is not the daemon, and the
first thing down that wire may be a secret.
pylon-ui carries its own copy of the path by design; it moves in step.
Verified live: the socket is now srw------- inside a drwx------ directory,
/tmp/pylon.sock is gone, and the CLI, the daemon and a raw client all work.
The refusal was checked against /run/dbus/system_bus_socket — root-owned and
srw-rw-rw-, so connecting would have succeeded — and Send rejected it before
dialling.
Upgrading needs a daemon restart: one still listening on the old path will not
be found. PYLON_SOCKET and paths.socket override exactly as before.
t.TempDir() on macOS produced /var/folders/df/.../TestSecureSocketClosesItToOthers58095275/001/pylon.sock which is 104 bytes — exactly the cap on a Unix socket path there — so bind failed with "invalid argument" for a reason that had nothing to do with what the tests check. The project has hit this before and already solved it twice: daemon_test.go and pylon-ui/app_test.go both make their own short temp root. Same helper here, with the same reasoning written down. Only the three tests that actually bind need it; the rest just stat directories, where path length is irrelevant.
YCistak
force-pushed
the
fix/socket-trust
branch
from
August 13, 2026 20:28
c96c0ce to
229c1af
Compare
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
/tmp/pylon.sockwas chosen because/tmpexists on every Unix. It is also the one directory every other user on the machine can write to — and the daemon authenticates nobody. Whatever reaches that socket can runsecret set,do, andlisten, which opens the microphone.Nothing checked who owned the socket either. On a shared machine that is enough:
/tmp/pylon.sockfirst — before Pylon's first run, or after the daemon exits and removes the name on shutdown.pylon secret set gemini <api-key>. It arrives at the attacker's socket in plaintext.Single-user machines were never exposed. This is a shared-machine problem, and it was a real one.
The fix, in four parts — the first alone would not be proof
$XDG_RUNTIME_DIR/pylon/on Linux (/run/user/<uid>, made 0700 by the login session),/tmp/pylon-<uid>/where there is no such variable. The shapetmuxandgpg-agentuse, for this reason.secureDirrefuses a directory that belongs to someone else and tightens one that is merely loose.MkdirAllsucceeds on a directory that already exists, whoever made it — which is exactly the case worth catching, since the fallback name is predictable.chmod 0600, not whatever the umask left. Connecting to a Unix socket needs write permission on it, so its mode is the last gate;022vs002should not be what decides.pylon-uicarries its own copy of the path by design (separate module, cannot importinternal/); it moves in step.Verification
Live, after restarting the daemon:
Was
srwxr-xr-xin a world-writable directory; is nowsrw-------in a private one.The refusal was checked against a socket that really belongs to someone else —
/run/dbus/system_bus_socket, root-owned andsrw-rw-rw-, so connecting would have worked:Tests cover the private directory, tightening a loose one, idempotent restarts, the socket's mode after
Listen, ownership accepted for our own socket and refused for another user's, that the refusal happens before the dial, and that the default path is never directly inside a world-writable directory.-raceclean.Upgrading
Restart the daemon —
systemctl --user restart pylon, or close and reopen the window. One still listening on the old path will not be found and the CLI will report it as not running.PYLON_SOCKETandpaths.socketoverride the default exactly as before.Third and last PR from the security review, after #62 (dependencies) and #63 (input hardening).
🤖 Generated with Claude Code