Skip to content

017: a pin the kernel can enforce - #15

Merged
jlgore merged 2 commits into
mainfrom
017-enforceable-pins
Jul 28, 2026
Merged

017: a pin the kernel can enforce#15
jlgore merged 2 commits into
mainfrom
017-enforceable-pins

Conversation

@jlgore

@jlgore jlgore commented Jul 28, 2026

Copy link
Copy Markdown
Contributor

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.allow entry prefixed ! grants a file, not a path. 016 built the external tier on exactly that — a binary merely on PATH is not a grant; only a pinned entry is (FR-008). But crates/userspace/plan.rs refused 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:

Policy Result
exec.allow = ["!…/opengrep"] infra_error: eBPF backend cannot enforce inode-pinned executable — episode never starts, in enforce and observe
exec.allow = ["…/opengrep"] episode runs; scan reports did not run: … is not granted to this episode

Mutually exclusive. And the layers disagreed silently: 016's the_compiled_exec_surface_is_bee_plus_the_granted_scanner_and_nothing_else passes, 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_security reads file → f_inode → {i_ino, i_sb → s_dev} — three direct loads off a pointer it already holds, the same mechanism f_mode uses — 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_security is 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::UnsupportedInodePin is gone, replaced by UnidentifiedPin — 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/.bee is 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 --host where 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 out scan/; .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 denied
  • exec-pin-swapped-denied — the granted image runs, an mv replaces 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 setup
  • scanner-escape-denied016'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 it

Two findings from building that last case are recorded in the spec's Edge Cases rather than papered over: /dev/null is an unlisted write in any scope that declares a write grant (the stand-in's own 2>/dev/null failed, 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 --host never needed it.

cargo fmt/clippy -D warnings clean; full workspace suite green.

🤖 Generated with Claude Code

jlgore and others added 2 commits July 28, 2026 13:50
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>
@jlgore
jlgore merged commit f399624 into main Jul 28, 2026
4 checks passed
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.

1 participant