Rhai mime checks
A mime type is a claim. Leviath checks the claim's spelling wherever a type is
written, and its registry's magic prefixes and extensions decide a type for bytes nobody named.
Nothing asks whether an upload that arrived as image/png is a PNG. For the built-in types
that is a fair trade: a provider handed a broken PNG says so. For a type of your own it is not,
because nothing downstream knows the format at all, and a mislabelled file fails wherever it is
first read rather than where it came in.
So a registry row can name a check: a .rhai file that sees the bytes and the type they claim,
and says what is wrong when something is.
Write one
// checks/scene.rhai
fn check(bytes, mime_type) {
if bytes.len() < 8 {
return "too short to be a scene file";
}
if bytes.extract(0, 4) != "ACME".to_blob() {
return "missing the ACME tag";
}
() // fine
}One function, one contract: return () when the bytes are what they claim, or a string saying
why they are not. The bytes arrive as a Rhai blob, with len, indexing, extract, to_blob and
the rest of the blob API. The type arrives as a string, so one script can answer for a whole
family: a check on image/* sees "image/png" and "image/webp" in turn.
Name it in a row
# ~/.leviath/mime_types.toml, or [mime_types] in a blueprint
["application/x-acme-scene"]
family = "model"
extensions = ["scene"]
check = "checks/scene.rhai"The path is relative to the file that names it: the config's directory for
mime_types.toml and a [mime_types] table in
config.toml, the blueprint's own directory for a row in a manifest. It has to resolve inside
that directory, the same fence every other script gets. lev mime add <type> --check <path>
writes the row for you.
A check resolves like every other field in a row. A subtype inherits its family's check, a row
that names its own replaces it, and check = "" lifts one a broader row put on the type.
lev mime list shows the check each type answers to, and lev mime check <file> runs it and
prints the verdict.
What it covers
The check runs once, where bytes come to rest: in the run's blob store, before anything is written. That one line covers every way bytes reach a run. You never have to remember it at a call site:
| Bytes arriving as | What refuses them |
|---|---|
lev run --attach, an @path in a task, a multipart upload |
the spawn or the message, naming the part and the reason |
a tool result, a script's write_part, context_attach |
the tool's own result, as an [error] the model reads |
a read_file on a file of your type |
the read, so the model learns the file is not what its name says |
| a model's own reply | a line in the reply saying what was dropped and why |
submit_output artifacts |
the submission, like a schema failure |
The reason goes back verbatim to whoever handed the bytes in, so write it for them.
When the check itself fails
A check that throws, runs past its operation budget, or returns something that is neither ()
nor a string is broken. Bytes it could not look at have not passed it, so they are refused,
with the script's own error in the reason. There is no accept policy here as there is for
output validators, because a byte check
is a gate on what enters the run, and a gate that opens when it breaks is not one.
A check is compiled when the registry is built: at daemon boot and on every reload for the
operator's rows, at spawn for a blueprint's. A script that is missing, does not compile, or
defines check with the wrong arity is a load error named by row. lev doctor and lev mime
report it, and the daemon keeps the compiled defaults rather than a registry with a check it
cannot run.
The sandbox
The same hardened engine the validators use: no filesystem, no network, no eval, print muted,
with a larger operation budget (five million) so a byte-by-byte scan of a large file finishes.
The functions every script can call are there;
nothing that reaches outside the process is.