Oregami
Repositories/oxedyne/daimond

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
3Daimond runs in a web page, and a web page cannot create a process. There is no
4flag and no future API: the capability has to live in a program outside the page,
5and every possible design is a different answer to *where that program sits and
6who may talk to it*.
7
8This is that program.
9
10## Why a native messaging host
11
12The obvious design is a small daemon on `127.0.0.1` that the page talks to. It
13was 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
19A native messaging host has no port. Chrome launches the binary and connects it
20to **one extension**, and that extension is reachable from **one origin**. There
21is nothing to find and nothing to steal, because the browser is the doorman. The
22cost 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
24step most likely to defeat a stranger later.
25
26## Why `argv` and never a shell string
27
28Handing 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
31string matching is not a fence**.
32
33`{"argv": ["cargo", "test"]}` does not need the defence, because there is nothing
34to inject into. Redirection and pipes become structured fields (`stdin`, `cwd`,
35`capture`) rather than characters something else interprets. Claude Code goes
36through bash because it grew out of a terminal; there is no such history here, so
37the problem can simply not exist.
38
39The `env` field is not the model's to set: a model that could name environment
40variables through it could set `LD_PRELOAD`, or carry a stolen value out through
41one. 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
43their own arguments, so a command can still choose what it runs with — from
44inside the fence, using only what the fence already grants. What bounds that is
45the 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
48whole of what a command gets that nobody asked for. `HOME`, because a shell script
49under `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
52decision, so a tool following `HOME` somewhere ungranted meets a refusal rather
53than a file. And `PATH`, the same fixed `/usr/local/bin:/usr/bin:/bin` the hand
54already resolves a bare `argv[0]` through; handing a program an environment in
55which it cannot find `node` or `grep` was the same answer given twice and
56differently. A pair the caller sent always wins, because a default is a floor and
57not a correction.
58
59`USER`, `LOGNAME`, `LANG`, `LC_*`, `SHELL` and `TERM` are deliberately absent, and
60each absence is argued at `ENV_DEFAULTED` in `exec.rs`. The short of it: nothing
61needs the first two, a locale changes what a program prints and the reader here is
62a machine, and a command down a pipe has no terminal. `TMPDIR`, `TMP` and `TEMP`
63are the other kind — set unconditionally, refused from the caller, and the hand's
64answer is the last word.
65
66## What a run leaves behind
67
68A command may outlive itself. `bash dev/world.sh 3 --up` starts a dev server and a
69mock provider in the background and returns; the direct child is reaped and the
70two servers go on holding their ports. That is a legitimate thing to want — a
71browser verifier needs a server to drive — so it is not refused.
72
73What was wrong was forgetting them at that moment. **Nothing else on the machine
74can reach them.** Landlock scopes signals to the domain that sent them, so a later
75command's `kill` answers `Operation not permitted`; `/proc` is outside every
76fence, so the pid cannot be found either. Measured: a daimon brought a world up,
77could not take it down, and two ports were held until a person cleared them from
78outside the app. That is a leak the app creates and then forbids fixing, which is
79worse than either half on its own.
80
81So 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
83whose process group has not emptied — and `Req::Signal` stops one **by the
84identifier the run was given**. Never a pid, never a name, never a pattern: the
85guard is not a check on the argument, it is that the argument cannot express
86anything else, so `pkill` stays impossible. `Req::Bye` stops the standing ones
87too, because a server nothing can reach is not a server anybody wanted.
88
89There is no `stopped` answer, on purpose. A signal that could not be delivered
90comes back as `Resp::Error`; a signal that could is confirmed by asking again. A
91teardown reporting success on a kill that failed is the defect this closes, and
92the cheapest way not to write it again is to have nowhere to write it.
93
94## The three tiers
95
96The Workspace panel has said `Browser · Machine · Cloud` since long before this
97existed, 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
105One binary serves Machine and Cloud, which is why the wire protocol is designed
106for remote from its first line. Loopback is the degenerate case of remote;
107building 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
112ruleset wants: a set of paths, each read-only or read-write, plus a deny for
113Daimond's own directory. So a Diamond's fence here is the **same rule enforced
114one layer down**, and only the mechanism changes. The claim the user guide
115already makes survives almost verbatim.
116
117Two things about that translation are silent when wrong, and are therefore tested
118against 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
130cannot create a symbolic link.** Anywhere — including the folders it may write and
131its own temporary directory. `ln -s` and `symlink(2)` answer `Permission denied`,
132because Landlock's `MAKE_SYM` is withheld from every writable grant.
133
134A link is half of a leak, and it is the half a fenced command can supply for the
135price of one call. The other half is supplied by whatever later follows it: an
136archiver, a packager, an uploader, a version control system recording the tree.
137The case that was measured is Ore, which absorbs the *content* of a link leaving
138the working copy, under the link's own path, into a signed history that has no
139forget — with a global `post-commit` hook running it from outside the fence on the
140owner's key. Checking what a link points at was the obvious repair and is weaker
141twice 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
144Measured cost, on this tree: a cold `cargo check` over the whole dependency graph
145and a `node` verifier make **no** `symlink` or `symlinkat` call at all, and both
146run to exit 0 behind the fence with the right withheld. What does need it is four
147shell scripts and six node scripts under `dev/`, all of them build and gate
148plumbing — five of the six are `verify_*` and run outside the command fence
149anyway.
150
151## Which folder, and whether it is the right one
152
153The page holds a File System Access *handle*, which has no path and cannot be
154turned into one. The hand holds a path it was configured with. Nothing joins
155them, and `Tool::run` nevertheless joins the page's workspace-relative names onto
156the hand's root — so a `root.txt` left over from another project, or a workspace
157that lives only in OPFS, produces a fence around the wrong tree while every
158component behaves exactly as designed.
159
160So 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
162has, and one comparison settles it. The token sits inside `.daimond` because a
163fence always denies that directory: a command cannot read it, and therefore
164cannot answer for a folder it is not in. Where the folder cannot hold a token the
165hand says `ws:unproven` rather than nothing, because a page cannot tell silence
166from an older hand.
167
168The hand has one of the two names, so this is evidence and not enforcement. The
169refusal 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
174an hour on 2026-08-02: a snap Chromium started the hand, snap's `home` interface
175grants only the *non-hidden* files in `$HOME`, and the hand could not open the
176journal — so it exited without writing the record it would have used to say why,
177and Chrome reported a bare "Native host has exited".
178
179Two things have been done and one has not.
180
181**Done.** Every refusal on the startup path now goes to standard error as a
182sentence before the process ends (`refuse` in `src/main.rs`), because Chrome
183copies a native host's standard error into its own log and that is the only
184channel that survives a journal the hand cannot open. And `install/install.sh`
185finds 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
188decision.** The argument for moving it — say to `~/Daimond/hand/journal`, not
189hidden, reachable by a confined browser — is that the override is an environment
190variable 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
192the granted root is a *file*. An escape hatch nobody can operate is not one.
193
194The argument against is stronger, and is why this is written down rather than
195acted 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
209than the default.** `root.txt` cannot be the model here — it lives *inside* the
210journal directory, so a file naming that directory cannot live there too. The one
211path 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
213operator asks for a journal elsewhere, would give the escape hatch to the person
214who actually needs it, with no migration and no second guess about where the
215record is by default.
216
217That has not been built. It is a change to where the tamper-evident record can be
218pointed, 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
236that is a deliberate exception rather than an oversight. State it plainly before
237reading further: a verify makes a process that Landlock and seccomp are not
238applied to.
239
240The reason is that the fence makes browser evidence impossible. A fenced command
241cannot open the display server's unix socket and cannot `listen`, so every
242verifier that drives a real page dies under it — and those verifiers are half the
243proof of a release. A machine that can write code and cannot check it is not a
244safer machine, it is a machine whose claims nobody can test.
245
246The justification is **provenance, not confinement**. The fence exists to contain
247a command a MODEL wrote. What the model supplies here is a *name*, which the hand
248looks up in its own granted `dev/` directory, and at most a *break*, which the
249hand looks up in that file's own source. What reaches the argument vector is the
250directory entry's own file name and a break name parsed out of the file — never
251the caller's string, and never through a shell. That puts a verifier in the same
252trust class as `cargo test`, which the hand already runs.
253
254Three 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
264And the verb **cannot report a bare pass**. It runs the clean pass and each
265declared break, and answers with three numbers: checks passed, breaks confirmed
266red, and breaks that reddened nothing. `verify::Verdict` is an enum whose every
267arm carries the third number, so there is no expression in the program that
268yields the first without it; a clean-only run is labelled UNPROVEN in the report
269and in the trailer the app restates. `dev/verify_verifyverb.mjs` proves this
270against 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
283consent window already promises them.** `ext/_locales/en/messages.json` tells the
284user that a command runs "only inside the folders the workspace already allows"
285and that "every run is written to a journal you can read". Until both are true
286and in force, that window is making a claim the code does not keep, and a promise
287about safety that is not kept is worse than no promise.
288
2891. **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.
2932. **The journal must be authoritative and outside every fence.** A journal the
294 daimon can rewrite is not a journal.
2953. **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
2994. **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
306The compartment is Landlock **and** seccomp, and neither is optional. Landlock
307governs opening a file, which leaves two measured escapes it has no way to
308express: the metadata calls have no access right, so a command could world-write
309a file inside the denied subtree; and `connect()` to a pathname unix socket is
310ungoverned below ABI 9, so a command could reach the session bus and start a
311process that was never fenced at all. `src/seccomp.rs` refuses both by syscall
312number. It is a deny-list and says so — it removes named capabilities from a
313command rather than sandboxing it.
314
315The launcher does three things before `execve` and the order is not negotiable:
316
3171. **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.
3202. **Apply the fence.** Doing so *opens every granted path*, so Landlock has real
321 work left after its own rules take hold.
3223. **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
327Both are irreversible, both survive `execve`, and both need `no_new_privs`. A
328machine that cannot do either refuses the command; there is no degraded mode.
329
330## The finding that changes the ssh plan
331
332Measured on this machine at Landlock **ABI 8**: **pathname unix sockets are not
333governed until ABI 9 (Linux 7.1)**. Landlock alone therefore lets a fenced
334command `connect()` to a socket file whose path is outside the fence — the
335session bus, the X11 socket, a container daemon, and **`ssh-agent`**.
336
337The filter closes this by refusing `socket(AF_UNIX, …)`, which every one of those
338needs first, so what follows is the reasoning that made the refusal
339unconditional rather than a problem still open.
340
341That 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
343key, offer `ssh_run(host, argv)` against an allow-list, and the daimon would
344never see the key at all. **A reachable agent socket defeats that entirely.** A
345command that can talk to `ssh-agent` can sign with the user's keys, to any host,
346without the key ever being read — which is precisely the property that made
347agent forwarding dangerous in the first place.
348
349So `ssh_run` cannot be built on the fence alone, on any kernel below 7.1. It can
350be built on the fence *and the filter*, which is why the filter refuses `AF_UNIX`
351for every command rather than only for one that was already denied the network:
352reaching the agent has nothing to do with whether the command was allowed to
353fetch a crate. The cost is that a command cannot use any local socket it names —
354a database, a container daemon, X11, an agent-authenticated `git fetch` — and
355that cost is stated in `--report` rather than discovered.
356
357**Decide the rest of `ssh_run` before building it, not during it.** The remaining
358options are unchanged: rely on `SSH_AUTH_SOCK` being absent (weak — the path is
359guessable, and the filter is what actually stops it today); or hold ssh behind a
360capability the hand executes in a *separate*, more tightly fenced process.
361
362## Deliberately not done
363
364This once said "no pty", and the paragraph is kept because the reasoning still
365explains the shape of the wire: `Req::Exec` is the simple, non-interactive case
366and it covers nearly everything an agent does. A terminal turned out to be one of
367the cases that hurt, so `Req::Open` exists alongside it, with its own messages
368rather than a flag — almost nothing is shared, and keystrokes are never
369journalled, because the questions a program asks a terminal are `sudo` wanting a
370password and `ssh` wanting a passphrase.