017: a pin the kernel can enforce - #15
Merged
Merged
Conversation
An `exec.allow` entry prefixed `!` grants a file, not a path. The
compiler carried the pin, user space re-checked it before spawn, and 016
built the entire external-scanner grant on it — a binary merely on PATH
is not a grant, only a pinned entry is.
The kernel could not express it. `plan_exec` refused outright:
"eBPF backend cannot enforce inode-pinned executable". Correct
fail-closed behaviour for a backend that matches resolved paths, and
invisible until now because the two layers never met in one test. On the
6.8 VM the consequence was total: a pinned entry made the episode
infra_error in enforce AND observe, an unpinned one made `scan` report
"not granted" — mutually exclusive, so the external tier only ever ran
under --host, where no plan is built. Meanwhile the 016 unit test
asserting every compiled entry is pinned passed happily.
The exec hook now reads the identity of the image it is being asked to
load — file → f_inode → {i_ino, i_sb → s_dev}, three direct loads off a
pointer it already holds — and a pinned rule is decided on that alone.
The path rides along for the audit line and does not participate; a pin
that also matched its own path would silently degrade to a path rule the
moment the file it named was replaced.
That makes the pin stronger than the check it replaces. Stat-then-exec
is a TOCTOU window; deciding at bprm_check_security is a decision about
the very image being loaded. `exec-pin-swapped-denied` proves it: the
granted image runs, an `mv` replaces it mid-episode, the same path is
refused.
The offsets join the boot-time BTF guard, so a kernel whose layout
disagrees refuses to enforce rather than reading a wrong field.
38/38 on the VM (was 35/35).
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
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.
Opened because 016's last VM case could not be written. Writing it uncovered why: a scanner grant was uninstallable under enforcement, so bee's whole external-scanner tier only ever ran under
--host.The gap
An
exec.allowentry prefixed!grants a file, not a path. 016 built the external tier on exactly that — a binary merely onPATHis not a grant; only a pinned entry is (FR-008). Butcrates/userspace/plan.rsrefused any policy containing a pin, because the in-kernel exec allowlist matches resolved paths and cannot express identity.Measured on the 6.8 BPF-LSM VM:
exec.allow = ["!…/opengrep"]infra_error: eBPF backend cannot enforce inode-pinned executable— episode never starts, inenforceandobserveexec.allow = ["…/opengrep"]scanreportsdid not run: … is not granted to this episodeMutually exclusive. And the layers disagreed silently: 016's
the_compiled_exec_surface_is_bee_plus_the_granted_scanner_and_nothing_elsepasses, asserting every compiled entry is pinned — the kernel then refused that same policy. Only a live case could show it.What this does
The hook decides on identity.
bprm_check_securityreadsfile → f_inode → {i_ino, i_sb → s_dev}— three direct loads off a pointer it already holds, the same mechanismf_modeuses — and a pinned rule matches on(ino, dev)alone. The path rides along for the audit line and does not participate: a pin that also matched its own path would degrade to a path rule the moment the file it named was replaced, which is the one thing a pin exists to prevent.That is stronger than the check it replaces. Stat-then-exec-by-path is a TOCTOU window. Deciding at
bprm_check_securityis a decision about the very image being loaded.The offsets join the boot-time BTF guard, so a kernel whose layout disagrees refuses to enforce rather than reading a wrong field.
PlanError::UnsupportedInodePinis gone, replaced byUnidentifiedPin— a pin that arrives with no resolved identity is refused, never installed as a loose path rule.The second half of the grant
Making the pin installable was not enough, and the matrix said so: with the grant in place the scan still failed with
cannot read …: Permission denied.:project_root/.beeis a default protection — bee's own state is denied to tool children — and the two-child pipeline exchanges its SARIF report inside it. Child 1's write and child 2's read were both refused, invisibly under--hostwhere nothing enforces the deny.So a resolved scanner grant now widens the policy by exactly
:project_root/.bee/scan, in the same place and the same way a skill grant does. More-specific-wins carves outscan/;.bee/findings— the ledger and its human verdicts — stays denied.Proof
Matrix 38/38 (was 35/35), 2026-07-28:
exec-pin-allow— pinned image runs, unpinned sibling deniedexec-pin-swapped-denied— the granted image runs, anmvreplaces it mid-episode, the same path is then refused. The original is proven to run first, so the case cannot pass by breaking its own setupscanner-escape-denied— 016's T068, finally: an enforcing scan episode's scanner child is refused an out-of-scope read, with a control run that leaks the same value when nothing forbids itTwo findings from building that last case are recorded in the spec's Edge Cases rather than papered over:
/dev/nullis an unlisted write in any scope that declares a write grant (the stand-in's own2>/dev/nullfailed, which reads as a failed read); and nothing grants bee itself, so an enforcing scan policy must carry bee's entry beside the scanner's — true since 016, but--hostnever needed it.cargo fmt/clippy -D warningsclean; full workspace suite green.🤖 Generated with Claude Code