profile: expand ${HOME} in path fields - #201
Merged
Merged
Conversation
congwang-mk
force-pushed
the
profile-home-expansion
branch
2 times, most recently
from
August 18, 2026 00:41
ebff9ae to
b9ae1f2
Compare
Profiles are not portable between machines: a grant under the author's home has to be hand-edited wherever the username differs. CLI flags avoid this because the shell expands them first, but a profile has no shell. This adds the grammar only, not the wiring. Every unrecognised form is a hard error so that adding variables later cannot silently reinterpret a grant that exists today. The fixture is shared with the Python loader so the two implementations cannot drift. Signed-off-by: Cong Wang <cwang@multikernel.io>
Expansion runs in parse_input, the ProfileInput to Sandbox step, rather than
during deserialization. Keeping it out of Deserialize is what lets
'learn --merge' read a profile and write it back with the author's ${HOME}
still spelled as a variable.
Home resolution is lazy so a profile that uses no variables still loads on a
host where home cannot be resolved. Mount specs expand after the VIRTUAL:HOST
split so a resolved value containing a colon cannot be read as a separator.
Signed-off-by: Cong Wang <cwang@multikernel.io>
A merge compared raw strings, so observing an access under ${HOME}/.config
appended this machine absolute path alongside the variable. Repeated merges
would fill a shared profile with machine-specific duplicates and undo the
portability the variable exists to provide.
Dedup now keys on the expanded path while the raw entry is what gets written
back. An observed path can carry a literal $ the grammar refuses to expand;
it keys on its raw spelling so an odd filename cannot abort the merge.
Signed-off-by: Cong Wang <cwang@multikernel.io>
The Python loader parses profiles independently of the Rust one, so a profile that expanded under the CLI would otherwise fail to expand under the SDK. Both now run the same grammar against the same shared fixture, which is what keeps the two from drifting. Home resolution stays lazy here too, so a profile with no variables loads even where home cannot be resolved. Signed-off-by: Cong Wang <cwang@multikernel.io>
The strictness is the part worth explaining: every unrecognised form is an error precisely so that adding variables later cannot change what a profile written today grants. Signed-off-by: Cong Wang <cwang@multikernel.io>
${HOME} is a portability macro for the person writing the profile, so it is the
launcher's home and nothing more. Two inputs slip through that reading.
$HOME can be /, which is absolute and so passed the old test, but is nobody's
home: a bare write = ["${HOME}"] would have become a grant over the entire
filesystem, the one grant learn already refuses to emit. It now joins the empty
and relative cases as unusable, falling through to the passwd entry.
Under chroot a path names the jail, not the host, so a host home builds a rule
from a directory the jail does not have and Landlock skips it without a word.
Refuse instead. Nothing is lost: jail layouts are fixed by the image rather
than by the host username, so the portability problem ${HOME} exists to solve
does not arise there.
Signed-off-by: Cong Wang <cwang@multikernel.io>
congwang-mk
force-pushed
the
profile-home-expansion
branch
from
August 18, 2026 01:05
b9ae1f2 to
cbc23db
Compare
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Closes #198.
Profiles are not portable between machines: a grant under the author's home has to be hand-edited wherever the username differs. CLI flags avoid this because the shell expands them first, but a profile has no shell, so
~/srcin a profile is a relative directory named~(a loud failure outside chroot, and a silently weaker policy under it).Path-typed profile fields now expand
${HOME}.Vocabulary
Exactly one variable, resolved by sandlock rather than looked up in the environment by name. No security-policy format in this space does general ambient expansion: firejail uses a fixed braced macro set, AppArmor uses
@{HOME}from policy tunables, systemd uses%h/%u/%U, nono uses a closed set, and NVIDIA OpenShell refuses expansion entirely. Open-ended${VAR}belongs to deployment manifests, where the file is configuration rather than the boundary. A sandlock profile is the boundary, so reading it must be enough to know what it grants.${HOME}resolves to$HOMEwhen set and absolute, otherwise the passwd entry for the real uid. Environment first because the sandboxed program resolves its own~through$HOME: a passwd-derived grant could cover a directory the program never opens while denying the one it does.Grammar
Strict by design. Every unrecognised form is a hard error, which is what makes future additions (
${CWD},${USER}, tilde) non-breaking: they cannot change the meaning of any profile written today.${HOME}$$$${OTHER}${home}${},${HO-ME}${with no}$anywhere else$$or${HOME}~${HOME}$$rather than a backslash escape because expansion runs after TOML parsing, where\$would mean different things in"..."versus'...'strings.Scope
Path-typed fields only: all of
[config],[program].execandcwd, and[filesystem].read/write/deny/chrootplus both halves of eachmountentry (expanded after theVIRTUAL:HOSTsplit, so a resolved value containing a colon cannot be read as a separator). Never[program].argsor[program].env, where a$belongs to the sandboxed program.Round trips
Expansion lives in
parse_input, never inDeserialize, solearn --mergecan read a profile and write it back with${HOME}intact. Merge dedup keys on the expanded path while emitting the raw entry, so observing an access under${HOME}/.configno longer appends this machine's absolute path beside the variable.learnandinspect --tomlescape literal dollars on output, since observed paths come straight from the kernel and a real file namedfoo$barwould otherwise produce a profile the grammar refuses.Parity
Rust and Python parse profiles independently, so both implement the grammar and both are driven by one shared fixture (
tests/fixtures/profile_expansion.toml). Expansion takes the home value as an argument, so no test mutates the environment.Known limitation
A profile cannot express a path whose first character is a literal
~. Write it as./~name. Emitted paths are absolute, solearncan never produce one.Testing
sandlock-clitests, 405 Python tests, all passing.argsuntouched), mount-spec halves, an end-to-end test proving a${HOME}grant reaches a running sandbox, and alearn --mergeround trip asserting the variable survives without a duplicate.🤖 Generated with Claude Code