Contributing to kern
September 19, 2026 · View on GitHub
Thanks for considering a contribution. kern is security-critical (it runs untrusted images as a sandbox), so the bar on the sandbox/OCI paths is high, and the tests are the proof.
Before you start
- CLA required. All contributions are under the CLA (a bot will ask on your first PR). This keeps relicensing/stewardship options open for the project.
- Read ARCHITECTURE.md. Match the surrounding code's idioms.
Workflow
cargo build
cargo test # unit + integration + characterization (skip-graceful for HW)
cargo clippy --all-targets -- -D warnings
cargo fmt --check
CI runs the above on x86 and on a native aarch64 runner, plus cargo-audit / cargo-deny. The
specific boards (Pi, Jetson, UNO Q) are also validated by hand; hardware-dependent tests skip
gracefully when the precondition is absent.
Tests are not optional
- Unit tests go inline (
#[cfg(test)] mod tests) next to the code (private logic). - Integration/CLI tests go in
crates/<crate>/tests/. - Anything touching the sandbox path must keep the characterization assertion (recorded mount/pivot sequence) green AND, where it changes behaviour, add/keep a real-syscall correctness test (escape-blocked / canary-unreadable).
- Security fixtures must be synthetic, minimal, and self-contained: no private paths, no
real-world exploit payloads. See
kern-oci's symlink-escape regression for the template.
Harness traps that have produced a wrong answer here
Every entry below is a mistake this project actually made, caught only because something else contradicted it. They are listed as environment facts, not as advice, because each one produced a confident wrong answer first and a correction second.
-
trim_start_matches/trim_end_matches/trim_matchesstrip AS MANY TIMES AS THEY FIND, which is almost never what a parser means. Reach forstrip_prefix/strip_suffixwhenever the thing being removed is a SEMANTIC marker rather than padding. Removing repeated/,., spaces or quotes is fine and is what most of the uses here do. Removing a word is not: measured, and each of these accepted a malformed value as a well-formed one, in silence.x-kern-x-kern-vcpu read as the x-kern-vcpu key on-failure:on-failure:3 read as a clean retry count of 3 0o0o755 read as mode 755This is the same defect three times, and it is a grep. It belongs with the other parsers that accepted an input they did not understand and produced a value that did not correspond to it (
parse_binary_sizeon31.2G,split_top_commason an escaped quote): the fix is never a wider accept, it is refusing the input or letting it fall onto the path that already reports it. -
Do not bump a caret dependency to the patch you just published.
^0.2.12already resolves 0.2.13 (a caret on a zero-major version allows>=0.2.12 <0.3.0), so the bump buys nothing and costs a window: npm takes minutes to serve a new version, and CI rannpm installinside it and went red withETARGET: No matching version found for kern-sandbox@^0.2.13. Measured both halves, and again a week later: the publishedkern-pistill declares^0.2.12and now installs 0.2.25, which is the rule working rather than an example aging. Raise the floor only when the dependent needs something the older version does not have, and then publish, wait for the registry, and push after. -
Inserting code just above a
def,fnorclassinserts it BELOW whatever documents that item. Five times in one day: a new test took the@integrationmarker off the test it was inserted above (which then ran in the job that has no kern binary and failed there, not here); a new helper tookapply_deploy's doc comment, which also still described the behaviour the same commit had changed; a test took another test's essay; a function tookclass Kernel's JSDoc; and athread_local!seam tookfn warn's doc, which-D warningscaught asunused doc commenton a macro invocation. The rule: insert after the complete unit (doc + decorators + item), and when a diff moves a doc block, read what it now says about the code beneath it. -
Count kern's processes by
readlink /proc/<pid>/exe, never by a pattern on the command line.pgrep -f kernand agrepover/proc/*/cmdlineboth match YOUR OWN shell, because the pattern you are searching for is in the command you typed. Measured three times in one session: 92 "kern processes" on an idle machine, and a Chromium helper reported as a sandbox escape that had outlived its box (its cmdline carried the marker string from the tool call that wrote the script). The census that works:for p in /proc/[0-9]*; do [ "$(basename "$(readlink "$p/exe" 2>/dev/null)" 2>/dev/null)" = kern ] && echo "$p" done | wc -lAnd when the thing you are looking for is inside a box, put a token in it that is generated at RUNTIME, so no command line on the machine can contain it. Then prove the census can see the process while it is alive, before believing a zero after it dies.
-
One
XDG_RUNTIME_DIRper worktree, or the registry is shared. kern keeps its registry, instance dirs, netns handles and exit files under$XDG_RUNTIME_DIR/kern, so two builds on one machine write to the same place by default. Measured: another worktree's debug build cleared instance records under a running compose stack, after whichcompose downtruthfully reportednothing was runningabout boxes that were still up, andkern psprinted the orphan warning naming them. Nothing was wrong with either binary. Before running the compose or residue suites:export XDG_RUNTIME_DIR=/run/user/$(id -u)/kern-$(basename "$PWD") mkdir -p "$XDG_RUNTIME_DIR" && chmod 700 "$XDG_RUNTIME_DIR"A red in those suites that you cannot explain is worth re-running isolated before you debug it.
-
A green test proves nothing until you have seen it go red. Sabotage the fix and watch the test fail. If it stays green, either the test or the sabotage is blind, and you do not yet know which.
-
A sabotage needs its own positive control. Leaking the outer
readlinein the MCP serve loop left the memory test green and looked like a blind test. The flood is consumed by the drain loop, so that sabotage never produced accumulation. Verify the sabotage broke the thing before reading the test's verdict. -
ru_maxrssof a forked child inherits the parent's peak. A test that holds the payload in a Python string measures the parent and reports a number that scales beautifully with the input while proving nothing. The control that exposes it: a child that reads nothing reports the same figure. Hand the payload over as a file descriptor and compare two sizes, so the inherited baseline cancels. -
A differential measurement needs both points on the plateau. 25 MB against 400 MB spans a ramp and reports real growth as if it were slack. Find the knee first, then pick two points past it.
-
io.StringIOdoes not exercise the encode path. A lone-surrogate defect is invisible in-process and appears only against a real subprocess, or against a stream whosewriteactually encodes. -
Under
LC_ALL=Ca search for the em-dash U+2014 silently returns zero. UseC.UTF-8and prove the search works with a positive control that returns a known hit. -
Querying git history by a stale path answers for a file nobody has touched. This repo has moved files; verify the path is current before believing "last changed 10 months ago".
-
pgrepwithout a unique marker matches your own shell. Use a marker, and prove the detector is looking with a canary process that must be found. -
Delete
__pycache__, or usepython -B, between sabotage runs. A same-length, same-second restore has already executed stale bytecode whilediffreported no change. -
A gate's exit code is read bare. Never through a pipe. See the
no-em-dashandstale-numbersinvocations. -
THE COMMONEST ONE HERE, and it has a name: reading a number before the thing it measures has happened. Five of the seven wrong answers in one measurement session were this single shape, wearing a different costume each time, which is why it is worth naming rather than listing. A timing loop ran
kern box NAME --rm ...and timed a usage error, because--rmis not a flag kern has; the figure was a clean 1.5 ms and a plausible box start. On the GPU branch, a probe computed(size_t)atof("0.05") * 1 GiBand allocated zero bytes, so eleven cases reported "no device" on a card with 14.5 GiB free; a concurrency floor was read frompgrep, which counts processes that exist but have not allocated yet, and produced twelve false dips; a tok/s median was taken over a single sample and read 30% noise as a regression; and a watchdog was armed after the first call it was meant to guard, which is itself the call that hangs, so the deliberate deadlock hung the program before the watchdog existed. The defence is one question asked before the number is believed: did the thing I am timing actually happen? Print the exit status of every timed command and assert it. Print the value the probe computed, not the value you passed it. Assert a positive control that must produce a non-zero reading, and a negative control that must produce none. -
An A/B needs a null control: the same binary in both columns. Without it an ordering bias in the harness reads as an effect of the change. Measured here:
kern doctortimed against ITSELF gives +300 us [-46, +512], so a +553 us "regression" attributed to a new branch was not resolvable from the harness, andstracethen proved that branch had never run. Run the null control on the same workload, at the same sample size, and report the effect against it.scripts/ab-measure.pydoes all of this and refuses the null control unless you declare it.
Shipping a claim about a boundary
These three rules were paid for by the GPU work and lived only in its commit messages, which is where a rule goes to die. They apply to any claim about what kern enforces, not to GPUs.
Read this before reading crates/kern-cli/src/gpu.rs, or that file will look out of proportion.
It is 915 lines and it prints two strings, on a command whose GPU row most users will never have a
GPU to trigger. That ratio is not an accident and it is not scope creep: the GPU work is the TEST
CASE for the three rules below, and the rules are the deliverable. A capability tier is the smallest
honest thing kern could ship about GPUs, which makes it the cheapest place to find out whether a rule
like "ship a tier only if the code can assign it" survives contact with real hardware. It did not
survive intact, and that is the useful part: the model has three tiers and the code has two, because
the measurement that would have earned the middle one failed.
What the phase actually produced, in order of how long it will matter:
- These three rules, and
scripts/stale-numbers.pymaking them mechanical rather than aspirational. pentest/pentest-gpu-claims.sh, which is the shape of a suite that attacks a CLAIM instead of a mechanism. Nothing about that shape is specific to GPUs.- The GPU tier itself, which is the least of the three and the only one a user sees.
Three reviews across three rounds found ten real defects in this work, and every one of them was the same class: a sentence or an exit code that said more than had been measured. Not one was a runtime bug. If you are about to change something here, that is the failure mode to expect from yourself.
A tier, a mode or a guarantee ships only if the code can assign it on hardware someone can reach.
The GPU model has three capability tiers and the code has two, because the measurement that would
earn the middle one failed: dmem accounts device memory and does not enforce it for the compute
path a tenant allocates through. The variant was removed rather than shipped weak. A level nobody can
be awarded is not completeness, it is a promise in the enum.
The test is not "is it verified", it is "what happens when it is wrong". A branch that can only fail downward, granting less than the hardware deserves, is acceptable untested and says so. A branch that can fail upward, granting more, does not ship until it cannot.
When a defence is not a boundary, the demonstration that it is not goes in the repo before the
announcement. pentest/pentest-gpu-claims.sh publishes the result that defeats a userspace VRAM
quota, on the same day the tier that depends on it was written. Finding your own defeat costs one
paragraph; having a reader find it costs the credibility of every other number you have published.
A claim and the code that prints it are held together by a gate, not by the discipline of whoever
edits next. scripts/stale-numbers.py refuses a document that names a tier the code cannot print,
requires each tier's caveat verbatim on the pages that carry it, and compares the forbidden
vocabulary between the Rust gate and the shell one, because the shell cannot import a Rust constant
and a duplicated derived condition with no gate on it drifts. Every arm of that gate has a sabotage
test: break the thing on purpose, watch it go red, restore it. A gate nobody has seen fail is a gate
nobody knows works.
Changing a flag or config key (deprecation policy)
The CLI/config surface isn't frozen pre-1.0, but changes still must not break a user's scripts without warning. This is blocking on review, same as tests:
- Rename with identical semantics → keep the old name as a deprecated alias. Parse it to
the same
Commandfield and emit a single stderr warning (warning: --old is deprecated; use --new). Keep it for ≥ 2 minor releases, then remove. Record it under Deprecated in the CHANGELOG when introduced and Removed when dropped. - Rename/repurpose with divergent semantics → do not alias it (a silent reinterpretation
corrupts behaviour). Reject the old name with a
Usageerror that explains the difference and names the replacement. The--memory-swap→--memory-swap-maxrejection incli.rsis the reference implementation; mirror its message shape (X is not supported (why); use Y). - A new flag must land with a parser test asserting it populates the right
Commandfield, and a rejection must land with a test asserting theUsageerror (seecpu_ram_flag_freeze).
Before a tag: the acceptance matrix
sh scripts/acceptance-matrix.sh <path-to-kern> runs every compose lifecycle transition in both
network modes and checks the three things the unit suites do not: that the OUTPUT agrees with the
state (a line naming a pod on a stack that has none is a failure, not cosmetics), that a payload
crosses with NO settling time (a bare connect cannot see a stale relay; only bytes back can), and that
down leaves nothing behind in processes OR on disk, counted by pid rather than by process name.
It was written after four defects in one release cycle were found by an independent test rather than
by this repo's own tests, and all four had that shape. --self-check exercises its own assertions
against fixed strings, so a matrix that cannot fail is caught before it is trusted.
Run it against the binary a tag will actually publish, not against cargo build --release: this
project has twice measured the wrong artifact that way.
Documentation has a gate too
Prose is checked the same way code is, mechanically, before the commit:
python3 scripts/no-ai-slop.py # every tracked .md; exit 1 on a hit
grep -rlP '\x{2014}' --include='*.md' . # the em-dash: must print nothing
no-ai-slop.py refuses a fixed list of markers that read as machine-written: delve, leverage,
seamless, robust, comprehensive, utilize, not only X but also Y, sentence-opening
Furthermore, and the rest. It ignores anything inside a code span, a link target or a fenced block,
because a word in backticks is a symbol and not the document's voice. The reason it exists is
commercial rather than aesthetic: readers who see one of those words decide a model wrote the page
and stop, before checking a single measurement.
Run sh scripts/gate.sh, not the commands by hand
It runs what CI runs, with the flags CI runs them with, and prints the clippy version it used.
The reason it exists is one line: cargo clippy without RUSTFLAGS=-D warnings exits 0 on a tree
that fails CI. The lint is still printed, as a warning, and the exit code says nothing
happened.
git add before you run it. Several gates read TRACKED files, so a file you have just written is
invisible to them. This script failed CI on the commit that introduced it, for a marker inside itself,
after passing by hand while still untracked. It now says so when the working tree has untracked
files.
That cost two red CI runs on a release branch inside one hour, both from typing the command by hand
and dropping the flag, and both times the local answer was a confident green.
Run rustup update stable before trusting a green clippy
CI pins stable, which means whatever stable is on the day it runs. A local toolchain one release
behind runs the same command and reaches a different verdict, and the direction is the dangerous one:
the newer clippy has more lints, so LOCAL IS THE WEAKER GATE.
Measured, and it cost a red CI on a release commit: clippy 0.1.98 of 2026-08-18 accepted an empty
line between a doc comment and the item it documents, and 0.1.98 of 2026-09-01 refuses it with
empty line after doc comment. Same version number, six weeks apart, opposite answers.
So cargo clippy passing here is not evidence until the toolchain matches. The check that settles it
is a positive control: reintroduce the thing CI rejected and confirm your clippy now rejects it too.
Progress goes through progress!, never eprintln!
kern and a box's workload share one stderr. A progress line written with a bare eprintln! therefore
lands in whatever is reading that stream: a shell pipeline, kern logs, or an agent's context through
the SDK, where an external audit found six -> layer ... lines sitting in front of the program's own
output inside a LangChain tool result.
kern_common::progress!("-> resolving {image}"); // kern-cli, kern-oci
crate::progress!("-> publishing {hp} -> box :{bp}"); // kern-isolation, which is libc-only by design
Both write only when stderr is a terminal, which is the rule the kern box status panel already
followed and the pull path never did.
A diagnostic is the other half of the rule, and it must carry kern: . That prefix is the only
thing the SDK has to tell kern's voice from the workload's on a shared stderr, so a bare
warning: bound 0.0.0.0 reaches an agent's context as a line the program printed. Three such lines
were live when this was written; five more used kern compose:, which matches neither the benign list
nor the failure marker.
python3 scripts/progress-is-tty-gated.py enforces both halves, exhaustively, inside the modules an
SDK caller's stderr is actually made of: in those files every eprintln! is one or the other. It
reads the whole macro call rather than one line, because the site that first escaped had its format
string on the following line.
It is scoped rather than global, and the scope is the judgement. Its first version instead matched
a set of leading markers (->, OK, layer). An independent test pointed out that this freezes today's
punctuation rather than the rule, and they were right: rescoping it immediately found seven more
progress lines written as [1/3] FROM ... and [cached - ...], plus the three unprefixed
diagnostics. A global rule is not reachable statically, because 55 eprintln! calls in the workspace
print a variable with no literal to inspect. --audit lists what it cannot check instead of passing
it in silence.
Errors, warnings and kern: note: advice are NOT progress. A pipe is exactly where those must
still arrive. And the two mechanisms are NOT layers over one problem: the TTY gate closes progress at
the source, and for the warning class the kern: convention is the only mechanism there is.
A changelog entry is not a commit message
The commit explains how a defect was found and why the fix is shaped that way. That is the right place for it: the reader is whoever maintains this next, and the diagnosis is the content.
The changelog has a different reader, deciding whether a version changes anything for them. So an entry answers that in its first line and stops. What went wrong, which review caught it, and how it was verified do not belong there, because a reader scanning for "does this affect me" has to walk past them to find out.
The measure that made this a rule: the changelog went from 7227 words to 1034 without losing a fact a user needs. Everything cut was already in the commits. If an entry runs past about eight lines, it is usually telling the story rather than the change.
Behaviour changes are the exception in one direction only: they lead with the symptom, in the words of someone it happens to, and they say how to keep the old behaviour on purpose.
Reporting security issues
Do not open a public issue, see SECURITY.md.
Scope reminder
GPU limits are not shipped, so a report about their strength has nothing to land on yet. See
SECURITY.md for what is and is not a boundary today.