A worker can replace its slot directory, and make_home follows the replacement
- Dominant language
- Ruby
- Stars
- 23
- Forks
- 0
- Avg merge
- 11h 43m
- Merged PRs (30d)
- 27
Description
A worker can rename its own slot directory aside and put a symlink in its place. `Slot#make_home` and `Slot#discard_home` both resolve that pathname rather than a handle, so the homes of two slots can be made to land in one directory — and the discard of one slot then renames the other slot's live home out from under an in-flight request.
The slot directory is `/`, and `` defaults to `File.join(Dir.tmpdir, "hotcell-workspace")` (`hotcell-server/lib/hot_cell/supervisor.rb:138`). Workers run as the uid that owns it, so nothing stops the rename.
## Reproduced
```ruby
FileUtils.mkdir_p File.join(ws, "1")
File.symlink File.join(ws, "1"), File.join(ws, "0") # worker 0 puts slot 1 in its own place
FileUtils.mkdir_p File.join(ws, "0"), mode: 0o700 # follows: no-op on slot 1
FileUtils.chmod 0o700, File.join(ws, "0") # follows: chmods slot 1
Dir.mkdir File.join(ws, "0", "home-aaaa"), 0o700 # creates inside slot 1
Dir.children(File.join(ws, "1")) # => ["home-aaaa"]
```
Every step of `Slot#make_home` resolves the symlink. `Slot#discard_home` then globs `File.join(directory, "home-*")` and renames each match, so a discard on slot 0 moves slot 1's home aside while slot 1's worker is still staging into it. That falsifies the comment above `discard_home` — at most one worker holds a slot, so every `home-*` under it is that worker's — which is true of the inode and not of the name.
## Impact
Bounded, and lower than it first looks. A compromised worker can disrupt a **concurrent** sibling's request: the sibling's staged input and output move, and its request fails. It does not carry state to a **later** request, because the home the next worker gets is a freshly created empty directory wherever the symlink lands, so there is no configuration to inherit. `$HOME` is the next request's own new subdirectory either way.
This sits alongside the residuals `docs/DESIGN.md` records under "Worker isolation" — a worker can already read a concurrent sibling's staged files and can already kill it — rather than above them. It is availability and integrity against an in-flight neighbour, not a new confidentiality path and not a cross-request one.
The prerequisite is arbitrary code execution in a worker, which the threat model assumes.
## Why there is no cheap fix
An `lstat` on the slot directory before `mkdir` is a TOCTOU window, not a defence: the attacker swaps the entry between the check and the use. Shipping one would buy false confidence and nothing else.
The correct fix is the one the purple-team report names for HC-PT-001: hold the slot directory open on a side the worker cannot mutate, and create, glob and rename relative to that handle rather than to a pathname. The supervisor would open the directory before the fork and pass the descriptor down, and the worker would use `mkdirat`, `openat`, `renameat` and `unlinkat` against it. Ruby exposes none of those, so this means either a native extension or an FFI call.
That is a real decision about `hotcell-server`'s posture: the gem carries no dependencies today, which is a deliberate property. It is worth weighing against [#13](https://github.com/basecamp/hotcell/issues/13), which already proposes a native dependency (`discourse/ruby-landlock`) for a neighbouring problem. Landlock does not help here on its own — it does not mediate the rename of a directory the worker legitimately owns.
Interim options that are honest rather than racy:
- Give each cell a workspace root the worker cannot write to, with the slot directories pre-created by the supervisor and the workspace root itself mode `0555`. A worker can still mutate its own slot directory, but it cannot replace the entry, because replacing it needs write on the parent. This does not need a new dependency.
- Record it as an accepted residual under "Worker isolation" until the handle work lands.
The first is probably the right interim, and it is small. It was not folded into the HC-PT-001 fix because it changes who creates and owns the workspace tree, which is its own change with its own review.
## Provenance
Found by an adversarial review of [#22](https://github.com/basecamp/hotcell/pull/22) (Codex, 2026-08-24), rated Medium. It is remediation bullet (b) of HC-PT-001 in the purple-team assessment of 2026-08-22 — "keep slot-parent ownership or an already-open directory handle on a side the worker cannot mutate. A same-uid retry of chmod and recursive deletion alone is raceable and insufficient" — which that PR deliberately scoped out. The report and the review reached it independently.
Contributor guide
Research direction
Start with Slot#make_home and Slot#discard_home, then read hotcell-server/lib/hot_cell/supervisor.rb around line 138 and the Worker isolation section of docs/DESIGN.md. Compare the proposed workspace-ownership interim option with the native-extension or FFI handle-based approach described here. Done means the slot entry cannot be replaced to redirect another worker’s home, with the residual documentation updated if the issue is deferred.
Written by the indexing model from the issue text.
Assessment
- Tech stack
- ruby
- Domain
- backend, security
- Issue type
- Bug
- Difficulty
- 4/5
- Estimated time
- 3-5 days
- Activity status
- Active
- Clarity
- Mostly clear
- Newbie friendliness
- 48/100