feat(bpf): gate exec with bprm_check_security, completing M3's hooks - #4
Merged
Conversation
The `execute` action compiled but never fired. It does now, off `bprm_check_security`, whose `linux_binprm -> file` is the first offset any hook has needed beyond the dentry chain the others already reach through. That offset is applied to the hook's *trusted* context pointer, so it joins `file_f_*` and `dentry_d_*` as one that must stay real under `maybe_poison_offsets`: the verifier range-checks trusted-pointer math against `.rodata` at load time, so poisoning it would abort the load and turn the failure-path test into a load failure instead of the self-test abort it is meant to exercise. `execute` is not implied by `read`. An exec opens the file (FMODE_EXEC|FMODE_READ), so `file_open` already refuses to run the contents of an object that denies reads; what this hook adds is the converse — an object may be readable and not runnable. The subject is the process performing the exec, since at this hook the caller is still its old image. Expressing "readable but not runnable" needs an explicit deny rather than a narrower allow. Copy-down puts the parent's rules in the child's bucket too, so a child rule that does not match the requested action falls through to them; an `allow read` on a child does not withdraw an `execute` the parent granted. The smoke scenario is built on that shape and also pins that the denied binary stays readable, which is the whole point of the hook. The self-test needs a second object to prove this one. Under the deny-all probe rule the exec *open* is refused by `file_open` and the verdict never reaches the bprm hook, so the probe would pass with the hook detached — the exact silent-no-op the self-test exists to catch. The exec probe therefore gets its own object that allows `read` and denies the rest, and its file carries the x bit, without which `may_open(MAY_EXEC)` fails EACCES first. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The plan asked for a note that `listdir` is coarse. The real situation is sharper than coarse, and worth stating as a trap. There is no per-getdents hook, so listing a directory *is* `file_open` on it, and `requested_action` derives only read/write/create from f_mode/f_flags. Nothing ever requests `listdir`. A rule naming it alone therefore matches no request, and the open falls through to default-deny: `allow @tools listdir @secrets` denies the listing it looks like it grants. It fails closed, so this is a usability trap rather than a hole. Policy must spell directory listing as `read` today. Both shipped example policies pair `listdir` with `read` already and now say why, so the keyword cannot be copied out on its own. Making it fire would mean tagging the request when the opened inode is a directory (i_mode & S_IFMT) — one more BTF offset. Deliberately not taken: `read` covers the case correctly, and an unused offset is a maintenance cost on every kernel. Recorded as a candidate follow-up: a `cordon policy check` warning on an action set naming `listdir` without `read` would turn the silent surprise into a lint. Userspace-only and cheap, but a CLI behaviour change rather than an M3 hook, so not bundled here. With this, M3 is complete — every hook in the action-to-hook map fires except `listdir`, which is specified as having no hook by design. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
nikicat
marked this pull request as ready for review
July 27, 2026 23:42
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.
The last hook in M3.
executecompiled but never fired; it does now.What's new mechanically
bprm_check_security, whoselinux_binprm -> fileis the first offset any hook has needed beyond the dentry chain the others already reach through.That offset is applied to the hook's trusted context pointer, so it joins
file_f_*anddentry_d_*as one that must stay real undermaybe_poison_offsets— the verifier range-checks trusted-pointer math against.rodataat load, so poisoning it would abort the load and turn the failure-path test into a load failure rather than the self-test abort it exists to exercise.executeis not implied byreadAn exec opens the file (
FMODE_EXEC | FMODE_READ), sofile_openalready refuses to run the contents of an object that denies reads. What this hook adds is the converse: an object may be readable and not runnable. The subject is the process performing the exec, since at this hook the caller is still its old image.Interaction with copy-down, worth knowing before writing policy: expressing "readable but not runnable" needs an explicit
deny, not a narrowerallow. Copy-down puts the parent's rules in the child's bucket too, so a child rule that doesn't match the requested action falls through to them — anallow readon a child does not withdraw anexecutethe parent granted. The smoke scenario is built on that shape, and also pins that the denied binary stays readable, which is the point of the hook.The self-test needed a second object
Under the deny-all probe rule the exec open is refused by
file_openand the verdict never reaches the bprm hook — so the probe would pass with the hook detached, which is exactly the silent no-op the self-test exists to catch. The exec probe therefore gets its own object that allowsreadand denies the rest, and its file carries the x bit, without whichmay_open(MAY_EXEC)failsEACCESfirst.Verification
VM smoke green, exit 0, zero FAILs. The execute scenario passes all three assertions (granted exec runs, denied exec refused, denied binary still readable). The startup self-test is fail-closed, so the daemon booting at all in every scenario is itself proof the hook mediates.
cargo test131 passed / 0 failed.M3 after this
All hooks ship. What remains is the
listdircoarseness note — documentation, not a hook; a finer one would need the hotfile_permission.🤖 Generated with Claude Code