fix: the placeholder agent socket gets a directory of its own - #648
JSmithRobotics wants to merge 2 commits into
Conversation
Reviewer's GuideThe PR confines absent-agent placeholder sockets to dedicated, uid-scoped directories for both cache and Sequence diagram for safe absent-agent socket setupsequenceDiagram
participant Host as Host
participant HostAgent as HostAgent
participant Filesystem as Filesystem
participant Devpod as Devpod
Host->>HostAgent: absent_agent_socket()
HostAgent->>HostAgent: choose cache or /tmp uid-scoped candidate
HostAgent->>Filesystem: bind_placeholder(path)
Filesystem->>Filesystem: ensure_our_dir(parent)
alt parent is owned directory
Filesystem->>Filesystem: symlink_metadata(socket)
Filesystem-->>HostAgent: socket path
else parent is symlink or not owned
Filesystem-->>HostAgent: None
end
HostAgent-->>Host: SSH_AUTH_SOCK path
Host->>Devpod: launch workspace
Devpod->>Filesystem: ChownR(filepath.Dir(SSH_AUTH_SOCK), user)
Filesystem-->>Devpod: chown dedicated directory only
File-Level Changes
Tips and commandsInteracting with Sourcery
Customizing Your ExperienceAccess your dashboard to:
Getting Help
|
There was a problem hiding this comment.
Hey - I've found 1 issue
Prompt for AI Agents
Please address the comments from this code review:
## Individual Comments
### Comment 1
<location path="rust/devlaunch-core/src/flows/launch.rs" line_range="2177-2182" />
<code_context>
if is_our_socket(path) {
return Some(path.to_owned());
}
- std::fs::create_dir_all(path.parent()?).ok()?;
+ if !ensure_our_dir(path.parent()?) {
+ return None;
+ }
// SAFETY: as above.
</code_context>
<issue_to_address>
**🚨 issue (security):** An existing socket is accepted before `ensure_our_dir` checks its parent, so a socket reached through a symlinked parent directory is reused and devpod recursively chowns the directory tree behind that symlink.
**Triggers:** When the placeholder socket already exists and its parent directory has been replaced with a symlink.
**Suggested fix:** Call `ensure_our_dir(path.parent()?)` before the `is_our_socket` reuse return, or revalidate the parent immediately before returning the existing socket.
```suggestion
if !ensure_our_dir(path.parent()?) {
return None;
}
if is_our_socket(path) {
return Some(path.to_owned());
}
```
</issue_to_address>
Codecov Report❌ Patch coverage is
Additional details and impacted files
Flags with carried forward coverage won't be shown. Click here to find out more.
🚀 New features to boost your workflow:
|
devpod's ChownAgentSock (pkg/devcontainer/setup/setup.go) runs unconditionally after every `devpod up` and recursively Lchowns everything under filepath.Dir(SSH_AUTH_SOCK) to the container's remote user. Host::absent_agent_socket put the placeholder straight in the cache root, so on a host with no ssh agent that walk was rooted at the whole cache: every clone, every control socket. If anything under it is not owned by this user or is read-only, the walk fails and the launch dies with it -- forwarding having nothing to do with why. The fix is the directory, not the socket's liveness. Both candidate paths (the cache path and the /tmp fallback for hosts whose cache path is too deep for a unix socket address) now get a directory holding nothing but the placeholder, so the recursive walk has exactly one trivial thing to walk. bind_placeholder grows an ensure_our_dir helper so the existing guarantee holds one level up: symlink_metadata and an owner test, never metadata, so a symlink planted at the directory path is refused rather than followed into a directory this process does not own. Saw the new invariant assertions fail to compile against AGENT_SOCKET_DIR_NAME / AGENT_SOCKET_FILE_NAME / ensure_our_dir before those existed; with the fix in place `cargo test -p devlaunch-core` passes the placeholder and directory tests. Claude-Session: https://claude.ai/code/session_01AdSFnBdxie6TosHVmjLY28
32c6033 to
cfbf323
Compare
`bind_placeholder` returned an existing socket before checking the directory
holding it, so the parent was validated on the path that CREATES a socket and
not on the path that REUSES one. The doc above the function already claimed
both ("the same is true one level up"); only one of them did it.
It matters because `is_our_socket` declines to follow a symlink at the final
component and follows every component above it. A parent replaced by a symlink
therefore leaves a genuine socket of this user's reachable through it, which
passes every test the reuse makes -- and devpod `Lchown`s the whole of
`filepath.Dir(SSH_AUTH_SOCK)`, so the tree it then walks is whoever made the
link's.
The fix is the order. `ensure_our_dir` runs first and both paths are behind it.
The new test is the trap stated exactly: a real socket this process bound, in a
directory reached through a symlink. It passes `is_our_socket` on purpose --
the socket is fine and the directory is not. Reverting the order fails it.
Reported by review on #648.
Claude-Session: https://claude.ai/code/session_01AdSFnBdxie6TosHVmjLY28
|
Fixed in 1fc0c87, and the finding was correct.
Why it is reachable: The fix is the suggested ordering: The new test states the trap exactly — a real socket bound by the test process, inside a directory reached through a symlink. It asserts Also rebased onto 0.59.2. |
The bug
When
dllaunches a workspace on a host with no ssh agent, it synthesises a placeholder unix socket and hands its path to devpod asSSH_AUTH_SOCK, so the devcontainer's bind mount has something real to point at instead of an empty string.devpod's
ChownAgentSock(pkg/devcontainer/setup/setup.go) runs unconditionally after everydevpod up, whether or not agent forwarding actually succeeded:ChownR(pkg/copy/copy.go) is a recursivefilepath.WalkDirthatLchowns every entry it finds under that directory. A live agent never triggers a problem here because devpod builds its own dedicated relay directory holding exactly one socket. ButHost::absent_agent_socketput the placeholder directly in devlaunch's cache root, sofilepath.Dir(SSH_AUTH_SOCK)resolved to the entire cache directory: every cloned repo, every ssh control socket, everythingdlstores. If any entry anywhere under that root is not owned by the container's remote user, or sits on a read-only mount, the recursive chown fails and the whole launch dies, with no indication that agent forwarding (which is working exactly as designed) had anything to do with it.Observed on a real launch, where the walk reached a read-only bind:
The fix
Give the placeholder socket a directory of its own, so the recursive walk has exactly one trivial thing to walk. Both candidate paths get this: the cache-dir path, and the
/tmpfallback used when the cache path is too deep to fit in a unix socket address (the 104-bytesockaddr_unlimit). The fallback's directory name is now uid-keyed (devlaunch-<uid>-agent-sock), which is the uniqueness the placeholder's filename used to carry on its own; the filename itself is now the fixedno-agent.sock, with the directory doing the per-user keying instead.bind_placeholder's existing symlink-safety guarantee (never trust a path viametadata, which follows symlinks, alwayssymlink_metadataplus an owner check) is extended one level up via a newensure_our_dirhelper, so a symlinked directory at the placeholder's parent is refused rather than followed into a tree this process does not own.Testing
Extended the existing placeholder-socket unit tests to assert the directory holding the placeholder contains nothing else and is never the cache root or
/tmpitself, and added a test that a symlinked directory at the placeholder's parent path is refused. All pass;cargo clippyandcargo fmt --checkclean; no public API or CLI flag changes.Summary by Sourcery
Isolate placeholder SSH agent sockets in exclusively owned directories so agent setup cannot recursively chown unrelated files.
Bug Fixes:
Enhancements:
Tests: