From a32e156edb4ad27f6f0ea039e564d6d9d4253fa6 Mon Sep 17 00:00:00 2001 From: jg Date: Sat, 25 Jul 2026 22:17:40 -0500 Subject: [PATCH] fix(sec): a hook that cannot evaluate an operation refuses it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Four branches in the LSM programs returned 0 — allow — when the hook could not reach a decision: a `bpf_d_path` failure in `file_open` and `bprm_check_security`, a null `bprm`/`file`/sockaddr argument, an unavailable `PATHBUF` slot, and any address family `socket_connect` does not decode. Three are provokable from inside a scope, which makes them escapes rather than edge cases. `bpf_d_path` fails with -ENAMETOOLONG once the resolved path passes 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 way out of an exec-enforced scope — the allowlist simply stopped applying (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). `deny_unevaluated` now returns the denial errno and emits an audit record in all four. It runs only after the scope has been shown to enforce that dimension, 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 `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: a network-enforced scope refuses local socket IPC outright, and no enforced scope can open or exec a path longer than the kernel's path buffer. Recorded as research R16, with the inode-identity fallback named as the better long-term answer once the backend can pin inodes. VM matrix: three new cases (`exec-unresolvable-denied`, `file-unresolvable-denied`, `net-unix-denied`), 35/35 on the BPF-LSM VM. All three fail against the pre-fix binary — the exec one only after its descent loop was rewritten to use shell builtins, since a denied `seq` left it in the shallow directory and turned it into a false PASS. Also marks triage f010 as stale rather than open: `hardening.rs::drop_privileges` already sets PR_SET_NO_NEW_PRIVS and empties the capability bounding set except the DAC pair, and the `priv-drop` case asserts it. Co-Authored-By: Claude Opus 5 (1M context) --- TRIAGE.json | 38 ++++++++- TRIAGE.md | 2 + crates/ebpf/src/main.rs | 59 +++++++++++--- .../contracts/policy.schema.md | 5 ++ specs/001-ebpf-agent-sandbox/research.md | 42 ++++++++++ test/vm/README.md | 4 +- test/vm/remote-matrix.sh | 79 +++++++++++++++++++ 7 files changed, 216 insertions(+), 13 deletions(-) diff --git a/TRIAGE.json b/TRIAGE.json index e2adde1..6eff22e 100644 --- a/TRIAGE.json +++ b/TRIAGE.json @@ -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", @@ -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", @@ -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", @@ -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`)." } ] } diff --git a/TRIAGE.md b/TRIAGE.md index 3968fa1..cbb26b3 100644 --- a/TRIAGE.md +++ b/TRIAGE.md @@ -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) diff --git a/crates/ebpf/src/main.rs b/crates/ebpf/src/main.rs index 4c93f03..344d0ab 100644 --- a/crates/ebpf/src/main.rs +++ b/crates/ebpf/src/main.rs @@ -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 @@ -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() { @@ -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) }; @@ -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() { @@ -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). @@ -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); @@ -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 }; diff --git a/specs/001-ebpf-agent-sandbox/contracts/policy.schema.md b/specs/001-ebpf-agent-sandbox/contracts/policy.schema.md index 1ca436f..e6a50c5 100644 --- a/specs/001-ebpf-agent-sandbox/contracts/policy.schema.md +++ b/specs/001-ebpf-agent-sandbox/contracts/policy.schema.md @@ -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. diff --git a/specs/001-ebpf-agent-sandbox/research.md b/specs/001-ebpf-agent-sandbox/research.md index 77bf172..6e22f18 100644 --- a/specs/001-ebpf-agent-sandbox/research.md +++ b/specs/001-ebpf-agent-sandbox/research.md @@ -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 | diff --git a/test/vm/README.md b/test/vm/README.md index 82fa5cb..75a8c7b 100644 --- a/test/vm/README.md +++ b/test/vm/README.md @@ -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. diff --git a/test/vm/remote-matrix.sh b/test/vm/remote-matrix.sh index d311dac..6b7f3b8 100755 --- a/test/vm/remote-matrix.sh +++ b/test/vm/remote-matrix.sh @@ -44,6 +44,36 @@ if connected "$out"; then emit net-allow PASS "http=$out"; else emit net-allow F out=$(run_bee "$WORK/net.toml" -- bash -c 'curl -sS --max-time 8 -o /dev/null -w %{http_code} https://8.8.8.8') if [ "$out" = 000 ]; then emit net-deny PASS "connection blocked"; else emit net-deny FAIL "http=$out (expected block)"; fi +# A non-IP destination is still egress. The policy language spells destinations `host:port`, so no +# rule can ever allow an AF_UNIX socket — and "no rule matches" in an enforcing scope means deny. +# The hook used to return allow for every family it did not decode, so a local agent socket was +# reachable from a network-enforced scope (f034). +SOCK=/tmp/bee-af-unix.sock +rm -f "$SOCK" +python3 - "$SOCK" <<'EOF' & +import socket, sys, os +p = sys.argv[1] +s = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) +s.bind(p); s.listen(1); os.chmod(p, 0o777) +try: s.accept() +except Exception: pass +EOF +listener=$! +for _ in 1 2 3 4 5 6 7 8 9 10; do [ -S "$SOCK" ] && break; sleep 0.3; done +out=$(run_bee "$WORK/net.toml" -- python3 -c " +import socket,sys +s=socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) +try: + s.connect('$SOCK'); print('CONNECTED') +except OSError as e: + print('REFUSED', e.errno) +" 2>&1) +case "$out" in + *REFUSED*) emit net-unix-denied PASS "AF_UNIX connect refused ($out)" ;; + *) emit net-unix-denied FAIL "AF_UNIX reachable from an enforced scope ($out)" ;; +esac +kill "$listener" 2>/dev/null; wait "$listener" 2>/dev/null; rm -f "$SOCK" + # ---------------------------------------------------------------- file deny / allow cat >"$WORK/fs.toml" <<'EOF' [policy] @@ -133,6 +163,55 @@ out=$(run_bee "$WORK/exec.toml" -- bash "$WORK/exectest.sh") echo "$out" | grep -q CAT_OK && emit exec-allow PASS "allowlisted exec ran" || emit exec-allow FAIL "allowed exec blocked" echo "$out" | grep -q 'NC_RC=126' && emit exec-deny PASS "unlisted exec denied" || emit exec-deny FAIL "nc not denied ($(echo "$out" | tr '\n' ' '))" +# ------------------------------------------- unresolvable paths fail closed (f007) +# `bpf_d_path` fails (-ENAMETOOLONG) once the resolved path exceeds its 4KB buffer. Both hooks used +# to return allow in that case, so a deep-enough directory chain was a general escape: `execve` of a +# short *relative* name never has to pass a >PATH_MAX argument, but the kernel still resolves it to +# one. Build such a chain outside the scope, then try to use it from inside. +DEEP=/home/ubuntu/deeptest +SEG=$(printf 'd%.0s' $(seq 1 60)) +sudo rm -rf "$DEEP"; mkdir -p "$DEEP" +( cd "$DEEP" && for _ in $(seq 1 80); do mkdir -p "$SEG" && cd "$SEG" || exit 1; done \ + && cp /bin/echo ./x && echo DEEPDATA > ./deep.txt ) +# 80 × 61 chars of chain, well past PATH_MAX — confirm before trusting either result. +depth_ok=$(cd "$DEEP" && for _ in $(seq 1 80); do cd "$SEG"; done && pwd | wc -c) +# Builtins only for the descent: this script runs under the exec allowlist, and a `seq` that gets +# denied would leave us in the shallow directory and turn the case into a false PASS. +cat >"$WORK/deep.sh" </dev/null; echo "EXEC_RC=\$?" +cat ./deep.txt 2>/dev/null; echo "READ_RC=\$?" +EOF + +if [ "$depth_ok" -le 4096 ]; then + emit exec-unresolvable-denied FAIL "chain only $depth_ok bytes — test cannot provoke d_path failure" + emit file-unresolvable-denied FAIL "chain only $depth_ok bytes — test cannot provoke d_path failure" +else + # `seq`/`cat` are on the allowlist, `./x` (a copy of echo) is not — but the point is that it is + # unresolvable, so it must be refused whatever its name would have been. + out=$(run_bee "$WORK/exec.toml" -- bash "$WORK/deep.sh" 2>&1) + if echo "$out" | grep -q DESCENT_FAILED; then + emit exec-unresolvable-denied FAIL "descent broke — case proves nothing ($(echo "$out" | tr '\n' ' '))" + elif echo "$out" | grep -q DEEP_EXEC_OK; then + emit exec-unresolvable-denied FAIL "exec of an unresolvable image ran ($(echo "$out" | tr '\n' ' '))" + else + emit exec-unresolvable-denied PASS "unresolvable exec refused" + fi + # Same provocation against the file hook: a deny-list scope must not open what it cannot resolve. + out=$(run_bee "$WORK/fs.toml" -- bash "$WORK/deep.sh" 2>&1) + if echo "$out" | grep -q DESCENT_FAILED; then + emit file-unresolvable-denied FAIL "descent broke — case proves nothing ($(echo "$out" | tr '\n' ' '))" + elif echo "$out" | grep -q DEEPDATA; then + emit file-unresolvable-denied FAIL "read of an unresolvable path succeeded" + else + emit file-unresolvable-denied PASS "unresolvable open refused" + fi +fi +sudo rm -rf "$DEEP" + # ---------------------------------------------------- search tool: library search runs IN-scope # The `search` tool execs `bee search-worker` (ripgrep as a library) through the sandbox, so its file # opens are mediated by the LSM. Prove it: a deny policy over one subtree must make a secret there