Skip to content

Fix six HIGH-severity sandbox findings + add scope-enforced search tool - #6

Merged
jlgore merged 4 commits into
mainfrom
012-high-severity-fixes
Jul 24, 2026
Merged

Fix six HIGH-severity sandbox findings + add scope-enforced search tool#6
jlgore merged 4 commits into
mainfrom
012-high-severity-fixes

Conversation

@jlgore

@jlgore jlgore commented Jul 24, 2026

Copy link
Copy Markdown
Contributor

Summary

Closes the six HIGH-severity findings from the latest triage run, and adds a search (ripgrep) tool that runs genuinely inside the eBPF scope. All enforce-only behavior is VM-verified on the BPF-LSM kernel (ac-matrix-vm): 32/32 matrix cases pass.

Security fixes (all verified against the post-consolidation tree)

Finding Fix
f028 privileged-target bypass PR_SET_NO_NEW_PRIVS in pre_execsh -c can no longer reach a setuid/fcaps binary
f026 cgroup migration eBPF hooks walk cgroup ancestors (resolve_scope) instead of exact-match, so a migrated process stays enforced
f010 privilege retention tool child drops every capability except the DAC pair (CAP_DAC_OVERRIDE/CAP_DAC_READ_SEARCH) — no CAP_SYS_ADMIN/CAP_BPF to detach bee's own LSM
f030 ambient credential leak tool children inherit an env allowlist (env_clear + PATH/HOME/LC_*), not a four-name denylist
f020 workdir traversal scenario workdir paths contained under an operator-only --workdir-root; symlink/../absolute escapes refused
f018 provider-key exfil provider.api_key_env constrained to the provider-key namespace, enforced at model construction so batch can't skip it
f022 enforcement outliving teardown scope teardown kills the whole cgroup subtree before detaching the engine; host-mode children get their own process group

The DAC-cap nuance was caught by VM testing: dropping the whole capability set while keeping uid 0 made root unable to traverse a 0700 dir, so the kernel denied at DAC before bee's LSM hook ran — breaking the "LSM is the file-access arbiter + complete audit" invariant. Keeping the DAC pair (and dropping SECBIT_NOROOT, letting the bounding set bound the post-exec child) fixes it while still stripping every escape-enabling capability.

search tool (ripgrep, scope-enforced)

ripgrep-api is a pure-Rust library, but running it in the harness would search around the sandbox (harness is root, outside the scope cgroup). Instead the tool execs a hidden bee search-worker subcommand through the same run_child path as every other tool — the worker joins the scope cgroup and every file the library opens passes through the file_open policy. Default tool; narrow arg surface (no ripgrep command-executing options exposed).

Verification

  • Host: full test suite (34 binaries), clippy + fmt clean on default / enforce / concurrent / mcp.
  • VM matrix 32/32, new cases: priv-drop, cgroup-migrate-enforced, teardown-kills-descendants, search-in-scope.

🤖 Generated with Claude Code

jlgore and others added 4 commits July 23, 2026 18:19
All six were verified against the post-consolidation tree, not the paths
the scanner reported. Three share one root cause — a tool child inherits
the launcher's root/CAP_SYS_ADMIN under `--features enforce` — so the
privilege drop is the load-bearing fix and the others are reasoned against it.

- f028/f026/f010 (privilege retention): drop_privileges() in pre_exec now
  sets PR_SET_NO_NEW_PRIVS, SECBIT_NOROOT (+locks), and empties the ambient,
  bounding, and per-thread capability sets. The cgroup join is reordered to
  run BEFORE the drop, since writing cgroup.procs needs the authority the
  drop discards. NO_NEW_PRIVS closes the "only the first executable is
  checked" hole (f028); the capability drop stops a migrated process from
  writing another cgroup.procs (f026).

- f030 (credential exposure): tool children now inherit an allowlist
  (PATH/HOME/… + LC_*) via env_clear instead of a four-name provider-key
  denylist, so AWS_SECRET_ACCESS_KEY / GITHUB_TOKEN / KUBECONFIG etc. no
  longer reach model-driven bash.

- f020 (path traversal): scenario workdir paths are contained under an
  operator-declared --workdir-root (serde-skipped, never scenario-set),
  resolving symlinks and refusing absolute/`..` escapes rather than rebasing.

