Skip to content

profile: expand ${HOME} in path fields - #201

Merged
congwang-mk merged 6 commits into
mainfrom
profile-home-expansion
Aug 18, 2026
Merged

profile: expand ${HOME} in path fields#201
congwang-mk merged 6 commits into
mainfrom
profile-home-expansion

Conversation

@congwang-mk

Copy link
Copy Markdown
Contributor

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 ~/src in 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 $HOME when 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.

Form Meaning
${HOME} the home directory
$$ a literal $
${OTHER} error, unknown variable
${home} error, lookup is case-sensitive
${}, ${HO-ME} error, malformed name
${ with no } error, unterminated
$ anywhere else error, write $$ or ${HOME}
leading ~ error, write ${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].exec and cwd, and [filesystem].read/write/deny/chroot plus both halves of each mount entry (expanded after the VIRTUAL:HOST split, so a resolved value containing a colon cannot be read as a separator). Never [program].args or [program].env, where a $ belongs to the sandboxed program.

Round trips

Expansion lives in parse_input, never in Deserialize, so learn --merge can 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}/.config no longer appends this machine's absolute path beside the variable.

learn and inspect --toml escape literal dollars on output, since observed paths come straight from the kernel and a real file named foo$bar would 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, so learn can never produce one.

Testing

  • 782 lib tests, 439 integration tests, 86 sandlock-cli tests, 405 Python tests, all passing.
  • New: shared fixture cases in both languages, home-resolution precedence, field scope (args untouched), mount-spec halves, an end-to-end test proving a ${HOME} grant reaches a running sandbox, and a learn --merge round trip asserting the variable survives without a duplicate.

🤖 Generated with Claude Code

@congwang-mk
congwang-mk force-pushed the profile-home-expansion branch 2 times, most recently from ebff9ae to b9ae1f2 Compare August 18, 2026 00:41
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
congwang-mk force-pushed the profile-home-expansion branch from b9ae1f2 to cbc23db Compare August 18, 2026 01:05
@congwang-mk
congwang-mk merged commit 1f2ea34 into main Aug 18, 2026
17 checks passed
@congwang-mk
congwang-mk deleted the profile-home-expansion branch August 18, 2026 01:14
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Support variable expansion in profiles

1 participant