Skip to content

[ML] PR D: linear child ownership and fault-injected lifecycle for Sandbox2 - #3186

Closed
valeriy42 wants to merge 15 commits into
elastic:feature/sandbox2-pr-c-fs-net-policyfrom
valeriy42:feature/sandbox2-pr-d-lifecycle
Closed

valeriy42 wants to merge 15 commits into
elastic:feature/sandbox2-pr-c-fs-net-policyfrom
valeriy42:feature/sandbox2-pr-d-lifecycle

Conversation

@valeriy42

Copy link
Copy Markdown
Contributor

Summary

PR D of the Sandbox2 clean rebuild epic (docs/projects/mlcpp-sandbox2-pr2873 in the elastic-workspace harness). Stacked on PR C (#3184), which stacks on PR B (#3182), which stacks on PR A (#3181).

Rebuilds CSandboxedProcessSpawner (new) around an explicit lifecycle state machine (Prepared -> Launched -> IdentityCaptured -> Registered -> Monitoring -> TerminationRequested -> CleanupRequired -> Reaped/Failed):

  • Non-throwing kill-and-reap guard, armed immediately after RunAsync()/pid() capture, holding its own owning shared_ptr<sandbox2::Sandbox2> so cleanup is order-independent during unwinding.
  • Four injectable seams (pidfd acquisition, registry allocation, monitor-thread launch, Sandbox2 completion) for deterministic fault injection.
  • Explicit pidfd outcome classification: ENOSYS is the only classification that routes to Sandbox2::Kill() (SIGKILL via the owned ptrace monitor, identity-safe, no numeric-PID lookup); every other pidfd error fails registration outright; numeric kill(pid, ...) does not appear anywhere in this file.
  • A CAS-controlled one-shot latch (CCasOutcomeLatch) replacing two-boolean coordination for the timeout-vs-completion race.
  • CSandboxedProcessSpawnerLifecycleTest_Linux.cc: fault-injection coverage for every pidfd class, allocation/resource failures, stale-generation protection, PID-reuse identity binding, the CAS race, descriptor-baseline cleanup, and destructor-latency (V8/V9/V10/V11, LI1-LI9 per design.md).

Notes

  • Implemented via subagent-driven development (5 sequential tasks, task-scoped review+fix loops, one whole-branch final review+fix wave, then two additional independent self-review loops at sonnet and opus tiers per request) — see the plan at docs/projects/mlcpp-sandbox2-pr2873/pr-d-lifecycle.plan.md in the elastic-workspace harness repo.
  • Not yet compiled or run on Linux/Sandbox2 — every review pass this session was performed on a macOS host with no Sandbox2 toolchain (headers are fetched at CMake configure time, not vendored). Buildkite on this PR is the first real execution of any of this code.
  • E_Launched/E_CleanupRequired lifecycle states are declared but intentionally left unassigned (no natural single point without broader restructuring) — not silently dropped.
  • The Sandbox2::Kill()/SIGKILL-only assumption (no graceful-SIGTERM-via-monitor variant is buildable against pinned sandboxed-api v20241008) was cross-checked against the vendored monitor source during review.

valeriy42 and others added 15 commits September 9, 2026 14:43
…aded seccomp

Replace the hand-maintained BPF jump-offset table in
CSystemCallFilter_Linux.cc with a program builder that derives every
jump from the allowlist vector's own size/index. The applied program
is generated from CPytorchInferenceSyscallAllowlist.h, a single
machine-readable declaration, instead of a parallel hardcoded list
(design.md V3/MG6).

CSystemCallFilter::installSystemCallFilter() returns a typed
ESystemCallFilterInstallOutcome across all three platform
implementations instead of void, and logs the ml.seccomp.installed
readiness marker on success. pytorch_inference/Main.cc gains a
decideDegradedModeAction() decision that would terminate before
CIoManager::initIo() on any degraded-mode seccomp failure; that
termination stays behind an internal switch defaulting to false until
ml-cpp PR E's typed controller routing can guarantee a degraded-mode
launch was deliberate (design.md MG8, "activation is deliberately
split across two slices"). The four non-PyTorch callers now make
their unchanged log-and-continue policy explicit instead of silently
discarding the result.

Adds CSeccompFilterBuilderTest.cc: decodes the actually-built BPF
program to prove it matches the declaration and that jump offsets are
derived, not hand-maintained; fault-injected coverage of
decideDegradedModeAction() for every install-failure class (V13); a
named regression test per design.md M4 carry-forward syscall (clone3
by number, prlimit64, x86_64 legacy filesystem syscalls) so a
blank-slate reconstruction cannot silently drop a hard-won
compatibility fix from frozen PR elastic#2873; and an explicit structured
degradedModeAttestationMarker() (design.md M2) so a controller/ES
observer can assert seccomp installation directly instead of
inferring it from the absence of a fatal log line.

The Sandbox2-grants half of V3 (CPytorchInferenceSandboxPolicy.cc)
does not exist yet in this clean rebuild lineage (ml-cpp PR C); this
change establishes the single declaration for that PR to consume.
d9a856d (Sandbox2 AllowFutexOp arg-filtering) and
730933d/f8b0a534 (Buildkite run_tests.sh/build.sh packaging) are
likewise out of this file's scope - see the bd note on
elastic-workspace-3b59.3 for the explicit deferral to PR C/D/E.

Verified on a real x86_64 devbox: ml_test_seccomp (7 test cases) and
the real pytorch_inference/autodetect/categorize/normalize/
data_frame_analyzer binaries all build, link, and run correctly
against the new typed API; the generated BPF program installs via a
real prctl(PR_SET_SECCOMP) call.
…rence

Replace raw argument-directory inference with a typed launch spec that
validates every input/output/restore/logPipe path against the pinned
child-root contract ($TMPDIR/ml-child-ipc/<child-id>): rejects relative,
root, out-of-root, dot-dot, duplicate, and mutable-symlink/alias paths
before any policy is built. Adds the minimized fixed-mount enumeration
(narrows /etc to individually justified files, never binds host
/proc//sys), a private bounded tmpfs at /tmp, and a purpose-built
allowlisted mechanism probe (ml_sandbox_probe) proving allowed IPC
access, denied host reads/writes, mount enumeration, and external-egress
denial.

Validator logic verified standalone on this host (non-Sandbox2 path):
compiles clean with -Wall -Wextra, and a driver exercising every V16
rejection case (relative/root/dot-dot/duplicate/aliased/wrong-depth/
child-id-mismatch) plus the valid multi-pipe case all pass. The
Sandbox2-gated PolicyBuilder path and the Linux mechanism-probe
integration test are unverified in this session (no Linux/Sandbox2
toolchain on this host) and need a devbox or Buildkite pass before
V4/V5(policy half)/V7/V16 can be marked closed - see
elastic-workspace-3b59.4.
…process spawner (header only)

Adds include/sandbox/CSandboxedProcessSpawner.h declaring the explicit
child lifecycle state enum (Prepared/Launched/IdentityCaptured/
Registered/Monitoring/TerminationRequested/CleanupRequired/Reaped/
Failed, design.md's mermaid diagram), a registry-entry shape
(SSandboxedChild) carrying that state, a generation counter, and
pidfd/Sandbox2 handle fields, and a one-shot CAS-controlled outcome
latch (CCasOutcomeLatch: Pending -> TimedOut|Completed via a single
compare_exchange_strong) replacing the two-boolean timeout/completion
coordination in the frozen enhancement/sandbox2 reference. Declares
the public API (spawn/terminateChild/hasChild) with no injectable
seams yet - those are PR D Task 2's contract.

No .cc file and no CMakeLists change: lib/sandbox/CMakeLists.txt's
SRCS only lists .cc files, so a header-only commit needs none.

Header-only; verified with a standalone g++ -fsyntax-only -std=c++17
-Wall -Wextra probe against this worktree's include/ (no Sandbox2
toolchain on this host - see docs/projects/mlcpp-sandbox2-pr2873/
pr-d-lifecycle.plan.md Task 1).
E_IdentityCaptured is not present in design.md's mermaid diagram (only 8
states); it comes from the Sandbox2 rebuild plan's explicit
Prepared->Launched->IdentityCaptured->Registered->Monitoring->Reaped
sequence. Corrected the class-level and enum-level doc comments to cite
the rebuild plan as the source for that state and stop claiming
one-for-one fidelity to design.md's diagram, which covers a related but
not identical lifecycle.
Implements Task 2 of the PR D Sandbox2 lifecycle rebuild: a new
CSandboxedProcessSpawner_Linux.cc gives spawn() its full skeleton, calling
PR C's validateChildIpcLaunchSpec()/buildPytorchInferenceFilesystemPolicy()
as an interlock before TryBuild(), and arming a non-throwing
CKillAndReapGuard immediately after RunAsync()/pid() capture
(E_IdentityCaptured) so every early-return before registry insertion and
monitor handoff both succeed (E_Monitoring, LI2) routes through one cleanup
owner instead of a duplicate catch block (LI1/LI3, closes MG3).

Adds four injectable seams (pidfd acquisition, registry allocation,
monitor-thread creation/detach, Sandbox2 completion) with production
defaults and a test-only constructor overload; SSandboxedChild/SPidRegistry
move from private to public so the seam signatures are nameable by test
code. The new .cc is added to lib/sandbox/CMakeLists.txt's SRCS
unconditionally, matching lib/core/CMakeLists.txt's CDetachedProcessSpawner.cc
pattern, with #ifdef SANDBOX2_AVAILABLE gating the real logic inside.

Does not implement pidfd-error classification or terminateChild()'s real
logic - Task 3 scope. No numeric kill(pid) fallback anywhere in this file.
CKillAndReapGuard held a raw, non-owning Sandbox2* declared before the
shared_ptr locals (sandbox, child.s_Sandbox) that could become its last
owner; on the registry-insert-throw and monitor-launch-fail paths those
locals were destroyed before the guard, freeing the object before the
guard's destructor called Kill() on it. The guard now stores its own
shared_ptr<Sandbox2> copy, making its cleanup self-sufficient regardless
of other locals' declaration order; the unique_ptr->shared_ptr
conversion moves earlier (before RunAsync()) so the guard can be
constructed from a real shared_ptr once the process is actually running.

Also wrap the span between successful registry insertion and monitor
launch (copying seams, constructing monitorBody) in try/catch, so an
exception there (e.g. bad_alloc copying a std::function) can no longer
escape spawn() leaking the registry entry; erase-on-failure logic is
factored into a shared lambda used by both the new catch and the
existing monitor-launch-false path.
…terminateChild()

Adds EPidFdOutcome (E_Acquired/E_KernelUnsupported/E_Failed) and a pure
classifyPidFdOutcome() so spawn() fails registration outright on any
non-ENOSYS pidfd_open errno (MG2/LI8), and records the classification on
the registry entry at registration time. Implements terminateChild() for
real: pidfd_send_signal(SIGTERM) request for E_Acquired, Sandbox2::Kill()
(SIGKILL via the owned monitor) for E_KernelUnsupported only - selected
from the recorded classification, never re-derived. Wires the monitor-body
completion path through CCasOutcomeLatch::tryResolve() (MG4/V11) instead
of an ad-hoc boolean. No numeric kill(pid) call exists anywhere in the file.
Fix round 1/5 for Task 3 review: terminateChild() set s_State to
E_TerminationRequested before releasing the registry lock and attempting
the actual pidfd_send_signal()/Sandbox2::Kill() call. On failure of that
call it returned false but left s_State claiming a termination request
had been issued, with no rollback and no distinguishable "attempted and
failed" state.

Capture the entry's prior state before overwriting it, and on each
failure path (pidfd_send_signal() non-zero return, the defensive
neither-pidfd-nor-sandbox branch, and Sandbox2::Kill() throwing)
re-acquire the registry lock briefly to roll s_State back to what it
was, but only if nothing else has since moved the state on. The
syscall/Kill() call itself still happens outside the lock, unchanged.
Implements the 8 required lifecycle proofs (pidfd classification,
allocation/monitor-launch failure, stale generation, PID reuse,
timeout/completion race, descriptor baseline, destructor latency,
monitor-outlives-spawner) by driving CSandboxedProcessSpawner's
injectable seams against one real spawn() per case, since no seam
bypasses RunAsync() itself and the private registry has no external
accessor other than the registry-insert seam's mutable reference.

Adds lifecycle_signal_payload.cc, a long-lived sandboxee that catches
and survives SIGTERM but not SIGKILL (using FUTEX_WAIT, since pause()
is not in the seccomp allowlist), needed to distinguish
terminateChild()'s two mechanisms by observable effect - there is no
seam around Sandbox2::Kill() itself. See task-4-report.md for the full
list of self-review findings, open items (no TSan/ASan CI job exists
yet; ML_SANDBOX2_REQUIRE gating referenced in the brief does not exist
in this checkout; gate 7's orphan-cleanup half is explicitly out of
scope), and what could/couldn't be verified on this macOS host.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
testTerminateChildFallsBackToKillWhenKernelUnsupportsPidfd now asserts
reason_code() == SIGKILL in addition to final_status() == SIGNALED, so a
regression sending a different signal no longer passes silently. The
monitorBody() call that drives AwaitResult() is now run on a bounded
background thread (join on success, detach on timeout) since production
AwaitResult() has no wall-clock limit and a Kill()-regressed-to-no-op would
otherwise hang indefinitely instead of failing fast.
capturedResult's outer shared_ptr was not captured by the detached
monitor thread's lambda, only its raw pointer traveled via
awaitResultFn's closure. On the timeout/detach regression path, the
test case's stack unwind (BOOST_TEST_REQUIRE failure) freed the
heap-boxed inner shared_ptr<Result> while the still-running detached
thread held a dangling raw pointer into it, ready to write through it
once AwaitResult() unblocked. Capture capturedResult by value in the
detached thread's own lambda so its copy keeps the object alive for as
long as the thread might still run, independent of the test case's
lifetime.
…ead lifecycle states

Final review fix wave for PR D (Sandbox2 lifecycle rebuild), all 6 findings
in one pass:

- C1/I3: terminateChild() now performs pidfd_send_signal() for the
  E_Acquired branch while still holding s_Mutex, closing the race against
  monitorBody's completion handler closing/recycling the same pidfd
  underneath a delayed signal. rollBackState() now matches on both
  s_Generation and s_State, so a stale rollback cannot clobber a newer
  registration that reused the same numeric PID.
- I1: wire up E_Monitoring (after monitor handoff is confirmed in spawn())
  and E_Reaped (immediately before the registry erase in monitorBody),
  generation-matched under the lock.
- I2: reset childPid to 0 immediately after capturing sandboxPid, and only
  set it to the live PID at the very end of spawn(), so an uncaught throw
  between guard-arm and the first try/catch can no longer leave childPid
  non-zero on a false/throw exit.
- I4: lifecycle test assertions that a fresh pidfd_open() succeeds on an
  already-killed-and-reaped PID now also accept ESRCH (fully reaped) as
  proof of cleanup, in the 3 affected test cases.
- I5: realMonitorLaunchWithCompletionSignal destroys the monitor closure
  (and its captured Sandbox2 handle) before signaling completion, so a
  waiting test cannot observe "done" while fds may still be open.
- I6: add a BOOST_GLOBAL_FIXTURE that warms up Sandbox2's forkserver before
  any per-case fd-baseline snapshot is taken, removing the test-ordering
  dependency shared with gate 4's fork().
…ng, guard E_Monitoring write

Final whole-branch review of the fix wave (b42ee96) surfaced three new,
small issues in that very wave:
- LOG_INFO printed childPid before the I2 fix restores it, always logging
  "PID 0" on success.
- The I6 forkserver warm-up fixture called AwaitResult() unconditionally
  even when terminateChild() failed, risking an unbounded hang of the
  BOOST_GLOBAL_FIXTURE (and therefore the whole test binary) if
  Sandbox2::Kill() ever throws.
- The new E_Monitoring write was unconditional on generation match alone,
  which could silently revert an E_TerminationRequested marker set by a
  concurrent registry-scanning terminator (PR E's future timeout caller).

Applied directly per the SDD final-review adjudication rule (no second
fix-wave dispatch): all three are small, load-bearing, and closing them now
is cheaper than a PR E regression hunt.
…8 UAF, gate 3 fd leak, promise UAF, log classification

- Gate 1 (testTerminateChildFallsBackToKillWhenKernelUnsupportsPidfd):
  Sandbox2::Kill() produces EXTERNAL_KILL/reason_code()==0, not
  SIGNALED/SIGKILL - fix the assertion to match the pinned sandboxed-api
  v20241008 status classification order.
- realMonitorLaunchWithCompletionSignal: stop destroying the monitor
  closure (and its co-owned shared_ptr<SPidRegistry>) before signalling
  completion; hand the caller its own shared_ptr<function<void()>>
  reference instead. Fixes a deterministic UAF in gate 8, which
  dereferenced a raw SPidRegistry* after the only remaining owning
  shared_ptr had already been released. Also switch its promise parameter
  to shared_ptr, matching the existing ENOSYS-case precedent, so a throw
  between spawn() and the wait can no longer free a stack-local promise
  out from under the still-running detached thread.
- Gate 3 (testStaleMonitorGenerationCannotEraseNewerRegistration): close
  the real pidfd the case's fabricated stale-generation scenario leaves
  open, so SFdBaselineFixture's descriptor count matches the baseline.
- logSandboxeeTermination: give EXTERNAL_KILL, VIOLATION, TIMEOUT,
  SETUP_ERROR and INTERNAL_ERROR their own log cases instead of funnelling
  them into one generic LOG_ERROR - EXTERNAL_KILL is this file's own
  successful ENOSYS-fallback path, and VIOLATION is the most operationally
  important signal a sandbox can report.
- terminateChild(): document that a repeated call reaching Sandbox2::Kill()
  twice is safe (idempotent at the pinned tag) - considered during review,
  confirmed harmless, recorded so it isn't re-derived next time.
…checks

Round-3 self-review M1: testDescriptorCountReturnsToBaselineAfterSpawnTerminateCleanup
asserted the fd baseline while its local monitorBody std::function still held
the closure's captured shared_ptr<Sandbox2>, keeping the comms socketpair fd
open. Release it (monitorBody = nullptr) before the check.

S1: realMonitorLaunchWithCompletionSignal's detached thread called
set_value() while still holding its own bodyPtr reference, reintroducing the
I5 ordering hazard (a waiter could observe "done" before the thread released
its Sandbox2 handle). Reset bodyPtr before signalling.

Checked gates 7/8 for the same pattern: neither has an inline fd assertion
in-scope with a handle-holding local (their only fd check is
SFdBaselineFixture's post-return destructor), so no change needed there.
@valeriy42

valeriy42 commented Sep 9, 2026

Copy link
Copy Markdown
Contributor Author

Closing: opened against a stale fork copy of the base branch (origin's copy had diverged from the branch #3184 actually targets), so this PR was not correctly stacked. Rebased onto the real upstream base and reopened as #3187.

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