- f018 (credential exfil): provider.api_key_env is constrained to the
  provider-key namespace (*_API_KEY + known names), enforced at model
  construction so the batch path cannot skip it; batch also announces which
  secret goes to which endpoint.

- f022 (enforcement outliving teardown): scope teardown now kills the whole
  cgroup subtree (cgroup.kill, or repeated SIGKILL) before detaching the
  engine, and no longer swallows the failure; host-mode tool children get
  their own process group so backgrounded descendants are reaped.

Tests: capability/NoNewPrivs readback, workdir containment (incl. symlink
crossing), api_key_env namespace, and a descendant-cleanup test verified to
fail without the fix. All feature builds + clippy + fmt clean.

VM verification of the enforce-only paths (privilege drop, cgroup ancestor
walk, subtree kill) is still pending — host tests cover the userspace logic.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
VM testing on the BPF-LSM kernel (ac-matrix-vm) surfaced a regression in the
f028/f026 privilege drop: emptying the *entire* capability set while keeping
uid 0 stripped CAP_DAC_OVERRIDE / CAP_DAC_READ_SEARCH, so a root tool child
could no longer traverse a 0700 dir owned by another user. The kernel then
denied at DAC *before* bee's security_file_open hook ran — which broke the
core invariant that the eBPF policy is the file-access arbiter with a complete
audit trail. Two matrix cases regressed: observe-mode (read wrongly denied
instead of allowed+observed) and episode-file-deny (denial never reached the
transcript because DAC pre-empted the LSM).

Corrected drop_privileges():
- Keep CAP_DAC_OVERRIDE + CAP_DAC_READ_SEARCH; drop every other capability
  from the bounding set (CAP_SYS_ADMIN, CAP_BPF, CAP_SYS_PTRACE, …), which is
  what actually matters for f010 — a root child that can detach bee's own LSM.
- Drop SECBIT_NOROOT and the capset/ambient dance: with NOROOT off, root-magic
  re-derives the post-exec set from the bounding set, so bounding-dropping the
  escape caps removes them from the running child while the DAC pair still
  comes through. No exploitable window — the full set exists only between the
  drop and the immediately following execve.

VM matrix now 31/31 (28 prior + 3 new): priv-drop (NoNewPrivs=1, bounding set
== the DAC pair, CAP_SYS_ADMIN gone), cgroup-migrate-enforced (child mkdir's a
sub-cgroup, migrates in, denied read still denied — the ancestor walk), and
teardown-kills-descendants (backgrounded descendant reaped before the engine
detaches). observe-mode and episode-file-deny green again.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The .triage-state shards and TRIAGE/VULN artifacts from the re-run that
produced the six HIGH findings fixed in this branch. Data only, no code.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…rker

Adds a default `search` tool: regex search over file contents, returning
`path:line:text` and honoring .gitignore.

The design point is scope enforcement. ripgrep-api is a pure-Rust library (it
wraps ripgrep's grep/ignore crates — no external `rg` binary), but running it
in the harness would search *around* the sandbox: the harness is root, outside
the scope cgroup, and not subject to the file policy. So instead the tool execs
a hidden `bee search-worker` subcommand through the same run_child path as every
other tool — the worker joins the scope cgroup, is hardened + credential-
stripped, and every file the library opens there passes through the eBPF
file_open policy. "In-process" is real; it just runs in that worker, not the
harness (the only place cgroup-per-process enforcement can reach it).

- src/search.rs: SearchArgs + the worker `run()` (SearchBuilder → for_each →
  path:line:text) + argv round-trip. Narrow arg surface (pattern/path/glob/
  ignore_case) — none of ripgrep's command-executing options are exposed.
- src/tools/search.rs: the model-facing tool; execs current_exe search-worker.
- main.rs: hidden `SearchWorker` subcommand.
- Added to both DEFAULT_TOOLS lists (tools + config layer) and the schema doc.

Verified: host unit + integration tests (worker E2E via CARGO_BIN_EXE_bee,
glob/case/argv), and VM matrix `search-in-scope` (32/32) — a policy-denied file
in the search tree is invisible to the library search while a sibling is found,
proving the search genuinely runs under the scope's LSM.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@jlgore
jlgore merged commit a055215 into main Jul 24, 2026
4 checks passed
@jlgore
jlgore deleted the 012-high-severity-fixes branch July 24, 2026 01:21
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