diff --git a/docs/specs/2026-09-26-m5b-hardening.md b/docs/specs/2026-09-26-m5b-hardening.md index 4b92088..90b1c7b 100644 --- a/docs/specs/2026-09-26-m5b-hardening.md +++ b/docs/specs/2026-09-26-m5b-hardening.md @@ -31,7 +31,8 @@ that has read untrusted content carries its links as code. 6. **`loopd`** neutralises tags and checks them in its self-test (section 8). 7. **`gatewayd`** escapes images and disarms links (section 9). 8. **`bxctl sanitize check`** (section 6.6). -9. The runbook entries (section 12) and the links table (section 10). +9. **One instance per daemon** (section 9a). +10. The runbook entries (section 12) and the links table (section 10). ## 3. `bxcore` @@ -278,6 +279,32 @@ labels here is sound: this is a defence in depth on presentation, not a trust ga Separately, the owner may want to review `EnableLinkPreviews` for the whole server: it applies to every post, including other people's. +## 9a. One instance per daemon + +Every daemon removes a socket it finds at its path before binding, so a socket left by a daemon +that was killed never blocks the next start (checked 2026-09-26: `loopd`, `brokerd`, `memoryd`, +`gatewayd` and `inferproxy` all do). But the removal is unconditional: a second copy of a daemon, +started by mistake, silently takes the path from the one that is running, which keeps serving its +now-unreachable socket, and nothing says so (owner, 2026-09-26: refuse to start instead). + +- Before it touches its sockets, each daemon opens a lock file, creating it with mode 0600, and + takes an exclusive lock with the standard library's `File::try_lock` (stable since Rust 1.89; + checked on 1.95: a second handle gets `TryLockError::WouldBlock` while the first holds it, and + succeeds once it is closed). The file is `/run/.lock` for `loopd`, `brokerd`, + `memoryd` and `gatewayd`, and `.lock` beside `inferproxy`'s `--listen` socket. +- `WouldBlock`: another instance is running. The daemon does not start, and says so: + `: another is running (it holds ); see docs/runbook.md#already-running`. + Any other error on the lock file also stops it, naming the error. +- With the lock held, it removes any stale socket and binds, as today. The lock is kept for the + life of the process and released by the system when the process ends, however it ends, so a + killed daemon never leaves a lock behind. The lock file itself is left in place: removing it + would reopen the race it closes. +- No new dependency and no `unsafe`: the lock is in the standard library. + +Leftover sockets after a stop stay harmless and are not removed at shutdown: that would need signal +handling, which the standard library lacks. In M8, systemd's `RuntimeDirectory=` removes the +sockets' directory when a unit stops. + ## 10. Links between the roles Every link as the tree stands after M5b. Paths are the defaults under the role's home; each can be @@ -317,6 +344,8 @@ The plan checks every row of this table against the code before it is handed ove as configured; a clean session's links are still fetched by the server's previews, by the owner's choice. - `loopd`'s binary does not contain the secret loader. +- A second instance of a daemon can no longer take a running one's sockets without a word + (section 9a). ## 12. Runbook @@ -329,6 +358,8 @@ New entries, given in the plan as fragments to copy verbatim (lesson of M5a task - `memory-entry-excluded`: an identity entry left out of the memory block by a refuse rule. - `sanitizer-rules`: the procedure of 6.5. - `loopd-tags-unexpected`: the self-test found a tag that does not tokenize as recorded. +- `already-running`: a daemon refused to start because another instance holds its lock: how to + find it (`pgrep -x `, the lock file), and when a second copy is a mistake. ## 13. Testing @@ -346,6 +377,9 @@ Given tests, first, as in every plan: labels. - `gatewayd`: images escaped in every reply, URLs disarmed only when `untrusted`, with the notice. - `bxctl`: `sanitize check`'s output and exit status. +- Each daemon: a second instance started against the same home (or, for `inferproxy`, the same + socket) while the first runs is refused with the sentence and pointer of 9a, and the first keeps + serving; after the first is killed, a new instance starts, removes the stale socket and serves. **Attack tests for every trust boundary (T37)**: a fetched page ending the tool result and forging a system turn reaches the model with the tags escaped; an identity write that asks to send the