Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
38 changes: 35 additions & 3 deletions TRIAGE.json
Original file line number Diff line number Diff line change
Expand Up @@ -707,7 +707,12 @@
"owner_hint": "top committer: jg (1/1 recent commits); no CODEOWNERS entry",
"missing_fields": [
"preconditions"
]
],
"remediation": {
"status": "not_applicable",
"where": "8e2cdbb, 3225d44",
"note": "Stale: crates/userspace/src/hardening.rs::drop_privileges sets PR_SET_NO_NEW_PRIVS and empties the capability bounding set except the DAC pair; VM case `priv-drop` asserts NoNewPrivs=1 and CapBnd=0x6. The report predates that work."
}
},
{
"id": "f003",
Expand Down Expand Up @@ -960,7 +965,12 @@
"owner_hint": "top committer: jg (1/1 recent commits); no CODEOWNERS entry",
"missing_fields": [
"preconditions"
]
],
"remediation": {
"status": "fixed",
"where": "015-hooks-fail-closed",
"note": "LSM hooks refuse what they cannot evaluate (`deny_unevaluated`): a bpf_d_path failure, a null struct argument, an unavailable scratch slot, and any non-IP address family now return the denial errno with an audit record instead of allowing. Research R16; VM cases exec-unresolvable-denied / file-unresolvable-denied / net-unix-denied."
}
},
{
"id": "f039",
Expand Down Expand Up @@ -1255,7 +1265,12 @@
"owner_hint": "top committer: jg (1/1 recent commits); no CODEOWNERS entry",
"missing_fields": [
"preconditions"
]
],
"remediation": {
"status": "fixed",
"where": "015-hooks-fail-closed",
"note": "LSM hooks refuse what they cannot evaluate (`deny_unevaluated`): a bpf_d_path failure, a null struct argument, an unavailable scratch slot, and any non-IP address family now return the denial errno with an audit record instead of allowing. Research R16; VM cases exec-unresolvable-denied / file-unresolvable-denied / net-unix-denied."
}
},
{
"id": "f049",
Expand Down Expand Up @@ -2152,6 +2167,23 @@
"status": "fixed",
"where": "014-terminal-safety",
"note": "Untrusted text is escaped where it enters a front-end (`safe_text::safe_block`/`safe_line`, applied in repl::terminal's ReplOutput impl and tui::app::handle_session) and only then styled; the interactive consent prompt escapes every field it prints; and skill frontmatter carrying control or bidi characters is refused at load."
},
{
"findings": [
"f007",
"f034"
],
"status": "fixed",
"where": "015-hooks-fail-closed",
"note": "LSM hooks refuse what they cannot evaluate (`deny_unevaluated`): a bpf_d_path failure, a null struct argument, an unavailable scratch slot, and any non-IP address family now return the denial errno with an audit record instead of allowing. Research R16; VM cases exec-unresolvable-denied / file-unresolvable-denied / net-unix-denied."
},
{
"findings": [
"f010"
],
"status": "not_applicable",
"where": "8e2cdbb, 3225d44",
"note": "Report predates the tool-child privilege drop; no_new_privs and the bounding-set drop are in place and VM-verified (`priv-drop`)."
}
]
}
2 changes: 2 additions & 0 deletions TRIAGE.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,8 @@ are left in place unedited for provenance.
| f018, f020, f022, f026, f028, f030 (all six HIGHs) | `8e2cdbb` + `3225d44` — closed and VM-verified (31/31 matrix) |
| f001, f002, f012, f014, f025 (+ absorbed f006, f032) | branch `013-attenuation-inheritance` — one root cause: attenuation validated only what a child *stated*, so omission widened authority. `Policy::derive` now returns the *effective* child policy (silence inherits, it does not reset) and refuses child grants reaching into FR-008 protected regions. See research R15; regression tests in `crates/core/tests/attenuation.rs`. |
| f040, f046 (+ absorbed f047) | branch `014-terminal-safety` — untrusted text is escaped where it enters a front-end (`safe_text`, applied in `repl::terminal` and `tui::app::handle_session`) and only then styled; the consent prompt escapes every field it prints; skill frontmatter carrying control or bidi characters is refused at load. |
| f007, f034 | branch `015-hooks-fail-closed` — LSM hooks refuse what they cannot evaluate: a `bpf_d_path` failure, a null struct argument, an unavailable scratch slot, and any non-IP address family are denied and audited instead of allowed. Research R16; VM cases `exec-unresolvable-denied`, `file-unresolvable-denied`, `net-unix-denied` (35/35). |
| f010 | **Not a defect — stale report.** `hardening.rs::drop_privileges` already sets `PR_SET_NO_NEW_PRIVS` and empties the capability bounding set except the DAC pair; VM case `priv-drop` asserts `NoNewPrivs=1 CapBnd=0x6`. Closed by `8e2cdbb`/`3225d44`, which the report predates. |

