The file workspace
Files are resources like anything else. urn:file:{path} is bound with a URI template, so
one binding covers a whole tree, and reading a file is a Source — the same verb as
everything else.
The jail
The tree is $IKIGAI_FILES, else ~/.ikigai/workspace. The CLI creates it if it is
missing, and since ikigai-cli 0.1.41 it creates it private (0700) whatever your umask;
a directory that already exists is left as you made it. The file endpoint never creates its
own root: a root it cannot open serves nothing.
It is deliberately a dedicated, ikigai-owned sandbox and deliberately not your home directory or your documents. Even the owner’s root capability reaches only inside this tree, and the file endpoint’s jail is a hard floor regardless of capability — a second, independent check, because one mechanism guarding your entire filesystem is one mechanism too few.
“Hard floor” is a claim about the file actually opened, not about the path you typed, and
it holds in that sense since ikigai-fs 0.1.8 (ikigai-cli 0.1.40). Before that the jail
judged the path you named, and a symbolic link, or a swap between the check and the open,
could put a different file behind it. Now:
- The path is opened one name at a time, each from an open handle on the directory before it, and the read or write happens on the handle that walk produced, so nothing can be swapped in between the check and the use.
- A symbolic link anywhere in the path is refused: the last name or a directory on the way, pointing inside the tree or out of it.
- A file has one name, its path as it is spelled on disk.
./notes.txt,a//banddir/are refused, and the error names the spelling to use (notes.txt); so isNOTES.txtfornotes.txton a volume that folds case, as macOS does by default...and absolute paths never leave the tree. - On Windows there is no confined backend yet, so the file endpoint refuses every request rather than serve one unconfined.
Two mechanisms, on purpose
It is worth naming why the jail exists in addition to capabilities, since capabilities are supposed to be the authority model:
- Capabilities decide what a given caller may do —
urn:cap:fs:read:ws/abc123grants read under one segment, and attenuates as it passes down a call chain. - The jail decides what the endpoint can reach at all, and no capability can widen it.
A bug in capability minting is then a bug about which files inside the sandbox were reachable, not a bug about whether your SSH keys were.
Segments
Capabilities are usually scoped to a segment of the workspace rather than the whole
thing — ws/{id}, where the id is derived from an identity.
There is a running demonstration of that, and it is not in this repository: it is
ikigai-web-demo, the kernel-as-WebAssembly
page from Running it, at
https://ikigai-rs.github.io/ikigai-web-demo/. hello-camel has no identity of any kind
(nor does Part II’s loadable-module, when you get there), so nothing you have built so far
can show you this.
Open that page’s Identity tab and sign in with a passkey: the credential yields a stable client id, and the id scopes a private workspace segment. The tab then offers three steps, and the third is the one to run — you try to write to somebody else’s segment and the resolver refuses. The boundary is the capability model, doing the one job it exists for.
Reading files through the kernel, not std::fs
⚠ Inside a module or endpoint, read files by resolving
urn:file:…rather than callingstd::fsdirectly.
Two reasons, and the second is the one that catches people:
std::fsbypasses the jail and the capability check entirely.- A kernel read is golden-threaded — a filesystem watcher can cut that thread when the
file changes, so anything derived from it recomputes. A
std::fsread is invisible to the kernel, so a cached result built on it goes stale silently.
It also keeps your code able to run in WebAssembly, where there is no filesystem to call in the first place.
Try it
At the REPL (ikigai), or one line at a time with ikigai --plain -c '…':
ikigai> sink urn:file:notes.txt a note
wrote 6 bytes to notes.txt
[uncacheable]
ikigai> source urn:file:notes.txt
a note
[computed]
ikigai> exists urn:file:notes.txt
true
[computed]
ikigai> exists urn:file:nothing-here.txt
false
[computed]
exists is the Exists verb (ikigai-cli 0.1.46 and later): is the resource there, asked
without reading it. Its arguments route the way source’s do, and it takes no pipe, because
the question is about one resource.
The rest of a sink line is the content verbatim (spaces, JSON, Turtle and quote marks
kept), with one exception since ikigai-cli 0.1.46: content that is exactly one quoted
token is unquoted, by the same rules as content="…". So a quoted note is stored without its
quotes; to store the quote marks, escape them. Only the double quote is a quote to the REPL,
so single quotes are stored as typed, and so is anything that is not one quoted token:
ikigai> sink urn:file:notes.txt "a note"
wrote 6 bytes to notes.txt
[uncacheable]
ikigai> source urn:file:notes.txt
a note
[computed]
ikigai> sink urn:file:notes.txt "\"a note\""
wrote 8 bytes to notes.txt
[uncacheable]
ikigai> source urn:file:notes.txt
"a note"
[computed]
ikigai> sink urn:file:notes.txt 'a note'
wrote 8 bytes to notes.txt
[uncacheable]
ikigai> sink urn:file:notes.txt "a" and "b"
wrote 11 bytes to notes.txt
[uncacheable]
ikigai> source urn:file:notes.txt
"a" and "b"
[computed]
A sink with no content on its line takes its content from a pipe, so
printf 'a note' | ikigai --plain -c 'sink urn:file:notes.txt' stores a note. Under -c
it waits at most two seconds for stdin’s first byte (ikigai-cli 0.1.46 and later): a
stdin nobody writes to, such as an editor’s or an agent’s inherited pipe, then sends the sink
with no content at all, never an empty one, and says so on stderr. The file endpoint
requires content, so that sink is refused and the file keeps what it held. A stdin that is
empty and closed (< /dev/null) is different: it answers at once, and its answer is an empty
body.
Then look in ~/.ikigai/workspace: it is an ordinary directory. Nothing about the model
requires the storage to be exotic; it requires the access to be named.
Files as bodies
The transcripts above are replayed against the book’s pinned ikigai-cli 0.2.6, which
links ikigai-fs 0.3.0. Until ikigai-fs 0.2.3 (ikigai-cli 0.2.3) a file read answered
one buffer; from it a file is a body (Streams, I) on the native backend:
- A
sourceanswers a seekable body backed by the file, read 64 KiB at a time. A reader that streams it (a pipe into a stage that reads a body, an HTTP download) holds a chunk, not the file, and is not held to the 16 MiB a buffered file read is; one that collects it is bound by its run’s byte budget instead. A file that changes while it is read ends in a conflict, never a short or mixed body. - The body states a strong validator: the content id of its bytes, computed on the
first read that asks for it and remembered against the file’s version, so an HTTP door
can send an
ETag, answer304and serve byte ranges (206) for a file. A file changed in the last two seconds states none yet, and until it does the door serves no ranges for it. - A
sinkstreams its content into the file, a chunk at a time and never collected first, sosource urn:file:big.log | sink urn:file:copy.logcopies a file without holding it. A sink that fails part-way leaves the target as it was. - Each root has a write ceiling, 64 MiB unless the host sets another
(
FileEndpoint::max_write). Asinkpast it is refused, never truncated, and the file keeps what it held.
The browser’s localStorage backend still answers and stores buffers.