A worker can signal another worker's tool and permanently condemn its blob
- Dominant language
- Ruby
- Stars
- 23
- Forks
- 0
- Avg merge
- 11h 43m
- Merged PRs (30d)
- 27
Description
A compromised worker can signal another worker's tool subprocess — same uid, same pid namespace — and the victim's blob is then recorded as permanently unprocessable. Active Storage never revisits a permanent verdict, so one signal durably corrupts an unrelated request's result.
The chain: `magick` dies by signal, `ToolOperation#run!` sees a non-zero status and raises `UnreadableDocument`, and `Codes.permanent?("unreadable")` is `true`. `exitstatus` is `nil` for a signalled child, so the message reads `exited : `.
No compromise is needed to reach the same verdict. A cgroup OOM kill, the deadline kill, a missing delegate and a saver the build lacks all arrive as the same class. `VipsOperation` declares `unreadable Vips::Error` and `MagickOperation` declares `unreadable MiniMagick::Error`; each covers its toolchain's whole failure surface, and `MiniMagick::TimeoutError` descends from `MiniMagick::Error`. Reproduced on a valid image the same cell analyses without complaint:
```
unreadable: Vips::Error: VipsForeignSave: "…/output-0.hotcell" is not a known file format
unreadable: MiniMagick::Error: `magick … -quality banana …` failed with status: 1
```
## Fix direction
The raise site is the evidence, not the exception class. An `Operation#decoding(*errors)` boundary marks where a library error is a decoder's refusal and everywhere else stays transient. The analyzers decode and nothing else, so their bodies sit inside it. The transform path needs the source opened before the pipeline, because one `ImageProcessing` call both reads and writes: a lazy header read for libvips, an extra `identify` for ImageMagick that #7 would remove.
## Parked
Mike, 2026-08-24: HotCell protects the application from image processing, worker-from-worker is secondary, and a blob wrongly marked unprocessable is low priority.
## For whoever picks it up
- Transient is not retried forever. `AnalyzeJob` and `TransformJob` declare `retry_on ActiveStorage::IntegrityError` and nothing else, so ActiveJob does not retry these — the job fails once, visibly.
- A self-inflicted `RLIMIT_FSIZE` blowout in a subprocess would become transient. Statting the output against the inherited limit to prove it was judged oblique and inadequate as a control.
- Timeout, `ENOSPC`, `EMFILE` and `EIO` need fault injection rather than fixtures and were never driven by a test.
Prevention for the signal itself is #13. #6 is a subset of this.
HC-PT-003 of the purple-team assessment of 2026-08-22, rated Medium.
Contributor guide
Research direction
Start at the Operation#decoding(*errors) boundary, then read VipsOperation and MagickOperation to understand which failures are decoder refusals. Check AnalyzeJob and TransformJob for their retry declarations. Done means decoder failures remain permanently classified while tool, timeout, resource, and signal failures are transient rather than permanently condemning the blob.
Written by the indexing model from the issue text.
Assessment
- Tech stack
- rails, 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