## Act on these
### [HIGH] Provider TOML can send an arbitrary environment secret to an attacker endpoint (f018)
Expand Down
59 changes: 50 additions & 9 deletions crates/ebpf/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,14 @@
//! deny-by-default writes for scopes that declare a writable surface. The requested access is the
//! `FMODE_WRITE` bit of `file->f_mode`, read by a direct load at a constant offset.
//!
//! **A hook that cannot evaluate an operation refuses it** ([`deny_unevaluated`]). Null arguments,
//! an unavailable scratch buffer, a `bpf_d_path` failure, and an address family the hook does not
//! decode all return the denial errno rather than `0`, and all emit an audit record. The trigger is
//! reachable by an attacker — a directory chain longer than the 4KB path buffer, a pathless memfd
//! image, an AF_UNIX destination the `host:port` policy language cannot describe — so allowing on
//! failure handed out exactly the operation the scope exists to mediate. This only applies once the
//! scope has been shown to enforce that dimension; an unenforced scope still returns `0` early.
//!
//! `bpf_d_path` needs a `*mut path`; we get it as `&file->f_path`. The `file`/`path`/`linux_binprm`
//! structs in `aya-ebpf-bindings` are opaque, so we read fields at compile-time-constant offsets (the
//! BPF verifier requires *constant* offsets into a BTF pointer, so these cannot be made
Expand Down Expand Up @@ -127,6 +135,28 @@ static PATHBUF: PerCpuArray<[u8; PATH_MAX]> = PerCpuArray::with_max_entries(1, 0
#[map]
static AUDIT_RB: RingBuf = RingBuf::with_byte_size(256 * 1024, 0);

/// Refuse an operation the hook enforces but could not evaluate — a null argument, an unreadable
/// struct field, an unresolvable path, an address family with no allowlist to check against.
///
/// Constitution I is unconditional: "cannot tell" is "no". Every one of these branches used to
/// `return 0`, which meant an attacker who could *provoke* the failure — a directory chain longer
/// than `bpf_d_path`'s buffer, a socket family the hook does not decode — got an unenforced
/// operation out of an enforcing scope. Reaching one of these is either an attack or a bug in bee,
/// and both deserve the audit record this emits.
///
/// Only ever called after the scope has been shown to enforce the dimension in question (a network
/// flag, a deny list, an exec allowlist), so an unenforced scope is unaffected. `errno` is negative,
/// as returned; observe mode records without blocking, exactly as a policy denial does.
fn deny_unevaluated(cgid: u64, meta: ScopeMeta, op: Op, errno: i32) -> i32 {
let observe = meta.mode == ScopeMode::Observe as u8;
emit_audit(cgid, op, observe, errno, None);
if observe {
0
} else {
errno
}
}

#[lsm(hook = "socket_connect")]
pub fn socket_connect(ctx: LsmContext) -> i32 {
let (cgid, meta) = match resolve_scope() {
Expand All @@ -140,7 +170,7 @@ pub fn socket_connect(ctx: LsmContext) -> i32 {
// arg1 of socket_connect is `struct sockaddr *address` (UAPI-stable layout).
let addr: *const u8 = ctx.arg(1);
if addr.is_null() {
return 0;
return deny_unevaluated(cgid, meta, Op::Connect, -EPERM);
}
let family = unsafe { bpf_probe_read_kernel(addr as *const u16).unwrap_or(0) };

Expand All @@ -160,7 +190,12 @@ pub fn socket_connect(ctx: LsmContext) -> i32 {
key.port = u16::from_be(port_be);
key.addr = a;
}
_ => return 0, // non-IP (unix, netlink, …) — not enforced here
// A non-IP family (unix, netlink, …). The policy language describes destinations as
// `host:port`, so there is no rule that could ever allow one — and "no rule matches" in an
// enforcing scope means deny, not allow. An AF_UNIX connect to a local agent socket is
// egress just as surely as a TCP one; letting it through because the allowlist cannot spell
// it is the enforcement gap, not the policy's silence.
_ => return deny_unevaluated(cgid, meta, Op::Connect, -EPERM),
}

if unsafe { NET_ALLOW.get(&key) }.is_some() {
Expand Down Expand Up @@ -190,12 +225,12 @@ pub fn file_open(ctx: LsmContext) -> i32 {
// Resolve the path into the per-CPU buffer.
let buf_ptr = match PATHBUF.get_ptr_mut(0) {
Some(p) => p,
None => return 0,
None => return deny_unevaluated(cgid, meta, Op::FileOpen, -EACCES),
};
// arg0 of file_open is `struct file *`.
let file: *const c_void = ctx.arg(0);
if file.is_null() {
return 0;
return deny_unevaluated(cgid, meta, Op::FileOpen, -EACCES);
}
// Requested access: direct load of `file->f_mode` (a scalar field on a trusted LSM BTF pointer,
// so the verifier maps offset 20 to the u32 field — same mechanism as `bprm->file` below).
Expand All @@ -206,7 +241,10 @@ pub fn file_open(ctx: LsmContext) -> i32 {
// SAFETY: bpf_d_path writes up to PATH_MAX bytes into buf_ptr and returns the length (incl. NUL).
let ret = unsafe { bpf_d_path(path_ptr, buf_ptr as *mut i8, PATH_MAX as u32) };
if ret <= 0 {
return 0; // could not resolve — do not block
// Unresolvable (most often a resolved path longer than the 4KB buffer). A rule list cannot
// be applied to a path we do not have, and a scope with deny rules does not get to skip them
// because an attacker nested the target deeply enough.
return deny_unevaluated(cgid, meta, Op::FileOpen, -EACCES);
}
let plen = resolved_len(ret);

Expand Down Expand Up @@ -241,23 +279,26 @@ pub fn bprm_check_security(ctx: LsmContext) -> i32 {
// arg0 of bprm_check_security is `struct linux_binprm *`; read bprm->file (a `struct file*`).
let bprm: *const u8 = ctx.arg(0);
if bprm.is_null() {
return 0;
return deny_unevaluated(cgid, meta, Op::Exec, -EACCES);
}
// Direct load of bprm->file. Because `bprm` is a trusted LSM BTF pointer, the verifier maps
// offset 64 to the `file*` field and keeps the loaded value a trusted pointer (which
// `bpf_d_path` requires) — unlike `bpf_probe_read`, which would yield an untyped scalar.
let file_val = unsafe { *((bprm as usize + BINPRM_FILE_OFF) as *const usize) };
if file_val == 0 {
return 0;
return deny_unevaluated(cgid, meta, Op::Exec, -EACCES);
}
let buf_ptr = match PATHBUF.get_ptr_mut(0) {
Some(p) => p,
None => return 0,
None => return deny_unevaluated(cgid, meta, Op::Exec, -EACCES),
};
let path_ptr = (file_val + FILE_F_PATH_OFF) as *mut path;
let ret = unsafe { bpf_d_path(path_ptr, buf_ptr as *mut i8, PATH_MAX as u32) };
if ret <= 0 {
return 0; // cannot resolve — do not block
// An allowlist names paths; an image whose path will not resolve cannot be on it. This is
// the sharpest of the four — a >4KB directory chain, or a memfd image with no path at all,
// was the way to exec anything at all out of an exec-enforced scope.
return deny_unevaluated(cgid, meta, Op::Exec, -EACCES);
}
let plen = resolved_len(ret);
let buf = unsafe { &*buf_ptr };
Expand Down
5 changes: 5 additions & 0 deletions specs/001-ebpf-agent-sandbox/contracts/policy.schema.md
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,11 @@ sensitive_paths = ["~/.ssh", "~/.aws", "~/.gnupg", "~/.config/gcloud"]
AS-2), independent of whether the policy has other writable roots.
- Segment (`**/name`) and single-`*` glob **filesystem** rules are not enforceable in-kernel and are
refused (fail-closed) for every mode — see compile/scope errors below.
- **Unevaluable operations are denied**: inside a scope that enforces a dimension, an operation the
kernel hook cannot evaluate is refused and audited — a path `bpf_d_path` cannot resolve (over ~4KB
resolved length, or a pathless image), or a socket family the `host:port` language cannot describe.
In practice: a network-enforced scope refuses AF_UNIX/AF_NETLINK connects outright, and no enforced
scope can open or execute a path longer than the kernel's path buffer (research R16).
- **Protected defaults (FR-008)**: within any writable root, VCS metadata (`.git`), bee's own config
(`.bee`), `~/.ssh`, and `~/.aws` are read-only/denied unless a rule explicitly grants otherwise.
- **Path tokens**: `:project_root` and `~` resolve to absolute paths at compile time.
Expand Down
42 changes: 42 additions & 0 deletions specs/001-ebpf-agent-sandbox/research.md
Original file line number Diff line number Diff line change
Expand Up @@ -373,6 +373,48 @@ property belongs to attenuation, and every future backend would have to re-imple

---

## R16. Hooks fail closed on what they cannot evaluate (FR-009, Constitution I)

**Context**: Four branches in the LSM programs returned `0` (allow) when the hook could not reach a
decision: `bpf_d_path` failure in `file_open` and `bprm_check_security`, a null `bprm`/`file`/sockaddr
argument, an unavailable `PATHBUF` slot, and — in `socket_connect` — any address family other than
AF_INET/AF_INET6. Three of the four are attacker-provokable from inside a scope. `bpf_d_path` fails
with `-ENAMETOOLONG` once the resolved path exceeds its 4KB buffer, and `execve` never has to pass a
path that long: `chdir` down a deep chain and exec a short *relative* name, and the kernel resolves it
to something the hook cannot render. That was a general escape from an exec-enforced scope (triage
f007). The family default was the same shape in the network hook: an AF_UNIX connect to a local agent
socket left an egress-enforced scope unmediated (f034).

**Decision**: A hook that enforces a dimension refuses what it cannot evaluate — `deny_unevaluated`
returns the denial errno and emits an audit record, in every one of those branches. The guard runs
only *after* the scope has been shown to enforce that dimension (network flag set, deny list present,
exec allowlist present), so an unenforced scope is untouched and observe mode still records without
blocking.

For non-IP families this is a policy statement as much as a code change: the authoring language spells
destinations as `host:port`, so no rule can ever name an AF_UNIX or AF_NETLINK peer — and "no rule
matches" in an enforcing scope means deny. The cost is real and accepted: inside a network-enforced
scope, local socket IPC is refused outright, and inside any enforced scope a path longer than 4KB
cannot be opened or executed.

**Rationale**: The alternative readings of a resolution failure — "probably benign", "not our
business" — are exactly the readings an attacker wants, and neither is available under Constitution I.
Auditing the refusal keeps a genuine bee bug (a wrong struct offset, an exhausted per-CPU slot)
diagnosable rather than silent.

**Alternatives**: (a) fall back to an inode identity check for exec — the TOCTOU-hard answer, and the
right long-term one, but it requires the inode-pinning backend that `EnforcementPlan` currently refuses
(`UnsupportedInodePin`); deny-on-unresolvable is correct in the meantime and stays correct after;
(b) allow non-IP families and add an `AF_UNIX` allowlist to the policy language — more expressive, but
it widens the authoring surface and the attenuation rules for a case no bee policy has yet asked for;
(c) raise the path buffer — moves the threshold without closing anything, and PATH_MAX is the kernel's
own ceiling.

**Verification**: VM matrix cases `exec-unresolvable-denied`, `file-unresolvable-denied`, and
`net-unix-denied`; all three fail against the pre-fix binary. 35/35 on the BPF-LSM VM.

---

## Resolved Technical Context values

| Field | Value |
Expand Down
4 changes: 3 additions & 1 deletion test/vm/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -44,11 +44,13 @@ Override target via env: `NS`, `VM`, `KEY`.
| `rw-read-source` / `rw-write-source-denied` | read/write modes: a `read`-marked source tree is readable but write-opens are blocked (`EACCES`) — US2 AS-2 |
| `rw-write-scratch` / `rw-write-default-deny` | a `write`-granted scratch dir is writable; an unlisted path is denied by default (scope declares a writable surface) |
| `exec-allow` / `exec-deny` | exec allowlist: allowlisted binary runs, un-listed `execve` denied |
| `exec-unresolvable-denied` / `file-unresolvable-denied` | fail-closed on what a hook cannot evaluate: a directory chain past `bpf_d_path`'s 4KB buffer makes the resolved path unrenderable, and the exec/open is refused rather than allowed (research R16) |
| `net-unix-denied` | a network-enforced scope refuses an AF_UNIX connect — the `host:port` language cannot name one, and no rule matching means deny |
| `observe-mode` | dry-run: operation allowed but emits a `decision:"observed"` audit event |
| `atten-reject` | subagent attenuation: an over-broad child (`--parent`) is refused before running |
| `atten-subset-allow` / `atten-subset-deny` | a valid subset child runs and enforces its *narrower* policy (a dest the parent allows but the child dropped is blocked) |
| `scope-isolation` | a process outside any bee scope is unaffected |
| `episode-file-deny` / `episode-allow` | LLM agent harness (002): a scripted episode's tool call that reads a policy-denied path returns kernel `EACCES` in the transcript with a `file_open` denial and status `completed` (US1 AS-1); a permissive in-scope write succeeds with no denials (US1 AS-2) |

All 23 pass on the reference VM. See the repo root `README.md` for the enforcement design and the
All 35 pass on the reference VM. See the repo root `README.md` for the enforcement design and the
`bpf_d_path` / offset caveats.
Loading
Loading