oxedyne/daimond/hand/README.md
21.0 KiB, 1 run
created by r2519314175:893, which is this file's identity for as long as the history lasts, whatever it is later renamed to
download · who wrote it · its history
| 1 | # The machine hand |
| 2 | |
| 3 | Daimond runs in a web page, and a web page cannot create a process. There is no |
| 4 | flag and no future API: the capability has to live in a program outside the page, |
| 5 | and every possible design is a different answer to *where that program sits and |
| 6 | who may talk to it*. |
| 7 | |
| 8 | This is that program. |
| 9 | |
| 10 | ## Why a native messaging host |
| 11 | |
| 12 | The obvious design is a small daemon on `127.0.0.1` that the page talks to. It |
| 13 | was rejected, and the reasoning should not be relitigated: |
| 14 | |
| 15 | - A loopback port is reachable by **any page the user visits**. |
| 16 | - It is not secret, and it is guessable in a second. |
| 17 | - So the entire defence collapses to one pasted secret. |
| 18 | |
| 19 | A native messaging host has no port. Chrome launches the binary and connects it |
| 20 | to **one extension**, and that extension is reachable from **one origin**. There |
| 21 | is nothing to find and nothing to steal, because the browser is the doorman. The |
| 22 | cost is a JSON file in a per-browser directory at install time — see |
| 23 | `install/README.md` — which is trivial for the person who wants this and is the |
| 24 | step most likely to defeat a stranger later. |
| 25 | |
| 26 | ## Why `argv` and never a shell string |
| 27 | |
| 28 | Handing a string to `sh -c` means defending against the shell itself: `;`, |
| 29 | `$(…)`, backticks, `|`, `eval`, `base64 -d | sh`, `find -exec`, |
| 30 | `tar --to-command`. That defence does not exist, anywhere, and **a fence made of |
| 31 | string matching is not a fence**. |
| 32 | |
| 33 | `{"argv": ["cargo", "test"]}` does not need the defence, because there is nothing |
| 34 | to inject into. Redirection and pipes become structured fields (`stdin`, `cwd`, |
| 35 | `capture`) rather than characters something else interprets. Claude Code goes |
| 36 | through bash because it grew out of a terminal; there is no such history here, so |
| 37 | the problem can simply not exist. |
| 38 | |
| 39 | The `env` field is not the model's to set: a model that could name environment |
| 40 | variables through it could set `LD_PRELOAD`, or carry a stolen value out through |
| 41 | one. That screen covers the field and not the request. `/usr/bin/env` and |
| 42 | `/bin/sh` are in the read-only system base, and both take an environment out of |
| 43 | their own arguments, so a command can still choose what it runs with — from |
| 44 | inside the fence, using only what the fence already grants. What bounds that is |
| 45 | the compartment, not the screen: see `REVIEW.md` §3.13. |
| 46 | |
| 47 | **Two names the hand fills in where the request named neither**, and they are the |
| 48 | whole of what a command gets that nobody asked for. `HOME`, because a shell script |
| 49 | under `set -u` dies on its first line without one — `bash dev/world.sh 3 --up` did |
| 50 | — and it is the hand's own home, the same path the page is told in `caps` as |
| 51 | `home:`. It POINTS and it does not GRANT: what a command may open is the fence's |
| 52 | decision, so a tool following `HOME` somewhere ungranted meets a refusal rather |
| 53 | than a file. And `PATH`, the same fixed `/usr/local/bin:/usr/bin:/bin` the hand |
| 54 | already resolves a bare `argv[0]` through; handing a program an environment in |
| 55 | which it cannot find `node` or `grep` was the same answer given twice and |
| 56 | differently. A pair the caller sent always wins, because a default is a floor and |
| 57 | not a correction. |
| 58 | |
| 59 | `USER`, `LOGNAME`, `LANG`, `LC_*`, `SHELL` and `TERM` are deliberately absent, and |
| 60 | each absence is argued at `ENV_DEFAULTED` in `exec.rs`. The short of it: nothing |
| 61 | needs the first two, a locale changes what a program prints and the reader here is |
| 62 | a machine, and a command down a pipe has no terminal. `TMPDIR`, `TMP` and `TEMP` |
| 63 | are the other kind — set unconditionally, refused from the caller, and the hand's |
| 64 | answer is the last word. |
| 65 | |
| 66 | ## What a run leaves behind |
| 67 | |
| 68 | A command may outlive itself. `bash dev/world.sh 3 --up` starts a dev server and a |
| 69 | mock provider in the background and returns; the direct child is reaped and the |
| 70 | two servers go on holding their ports. That is a legitimate thing to want — a |
| 71 | browser verifier needs a server to drive — so it is not refused. |
| 72 | |
| 73 | What was wrong was forgetting them at that moment. **Nothing else on the machine |
| 74 | can reach them.** Landlock scopes signals to the domain that sent them, so a later |
| 75 | command's `kill` answers `Operation not permitted`; `/proc` is outside every |
| 76 | fence, so the pid cannot be found either. Measured: a daimon brought a world up, |
| 77 | could not take it down, and two ports were held until a person cleared them from |
| 78 | outside the app. That is a leak the app creates and then forbids fixing, which is |
| 79 | worse than either half on its own. |
| 80 | |
| 81 | So the hand keeps what it started. `Req::Runs` asks what is still going — |
| 82 | `running` for a command that has not finished, `standing` for one that has and |
| 83 | whose process group has not emptied — and `Req::Signal` stops one **by the |
| 84 | identifier the run was given**. Never a pid, never a name, never a pattern: the |
| 85 | guard is not a check on the argument, it is that the argument cannot express |
| 86 | anything else, so `pkill` stays impossible. `Req::Bye` stops the standing ones |
| 87 | too, because a server nothing can reach is not a server anybody wanted. |
| 88 | |
| 89 | There is no `stopped` answer, on purpose. A signal that could not be delivered |
| 90 | comes back as `Resp::Error`; a signal that could is confirmed by asking again. A |
| 91 | teardown reporting success on a kill that failed is the defect this closes, and |
| 92 | the cheapest way not to write it again is to have nowhere to write it. |
| 93 | |
| 94 | ## The three tiers |
| 95 | |
| 96 | The Workspace panel has said `Browser · Machine · Cloud` since long before this |
| 97 | existed, and those words already mean the right things: |
| 98 | |
| 99 | | Tier | What runs | Isolation | |
| 100 | |---|---|---| |
| 101 | | **Browser** | WASI in the page | Perfect, and useless for real work — no arbitrary binaries, no sockets | |
| 102 | | **Machine** | this hand, over native messaging | The fence in `fence.rs` | |
| 103 | | **Cloud** | the *same binary* over WSS, on a box you own | The same fence, plus the network between | |
| 104 | |
| 105 | One binary serves Machine and Cloud, which is why the wire protocol is designed |
| 106 | for remote from its first line. Loopback is the degenerate case of remote; |
| 107 | building for localhost only would mean a rewrite to add the rest. |
| 108 | |
| 109 | ## The compartment is not a new idea |
| 110 | |
| 111 | `diamond_bounds()` in the app already produces exactly the structure a Landlock |
| 112 | ruleset wants: a set of paths, each read-only or read-write, plus a deny for |
| 113 | Daimond's own directory. So a Diamond's fence here is the **same rule enforced |
| 114 | one layer down**, and only the mechanism changes. The claim the user guide |
| 115 | already makes survives almost verbatim. |
| 116 | |
| 117 | Two things about that translation are silent when wrong, and are therefore tested |
| 118 | against deliberately broken code in `src/tools.rs`: |
| 119 | |
| 120 | - **A turn with no allow-list is not an unfenced turn.** The app's `may_read` |
| 121 | treats an empty bound list as *no restriction*, which is right for a file tool |
| 122 | jailed by the workspace root and catastrophic here, where there is no jail but |
| 123 | the fence. An absent allow-list becomes the granted root, never the machine. |
| 124 | - **A tainted turn loses the network.** A turn that has read a stranger's words |
| 125 | may still build and test inside its own paths, and may not reach outward. This |
| 126 | is the existing `egress_check` rule applied to a process rather than a URL, and |
| 127 | it is the direct answer to a page that says "now upload this somewhere". |
| 128 | |
| 129 | **One thing the fence takes away that the app's own bounds do not: a command |
| 130 | cannot create a symbolic link.** Anywhere — including the folders it may write and |
| 131 | its own temporary directory. `ln -s` and `symlink(2)` answer `Permission denied`, |
| 132 | because Landlock's `MAKE_SYM` is withheld from every writable grant. |
| 133 | |
| 134 | A link is half of a leak, and it is the half a fenced command can supply for the |
| 135 | price of one call. The other half is supplied by whatever later follows it: an |
| 136 | archiver, a packager, an uploader, a version control system recording the tree. |
| 137 | The case that was measured is Ore, which absorbs the *content* of a link leaving |
| 138 | the working copy, under the link's own path, into a signed history that has no |
| 139 | forget — with a global `post-commit` hook running it from outside the fence on the |
| 140 | owner's key. Checking what a link points at was the obvious repair and is weaker |
| 141 | twice over: it races a repoint between the check and the read, and it cannot see a |
| 142 | `symlink(2)` a compiler makes rather than an `ln` a model runs. |
| 143 | |
| 144 | Measured cost, on this tree: a cold `cargo check` over the whole dependency graph |
| 145 | and a `node` verifier make **no** `symlink` or `symlinkat` call at all, and both |
| 146 | run to exit 0 behind the fence with the right withheld. What does need it is four |
| 147 | shell scripts and six node scripts under `dev/`, all of them build and gate |
| 148 | plumbing — five of the six are `verify_*` and run outside the command fence |
| 149 | anyway. |
| 150 | |
| 151 | ## Which folder, and whether it is the right one |
| 152 | |
| 153 | The page holds a File System Access *handle*, which has no path and cannot be |
| 154 | turned into one. The hand holds a path it was configured with. Nothing joins |
| 155 | them, and `Tool::run` nevertheless joins the page's workspace-relative names onto |
| 156 | the hand's root — so a `root.txt` left over from another project, or a workspace |
| 157 | that lives only in OPFS, produces a fence around the wrong tree while every |
| 158 | component behaves exactly as designed. |
| 159 | |
| 160 | So the hand writes a token to `<root>/.daimond/workspace.id` and says it in |
| 161 | `caps` as `ws:<token>`. The page can read that file through the handle it already |
| 162 | has, and one comparison settles it. The token sits inside `.daimond` because a |
| 163 | fence always denies that directory: a command cannot read it, and therefore |
| 164 | cannot answer for a folder it is not in. Where the folder cannot hold a token the |
| 165 | hand says `ws:unproven` rather than nothing, because a page cannot tell silence |
| 166 | from an older hand. |
| 167 | |
| 168 | The hand has one of the two names, so this is evidence and not enforcement. The |
| 169 | refusal belongs in the page, which has both. |
| 170 | |
| 171 | ## Where the journal lives — an open question |
| 172 | |
| 173 | `~/.local/share/daimond/hand/journal`, and `.local` is hidden. That one fact cost |
| 174 | an hour on 2026-08-02: a snap Chromium started the hand, snap's `home` interface |
| 175 | grants only the *non-hidden* files in `$HOME`, and the hand could not open the |
| 176 | journal — so it exited without writing the record it would have used to say why, |
| 177 | and Chrome reported a bare "Native host has exited". |
| 178 | |
| 179 | Two things have been done and one has not. |
| 180 | |
| 181 | **Done.** Every refusal on the startup path now goes to standard error as a |
| 182 | sentence before the process ends (`refuse` in `src/main.rs`), because Chrome |
| 183 | copies a native host's standard error into its own log and that is the only |
| 184 | channel that survives a journal the hand cannot open. And `install/install.sh` |
| 185 | finds snap and flatpak profiles, refuses to register into them, and says why. |
| 186 | |
| 187 | **Not done: the journal has not been moved, and should not be without a |
| 188 | decision.** The argument for moving it — say to `~/Daimond/hand/journal`, not |
| 189 | hidden, reachable by a confined browser — is that the override is an environment |
| 190 | variable and a browser hands a native messaging host *its own* environment, so |
| 191 | `DAIMOND_HAND_JOURNAL_DIR` cannot reach the hand at all. That is precisely why |
| 192 | the granted root is a *file*. An escape hatch nobody can operate is not one. |
| 193 | |
| 194 | The argument against is stronger, and is why this is written down rather than |
| 195 | acted on: |
| 196 | |
| 197 | - The journal is the tamper-evident record, and its whole value is that its |
| 198 | location is predictable and boring. `~/.local/share` is where XDG says |
| 199 | application state goes; a visible directory in `$HOME` is a directory users |
| 200 | rename, sync, back up into a shared drive, and delete. |
| 201 | - Moving it strands every existing install's chain. The chain is the product's |
| 202 | claim, and a hand that starts a new one because the path changed under it is a |
| 203 | hand that has quietly discarded history. |
| 204 | - It fixes the symptom for one packaging. Flatpak's `--filesystem=home` has the |
| 205 | same hidden-file exclusion, and any future confinement will differ again. The |
| 206 | refusal at install time works for all of them. |
| 207 | |
| 208 | **The recommendation is therefore: keep the path, and fix the override rather |
| 209 | than the default.** `root.txt` cannot be the model here — it lives *inside* the |
| 210 | journal directory, so a file naming that directory cannot live there too. The one |
| 211 | path a browser-launched hand knows without being told is its own: a |
| 212 | `daimond-hand.journal` beside `/proc/self/exe`, written by `install.sh` when an |
| 213 | operator asks for a journal elsewhere, would give the escape hatch to the person |
| 214 | who actually needs it, with no migration and no second guess about where the |
| 215 | record is by default. |
| 216 | |
| 217 | That has not been built. It is a change to where the tamper-evident record can be |
| 218 | pointed, and that is the user's call rather than this session's. |
| 219 | |
| 220 | ## Layout |
| 221 | |
| 222 | | File | What it is | |
| 223 | |---|---| |
| 224 | | `src/wire.rs` | The contract: `Req`, `Resp`, `FenceSpec`, the size limits | |
| 225 | | `src/codec.rs` | JSON via `fe2o3_jdat`, framed for native messaging and for WebSocket | |
| 226 | | `src/exec.rs` | The runner: argv, cleared environment, streamed output, process-group kill | |
| 227 | | `src/fence.rs` | What a command may touch | |
| 228 | | `src/seccomp.rs` | What a command may *call* — the half Landlock cannot express | |
| 229 | | `src/verify.rs` | Running a NAMED verifier from the tracked tree, and refusing to report a bare pass | |
| 230 | | `src/journal.rs` | What was run, what it returned, and what it was refused | |
| 231 | | `install/` | The host manifest, the installer (`--workspace`, `--check`, `--selftest`), and a mock host for tests | |
| 232 | |
| 233 | ## The one thing that runs outside the fence |
| 234 | |
| 235 | `Req::Verify` runs a `dev/verify_*.mjs` from the granted tree **unfenced**, and |
| 236 | that is a deliberate exception rather than an oversight. State it plainly before |
| 237 | reading further: a verify makes a process that Landlock and seccomp are not |
| 238 | applied to. |
| 239 | |
| 240 | The reason is that the fence makes browser evidence impossible. A fenced command |
| 241 | cannot open the display server's unix socket and cannot `listen`, so every |
| 242 | verifier that drives a real page dies under it — and those verifiers are half the |
| 243 | proof of a release. A machine that can write code and cannot check it is not a |
| 244 | safer machine, it is a machine whose claims nobody can test. |
| 245 | |
| 246 | The justification is **provenance, not confinement**. The fence exists to contain |
| 247 | a command a MODEL wrote. What the model supplies here is a *name*, which the hand |
| 248 | looks up in its own granted `dev/` directory, and at most a *break*, which the |
| 249 | hand looks up in that file's own source. What reaches the argument vector is the |
| 250 | directory entry's own file name and a break name parsed out of the file — never |
| 251 | the caller's string, and never through a shell. That puts a verifier in the same |
| 252 | trust class as `cargo test`, which the hand already runs. |
| 253 | |
| 254 | Three things keep that checkable rather than merely asserted: |
| 255 | |
| 256 | * Every run is journalled as the `Exec` it really is, with the real node command |
| 257 | line and `fence:none` in its mechanisms. A reader of the record sees the |
| 258 | exception; nothing hides it. |
| 259 | * `--report` prints the verifiers this machine would run, and says they run |
| 260 | outside the fence, above the list of what the fence enforces. |
| 261 | * The handshake carries `verify:dev` or `verify:none`, so a page can say "not on |
| 262 | this computer" rather than discovering it one refusal at a time. |
| 263 | |
| 264 | And the verb **cannot report a bare pass**. It runs the clean pass and each |
| 265 | declared break, and answers with three numbers: checks passed, breaks confirmed |
| 266 | red, and breaks that reddened nothing. `verify::Verdict` is an enum whose every |
| 267 | arm carries the third number, so there is no expression in the program that |
| 268 | yields the first without it; a clean-only run is labelled UNPROVEN in the report |
| 269 | and in the trailer the app restates. `dev/verify_verifyverb.mjs` proves this |
| 270 | against a fixture with a deliberately dead break. |
| 271 | |
| 272 | ## Release gates |
| 273 | |
| 274 | > **Read `REVIEW.md` first**, and read its "Where this stands" table before |
| 275 | > anything else in it. Six adversarial reviews on 2026-08-02 demonstrated three |
| 276 | > escapes from the fence and forged a tampered journal three ways; all three |
| 277 | > escapes are now closed, each closure named against the code that answers it and |
| 278 | > proved by re-running the escape, and three findings are still open. The gates |
| 279 | > below were written before that review; gates 1, 2 and 4 now hold, and the |
| 280 | > capability does not ship until the third open finding is decided. |
| 281 | |
| 282 | **These are not finished-work notes. They are conditions on shipping, because the |
| 283 | consent window already promises them.** `ext/_locales/en/messages.json` tells the |
| 284 | user that a command runs "only inside the folders the workspace already allows" |
| 285 | and that "every run is written to a journal you can read". Until both are true |
| 286 | and in force, that window is making a claim the code does not keep, and a promise |
| 287 | about safety that is not kept is worse than no promise. |
| 288 | |
| 289 | 1. **The fence must be in force, or the command must be refused.** Not "run it |
| 290 | unfenced and mention it" — refused. The `caps` list in the `hello` exists so |
| 291 | the app can say which guarantee it is actually offering on this machine, and |
| 292 | the grant window's wording must be chosen from `caps` rather than hard-coded. |
| 293 | 2. **The journal must be authoritative and outside every fence.** A journal the |
| 294 | daimon can rewrite is not a journal. |
| 295 | 3. **Neither promise may be softened by weakening the text.** If a guarantee |
| 296 | cannot be kept, the capability does not ship; the sentence is not what needs |
| 297 | editing. |
| 298 | |
| 299 | 4. **The fence must be applied to the command, not to the hand.** `Plan::apply()` |
| 300 | fences the *calling* process and `pre_exec` is `unsafe`, so the hand re-execs |
| 301 | itself as a launcher, applies the fence there, and becomes the command through |
| 302 | a safe `CommandExt::exec`. That landed; `exec::launch_main` is it. |
| 303 | |
| 304 | ## Two mechanisms, in one order |
| 305 | |
| 306 | The compartment is Landlock **and** seccomp, and neither is optional. Landlock |
| 307 | governs opening a file, which leaves two measured escapes it has no way to |
| 308 | express: the metadata calls have no access right, so a command could world-write |
| 309 | a file inside the denied subtree; and `connect()` to a pathname unix socket is |
| 310 | ungoverned below ABI 9, so a command could reach the session bus and start a |
| 311 | process that was never fenced at all. `src/seccomp.rs` refuses both by syscall |
| 312 | number. It is a deny-list and says so — it removes named capabilities from a |
| 313 | command rather than sandboxing it. |
| 314 | |
| 315 | The launcher does three things before `execve` and the order is not negotiable: |
| 316 | |
| 317 | 1. **Adopt the terminal**, where there is one. Landlock ABI 5 governs `ioctl` on |
| 318 | a device file opened after the ruleset, and `TIOCSCTTY` is exactly that ioctl, |
| 319 | so a session that fenced first would fence itself out of its own terminal. |
| 320 | 2. **Apply the fence.** Doing so *opens every granted path*, so Landlock has real |
| 321 | work left after its own rules take hold. |
| 322 | 3. **Install the filter.** It needs nothing after itself but `execve`. Put before |
| 323 | the fence, a deny-list that ever named something the `landlock` crate needed |
| 324 | would break the fence rather than the command — the wrong failure, in the |
| 325 | wrong layer, for a reason nobody could read. |
| 326 | |
| 327 | Both are irreversible, both survive `execve`, and both need `no_new_privs`. A |
| 328 | machine that cannot do either refuses the command; there is no degraded mode. |
| 329 | |
| 330 | ## The finding that changes the ssh plan |
| 331 | |
| 332 | Measured on this machine at Landlock **ABI 8**: **pathname unix sockets are not |
| 333 | governed until ABI 9 (Linux 7.1)**. Landlock alone therefore lets a fenced |
| 334 | command `connect()` to a socket file whose path is outside the fence — the |
| 335 | session bus, the X11 socket, a container daemon, and **`ssh-agent`**. |
| 336 | |
| 337 | The filter closes this by refusing `socket(AF_UNIX, …)`, which every one of those |
| 338 | needs first, so what follows is the reasoning that made the refusal |
| 339 | unconditional rather than a problem still open. |
| 340 | |
| 341 | That last one matters more than the rest put together, because the plan for |
| 342 | `ssh_run` was that *keys are capabilities, not files*: the hand would hold the |
| 343 | key, offer `ssh_run(host, argv)` against an allow-list, and the daimon would |
| 344 | never see the key at all. **A reachable agent socket defeats that entirely.** A |
| 345 | command that can talk to `ssh-agent` can sign with the user's keys, to any host, |
| 346 | without the key ever being read — which is precisely the property that made |
| 347 | agent forwarding dangerous in the first place. |
| 348 | |
| 349 | So `ssh_run` cannot be built on the fence alone, on any kernel below 7.1. It can |
| 350 | be built on the fence *and the filter*, which is why the filter refuses `AF_UNIX` |
| 351 | for every command rather than only for one that was already denied the network: |
| 352 | reaching the agent has nothing to do with whether the command was allowed to |
| 353 | fetch a crate. The cost is that a command cannot use any local socket it names — |
| 354 | a database, a container daemon, X11, an agent-authenticated `git fetch` — and |
| 355 | that cost is stated in `--report` rather than discovered. |
| 356 | |
| 357 | **Decide the rest of `ssh_run` before building it, not during it.** The remaining |
| 358 | options are unchanged: rely on `SSH_AUTH_SOCK` being absent (weak — the path is |
| 359 | guessable, and the filter is what actually stops it today); or hold ssh behind a |
| 360 | capability the hand executes in a *separate*, more tightly fenced process. |
| 361 | |
| 362 | ## Deliberately not done |
| 363 | |
| 364 | This once said "no pty", and the paragraph is kept because the reasoning still |
| 365 | explains the shape of the wire: `Req::Exec` is the simple, non-interactive case |
| 366 | and it covers nearly everything an agent does. A terminal turned out to be one of |
| 367 | the cases that hurt, so `Req::Open` exists alongside it, with its own messages |
| 368 | rather than a flag — almost nothing is shared, and keystrokes are never |
| 369 | journalled, because the questions a program asks a terminal are `sudo` wanting a |
| 370 | password and `ssh` wanting a passphrase. |