Verification — subprocess file-descriptor hygiene
Status: PENDING — not yet run. No result has been recorded, and none should be inferred.
The close_range(2) fd-hygiene step described below is implemented and compiles, but the
procedure on this page has not been executed on a real Linux host. A green
cargo build/cargo test/cargo clippy on a macOS dev machine is not evidence about
this property and must not be reported as if it were: close_range(2) is Linux-only, and
macOS resolves permanently to EnforcementTier::EnvironmentOnly, so nothing on that
platform exercises a single line of the code under test.
See Recording the result for what to replace when someone runs it.
What this verifies
Both of capsule-runtime's subprocess spawn paths now mark every file descriptor at or above 3
close-on-exec inside the forked child's pre_exec window, before anything else in that window
runs:
| path | entry point | where the step runs |
|---|---|---|
| shell tool | shell::execute_shell → sandbox::prepare_enforcement |
first statement of sandbox::linux_enforce::child_install_enforcement |
| native tool | runtime::dispatch_native_tool |
sandbox::apply_fd_hygiene, a pre_exec closure attached before .spawn() |
Both call the same function, sandbox::linux_enforce::mark_inherited_fds_cloexec, so the two
paths cannot drift apart on this dimension.
The property under test is a negative: a descriptor open in the runtime process at spawn time
must not be visible inside the spawned child. Landlock cannot substitute for this. Landlock
mediates new filesystem operations against the ruleset installed in that same pre_exec
window; an fd the child already holds was opened before the ruleset existed, so there is no
operation left for the ruleset to intercept, and a leaked fd reads straight through the
workdir scope. On the KernelSeccompOnly tier there is no Landlock at all — which is why the
fd-hygiene step runs on both kernel tiers rather than only under KernelFull.
Fds 0/1/2 are deliberately excluded and are expected to be present in every result below.
That is the correct outcome, not a leak: both spawn paths install Stdio::piped() handles
before pre_exec runs precisely so the exec'd program inherits them and talks to the parent
over them. See sandbox::FD_HYGIENE_FIRST_FD for the recorded decision.
What this deliberately is not
This procedure is not automated, and must not be wired into CI in any form — no #[test],
no #[ignore] marker intended for an automated runner, no workflow step. CI runs on
ubuntu-latest GitHub runners, which never resolve to a kernel-enforcement tier, and on macOS,
where close_range(2) does not exist. A test asserting this property would either pass
vacuously or skip — and either outcome would turn a green run into false evidence about a
security property it never touched. This is the same reasoning that keeps the
seccomp-notify race probes
out of the workspace.
The one automated test this work added is a content check —
sandbox::tests::fd_hygiene_range_starts_above_stdio, asserting FD_HYGIENE_FIRST_FD == 3. It
catches the single silent, catastrophic edit (a 0, which would close stdio and break every
subprocess invocation); it asserts nothing about the kernel.
Why not /proc/self/fd
The obvious way to list a process's open descriptors is ls /proc/self/fd. Do not use it
here. Under the KernelFull tier the child's Landlock ruleset is scoped to the capsule
workdir plus a fixed set of read/execute grants; /proc is not among them, so the openat of
/proc/self/fd fails with EACCES and the probe reports nothing — indistinguishable, from the
outside, from a clean result. The investigation that produced this work hit exactly that and had
to be rewritten.
The enumeration below therefore probes descriptors directly with fcntl(fd, F_GETFD) across a
range bounded by getrlimit(RLIMIT_NOFILE). fcntl returns -1/EBADF for a closed
descriptor and 0/flags for an open one, needs no filesystem access at all, and is in the
seccomp allowlist (sandbox.rs, SECCOMP_SYSCALL_ALLOWLIST) — as are getrlimit, prlimit64
and pread64, which the probe also uses.
Prerequisites
- A real Linux host, kernel 5.11 or newer (
close_range(2)landed in 5.9,CLOSE_RANGE_CLOEXECin 5.11). Confirm withuname -r. murbuilt from this branch and onPATH, plus a workingccandcargo.- An inference driver artifact installed and an API key exported, since a capsule tool call is
driven by the agent loop. The example below uses
murmur-driver-anthropicandANTHROPIC_API_KEY; any driver works. - Confirm the host actually resolves to a kernel-enforcement tier before trusting anything.
A capsule that logs
W-SEC-003(capabilities.shell.allow is non-empty and this host ...) did not resolve toKernelFull/KernelSeccompOnly, and the shell half of this procedure proves nothing on that machine:
uname -r
mur doctor
Step 1 — build the fd probe
The probe is the same binary in both roles: it enumerates its own descriptors, writes the
result to a file in the capsule workdir, and prints a Murmur native ToolResult envelope on
stdout so it also works unchanged as a native tool artifact.
Two details are load-bearing and are commented in the source: it enumerates before opening
its own output file (opening it first would occupy a descriptor and appear in the results), and
it probes any descriptor it does find with pread(2) rather than read(2), because pread
never blocks and returns ESPIPE on a pipe or socket, so it is safe to point at an unknown fd.
mkdir -p /tmp/fdhygiene/src /tmp/fdhygiene/bin
cat > /tmp/fdhygiene/src/fdprobe.c <<'EOF'
/* fdprobe — enumerate this process's open file descriptors without touching /proc. */
#define _GNU_SOURCE
#include <fcntl.h>
#include <stdarg.h>
#include <stdio.h>
#include <string.h>
#include <sys/resource.h>
#include <unistd.h>
#define MAX_PROBE 65536
#define MAX_REPORT 64
static char report[8192];
static size_t off;
static void apnd(const char *fmt, ...) {
va_list ap;
int w;
if (off >= sizeof report - 1) return;
va_start(ap, fmt);
w = vsnprintf(report + off, sizeof report - off, fmt, ap);
va_end(ap);
if (w < 0) return;
off += (size_t)w;
if (off > sizeof report - 1) off = sizeof report - 1;
}
int main(int argc, char **argv) {
struct rlimit rl;
unsigned long limit = 1024, fd;
int open_fds[MAX_REPORT];
int n = 0, extra = 0, i;
char path[256], tag[64];
FILE *out;
/* Enumerate FIRST: opening the report file below would itself occupy a
descriptor and show up in the results. */
if (getrlimit(RLIMIT_NOFILE, &rl) == 0 && rl.rlim_cur != RLIM_INFINITY)
limit = (unsigned long)rl.rlim_cur;
if (limit > MAX_PROBE) limit = MAX_PROBE;
for (fd = 0; fd < limit; fd++) {
if (fcntl((int)fd, F_GETFD) == -1) continue; /* EBADF: not open */
if (fd >= 3) extra++;
if (n < MAX_REPORT) open_fds[n++] = (int)fd;
}
apnd("open_fds:");
for (i = 0; i < n; i++) apnd(" %d", open_fds[i]);
apnd("\nfd_limit_probed: %lu\nextra_fds: %d\n", limit, extra);
/* For anything above stdio, show what it points at. pread() never blocks and
returns ESPIPE on a pipe/socket, so it is safe against an unknown fd. */
for (i = 0; i < n; i++) {
char buf[65];
ssize_t r, k;
if (open_fds[i] < 3) continue;
r = pread(open_fds[i], buf, sizeof buf - 1, 0);
if (r < 0) r = 0;
buf[r] = '\0';
for (k = 0; k < r; k++) if (buf[k] == '\n') buf[k] = ' ';
apnd("LEAKED fd %d first_bytes: %s\n", open_fds[i], buf);
}
/* argv[1] tags the output file so the two spawn paths write separate results;
the native path passes no arguments. */
snprintf(tag, sizeof tag, "%s", (argc > 1 && argv[1][0]) ? argv[1] : "native");
for (i = 0; tag[i]; i++)
if (!((tag[i] >= 'a' && tag[i] <= 'z') || (tag[i] >= '0' && tag[i] <= '9')))
tag[i] = '_';
snprintf(path, sizeof path, "fdprobe-%s.txt", tag);
out = fopen(path, "w");
if (out) { fputs(report, out); fclose(out); }
printf("{\"status\":\"passed\",\"summary\":\"extra_fds=%d\",\"data\":\"wrote %s\","
"\"truncated\":false,\"metadata\":{}}\n", extra, path);
return 0;
}
EOF
cc -static -O1 -o /tmp/fdhygiene/bin/fdprobe /tmp/fdhygiene/src/fdprobe.c
-static is deliberate: a static binary needs no dynamic loader, which removes the loader and
its shared libraries as a possible source of Landlock/exec-allowlist failures unrelated to what
is being measured. If the toolchain cannot link statically, drop -static — the shell tool's
derived Landlock grants cover the ELF interpreter and its libraries — and re-run.
Sanity-check the probe outside the capsule, with a descriptor you know is open:
cd /tmp && exec 7</etc/hostname && /tmp/fdhygiene/bin/fdprobe baseline; cat /tmp/fdprobe-baseline.txt; exec 7<&-
Expected — the probe must find fd 7 here, otherwise it cannot prove anything later:
open_fds: 0 1 2 7
fd_limit_probed: 1024
extra_fds: 1
LEAKED fd 7 first_bytes: <your hostname>
Step 2 — install the probe in both roles
2a — as a capabilities.shell.allow binary
shell.allow entries are resolved against the launching process's PATH, so the probe must be
on it under a bare name:
sudo install -m 0755 /tmp/fdhygiene/bin/fdprobe /usr/local/bin/fdprobe
command -v fdprobe
2b — as a native tool artifact
The runtime reads a native artifact's binary from the archive entry bin/<artifact-name> and
stages it to <workdir>/tools/<artifact-name>/<artifact-name>, so the file name must match the
artifact name exactly:
mkdir -p /tmp/fdhygiene/tool/bin
cp /tmp/fdhygiene/bin/fdprobe /tmp/fdhygiene/tool/bin/fdprobe-native
cat > /tmp/fdhygiene/tool/murmur.yaml <<'EOF'
name: fdprobe-native
version: "0.1.0"
runtime: tool
implementation: native
description: "Enumerates its own open file descriptors and writes them to the capsule workdir."
input_schema: '{"type":"object","properties":{},"required":[]}'
requires_files:
- bin/fdprobe-native
EOF
cd /tmp/fdhygiene/tool
mur build .
mur publish fdprobe-native-0.1.0.mur.zip
Step 3 — the capsule
mkdir -p /tmp/fdhygiene/capsule
cat > /tmp/fdhygiene/capsule/murmur.yaml <<'EOF'
name: fd-hygiene-check
version: "0.1.0"
artifacts:
- name: murmur-driver-anthropic
version: "1.0.0"
runtime: driver
- name: fdprobe-native
version: "0.1.0"
runtime: tool
capabilities:
network:
allow:
- https://api.anthropic.com
shell:
allow:
- fdprobe
inference:
transport: http
endpoint: https://api.anthropic.com
model: claude-sonnet-5
api_key: ${ANTHROPIC_API_KEY}
driver:
artifact: murmur-driver-anthropic
max_turns: 6
EOF
cat > /tmp/fdhygiene/capsule/task.md <<'EOF'
Call the `fdprobe` tool exactly once, with the command field set to the single word: shell
Then call the `fdprobe-native` tool exactly once, with an empty input object.
Then stop. Do not call any other tool and do not retry either call.
EOF
cd /tmp/fdhygiene/capsule
mur install
Step 4 — run it with a deliberately leaked descriptor
`exec 7