Skip to content

[ML] Typed routing behind the established controller protocol - #3188

Merged
valeriy42 merged 49 commits into
mainfrom
feature/sandbox2-pr-e-typed-routing
Sep 24, 2026
Merged

valeriy42 merged 49 commits into
mainfrom
feature/sandbox2-pr-e-typed-routing

Conversation

@valeriy42

@valeriy42 valeriy42 commented Sep 10, 2026 •

Copy link
Copy Markdown
Contributor

Summary

Stacks on #3187. Adds typed controller-side routing behind two symmetric controller tokens. Landlock fallback when Sandbox2 is unavailable is split to stacked #3215.

  • CCommandProcessor parses at most one of --disableSandbox (operator kill switch, forces legacy) or --requireSandbox (operator opt-in, forces Sandbox2 on capable hosts) for the exact configured pytorch_inference path. Duplicate occurrences of either token, a mismatched process path, or both tokens present together are all rejected before spawn. The selected token is stripped before the child ever sees it.
  • New CProcessSpawnerRouter dispatches the decided route to CSandboxedProcessSpawner or CDetachedProcessSpawner with no automatic fallback: a required Sandbox2 launch that fails returns a failed start response, it never retries through the legacy spawner.
  • A start command with neither token always takes the legacy route - permanent behaviour for any caller that sends no routing token (support/debug scripts, direct controller invocation, the test harness), not a rollout seam. Elasticsearch is expected to always send exactly one of the two tokens per launch, chosen from its own operator setting's live value.
  • Structured once-per-launch observability signal (sandbox2_launch: deployment_id, model_id, route, sandbox2_established, mode, legacy_reason, sandbox2_compiled_in) so a consumer can distinguish an operator kill switch from the no-token default, and "Sandbox2 supported but no routing token sent" from "built without Sandbox2 support."
  • ML_SANDBOXED is stripped from every legacy-route child's environment (POSIX and Windows) so an inherited or injected value can never fail-open the mandatory in-process seccomp filter.
  • Active Sandbox2 capability probe logged once at controller start (CSandbox2Diagnostics) so operators see whether --requireSandbox can succeed before a deployment fails closed.
  • Staged user-namespace capability probe plus CI wiring: aarch64 runs enforced-mode coverage; x86_64 runs fail-closed-only coverage, since x86_64 Buildkite k8s pods get EPERM on mount("proc", ...) and there is currently no userns-capable x86_64 CI runner.
  • Repaired test_sandbox2_attack_defense.py: reached markers, unsandboxed positive controls, per-case cleanup/reap assertions, the real ml-child-ipc/<child-id> IPC layout, an assertion that the controller's own sandbox2_launch signal reports the expected route before any security-boundary check runs, and explicit --requireSandbox/--disableSandbox tokens per case instead of a global env-var default.
  • Producer-side controller-protocol capability token (3rd_party/controller-protocol.version, now version 2) for a future cross-repo compatibility check.
  • Build packaging: per-library install_libs() guard and unconditional $ORIGIN RPATH on bundled ELF libraries so sibling .so dependencies resolve when the controller environment is cleared.

Filesystem-policy fixes found during enforced-mode qualification

End-to-end qualification on the qaf harness with xpack.ml.trained_models.sandbox_enabled=true (real mode:enforced route, not the legacy fallback) surfaced several launch/filesystem-policy gaps that previously only manifested once a real pytorch_inference ran under the enforced sandbox. Fixed here:

  • Per-child IPC directory is created before validation. The native controller now creates $TMPDIR/ml-child-ipc/<child-id> (mode 0700) before validateChildIpcLaunchSpec()'s live realpath() calls, on both spawn paths. Elasticsearch only ever constructs the IPC path strings; nothing created the directory they name.
  • Per-child IPC root is mounted at the same path inside and outside the sandbox (no /run/elastic/ml-ipc remap), because pytorch_inference receives its --input=/--output=/--restore=/--logPipe= argv as host paths under that root - a remap left them unresolvable in the sandbox mount namespace.
  • Sandbox2-specific syscall allowlist ported from the frozen reference ([ML] Add Sandbox2 security integration for PyTorch inference #2873): Sandbox2's namespace/threading setup exercises syscalls the legacy in-process BPF filter never needed, so legacyBpfAllowedSyscalls() alone is insufficient.
  • /proc is mounted (PID-namespaced) inside the sandbox rootfs. Sandbox2 mounts a fresh PID-namespaced procfs on the outer root, then pivot_roots into the chroot and detaches the old root, so the rootfs had no /proc. Without it readlink(/proc/self/exe) fails with ENOENT, breaking Intel oneMKL's runtime dispatcher (it reads /proc/self/exe to self-locate and dlopen its CPU-specific libmkl_*.so.3 kernels) - every enforced pytorch_inference aborted with Intel oneMKL FATAL ERROR: Cannot load <mkl-loader>. The mount binds the already-namespaced procfs (never the host's): inside the sandbox /proc shows only the sandboxee's own PIDs. /sys is left unmounted.
  • Regression test: ml_sandbox_probe now checks /proc/self/exe is readable inside the real sandbox, asserted by CPytorchInferenceSandboxPolicyMechanismTest_Linux - turning the MKL crash into a build-time failure.

Verified: ml-cpp sandbox unit tests 33/33; qaf boot check and test_scenario_buildly (8/8) pass under mode:enforced with zero MKL crashes and no legacy fallbacks.

@valeriy42
valeriy42 added this pull request to stack #3183 September 10, 2026 07:24
valeriy42 added a commit to valeriy42/elasticsearch that referenced this pull request Sep 10, 2026
The check hard-fails bundlePlugin/explodedBundlePlugin today because the
resolved ml-cpp SNAPSHOT does not yet publish controller-protocol.version
(companion change: elastic/ml-cpp#3188). That blocks every build on this
branch, including work unrelated to sandboxing. sandbox_enabled defaults to
false during this dark-launch window, so nothing downstream depends on the
mismatch yet.

Log a warning instead of throwing for now; revert to GradleException once
ml-cpp#3188 lands and mlCppVersion() resolves to an artifact containing the
file. Detection logic is unchanged.
valeriy42 added a commit to valeriy42/elasticsearch that referenced this pull request Sep 10, 2026
…ocked

The javadoc claimed the sandbox_enabled=false (default) path "uses
exactly the pipe layout the bundled ml-cpp controller already supports
today" and was therefore independent of the paired ml-cpp artifact.
That's false: PyTorchBuilder emits --disableSandbox unconditionally on
Linux at sandbox_enabled=false (PyTorchBuilder#buildCommand), and
today's bundled controller has no parsing for that token - it forwards
it verbatim to pytorch_inference, which aborts on the unrecognized CLI
option. So the default path is blocked on the paired artifact too, for
a different reason (unrecognized flag) than the explicit-enable path
(missing isolated IPC directory support).

Rewrite the class-level javadoc and the per-method javadoc on
testDefaultSandboxDisabledStartsAndInfersSuccessfully and
testChildIpcPathsIsolatedPerDeployment (both run at the default
setting) to state plainly that every test method in this class is
currently blocked on elastic/ml-cpp#3188, and
add @AwaitsFix to those two methods for consistency with the other two
- verifyControllerProtocolVersion now hard-fails the build before any
of these tests could run regardless of annotation, so all four should
carry the same accurate, skip-cleanly-not-error-loudly annotation.
@valeriy42
valeriy42 marked this pull request as ready for review September 10, 2026 14:19
@elasticsearchmachine

Copy link
Copy Markdown

Pinging @elastic/ml-core (Team:ML)

@valeriy42
valeriy42 force-pushed the feature/sandbox2-pr-e-typed-routing branch from f1bb15c to 3ffdca8 Compare September 18, 2026 07:04
@valeriy42
valeriy42 force-pushed the feature/sandbox2-pr-e-typed-routing branch from 3ffdca8 to bc2e6a9 Compare September 18, 2026 08:24
@valeriy42
valeriy42 requested a review from edsavage September 18, 2026 11:54
@valeriy42
valeriy42 force-pushed the feature/sandbox2-pr-e-typed-routing branch from bc2e6a9 to a74b251 Compare September 18, 2026 14:56
@valeriy42
valeriy42 force-pushed the feature/sandbox2-pr-e-typed-routing branch from a74b251 to 1765d61 Compare September 18, 2026 19:57
@valeriy42
valeriy42 requested a lite review from Copilot September 21, 2026 12:33

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot review overview

🟡 Changes recommended

The attack-defense harness currently destroys its inference binary and loses FIFO readers before child writers connect, with additional IPC, Windows, probe, and Sandbox2-route issues identified.

Get a fresh assessment by requesting another Copilot review.

Review effort: Lite
Findings: 1 High severity · 3 Medium severity

Open (4)
What changed in this PR

Adds typed Sandbox2/legacy routing for pytorch_inference, strengthens IPC/filesystem policy handling, and wires observability plus CI coverage.

Changes:

  • Adds symmetric routing tokens and fail-closed dispatch.
  • Extends Sandbox2 syscall, /proc, IPC, and environment protections.
  • Adds tests, attack-defense tooling, documentation, and protocol metadata.
File Description
test/​test_sandbox2_attack_defense.py End-to-end attack-defense harness
test/​evil_model_generator.py Generates test models
lib/​seccomp/​unittest/​CSeccompFilterBuilderTest.cc Seccomp regression tests
lib/​sandbox/​unittest/​payloads/​ml_sandbox_userns_probe.cc User-namespace capability probe
lib/​sandbox/​unittest/​payloads/​ml_sandbox_probe.cc Adds /proc/self/exe probe
lib/​sandbox/​unittest/​payloads/​lifecycle_signal_payload.cc Updates allowlist reference
lib/​sandbox/​unittest/​CSandboxUserNamespaceProbeTest_Linux.cc Validates probe modes
lib/​sandbox/​unittest/​CPytorchInferenceSandboxPolicyTest.cc IPC directory tests
lib/​sandbox/​unittest/​CPytorchInferenceSandboxPolicyMechanismTest_Linux.cc Mechanism assertions
lib/​sandbox/​unittest/​CMakeLists.txt Registers sandbox tests and payload
lib/​sandbox/​CSandboxedProcessSpawner_Linux.cc Creates IPC directories before validation
lib/​sandbox/​CPytorchInferenceSandboxPolicy.cc Adds mounts, syscall grants, and directory setup
lib/​sandbox/​CMakeLists.txt Updates sandbox documentation
lib/​core/​unittest/​CDetachedProcessSpawnerTest.cc Environment stripping tests
lib/​core/​CDetachedProcessSpawner.cc POSIX marker filtering
lib/​core/​CDetachedProcessSpawner_Windows.cc Windows Unicode environment filtering
include/​seccomp/​CSystemCallFilter.h Sandbox2-only seccomp startup
include/​seccomp/​CMlLegacyBpfSyscallAllowlist.h Sandbox2 syscall declarations
include/​sandbox/​CPytorchInferenceSandboxPolicy.h IPC directory API and contract
include/​core/​CDetachedProcessSpawner.h Environment helper declarations
docs/​sandbox2_production_failure_modes.md Operational signal documentation
dev-tools/​run_sandbox2_attack_defense.sh Harness launcher
build.gradle Publishes protocol metadata
bin/​pytorch_inference/​Main.cc Routes seccomp installation
bin/​controller/​unittest/​CProcessSpawnerRouterTest.cc Router tests
bin/​controller/​unittest/​CMakeLists.txt Registers router tests
bin/​controller/​unittest/​CCommandProcessorTest.cc Routing token tests
bin/​controller/​Main.cc Configures sandboxed process paths
bin/​controller/​CProcessSpawnerRouter.h Router API
bin/​controller/​CProcessSpawnerRouter.cc Route dispatch and signals
bin/​controller/​CMakeLists.txt Builds and links router
bin/​controller/​CCommandProcessor.h Uses router-based spawning
bin/​controller/​CCommandProcessor.cc Parses and strips routing tokens
3rd_party/​controller-protocol.version Sets protocol version 2
.buildkite/​scripts/​steps/​run_tests.sh Adds architecture-specific probe modes

💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

Comment on lines +204 to +207
if (::mkdir(dir.c_str(), 0700) == 0) {
return true;
}
return errno == EEXIST;

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 11dc1407f: makeChildIpcDirectory() now accepts EEXIST only when lstat shows an owner-only mode-0700 directory; a regular file or looser directory yields E_CreationFailed. Tests in CPytorchInferenceSandboxPolicyTest.cc.

Comment on lines +248 to +250
const std::wstring wideProcessPath{CStringUtils::narrowToWide(
processPathHasExeExt ? processPath : processPath + ".exe")};
std::wstring wideCmdLine{CStringUtils::narrowToWide(cmdLine)};

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No code change: argv paths are ASCII in practice; the pre-change CreateProcess (ANSI) path was equally lossy for non-ASCII environment values. The CreateProcessW switch was required for correct Unicode lpEnvironment handling when stripping ML_SANDBOXED.

Comment on lines +86 to +90
if (WIFEXITED(status) == 0) {
// Killed by a signal: not a meaningful staged result, but also not
// the specific exec-failure signature (126/127) - treat as a
// staged failure rather than a hard harness-broken failure.
return EProbeOutcome::E_StagedFailure;

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 11dc1407f: signaled probe payload is E_Crashed and fails the harness in every ML_SANDBOX2_REQUIRE mode.

Comment on lines +150 to +151
if not data:
break

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No code change: on Linux, FIFO hang-up is reported only after a writer has connected and left (pipe_poll writer count), so the reader does not exit on an empty read() before the child connects.

@valeriy42 valeriy42 changed the title [ML] PR E: typed routing behind the established controller protocol [ML] Typed routing behind the established controller protocol Sep 21, 2026
@valeriy42
valeriy42 force-pushed the feature/sandbox2-pr-e-typed-routing branch from c7b361b to 0721a82 Compare September 21, 2026 12:54
valeriy42 added a commit to elastic/elasticsearch that referenced this pull request Sep 21, 2026
The four sandbox_enabled tests were annotated @AwaitsFix pending the paired
ml-cpp artifact carrying the /proc mount and controller-protocol version-2
marker (elastic/ml-cpp#3188). With that artifact in place the tests run
green, so enable them. Sequencing note: the ml-cpp snapshot resolved by ES
CI must contain #3188 before this PR merges.
@valeriy42
valeriy42 force-pushed the feature/sandbox2-pr-e-typed-routing branch 2 times, most recently from fb346e0 to 0d366ad Compare September 21, 2026 18:30

@edsavage edsavage left a comment •

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Just a few changes and some nits below, plus one non-defect I want to state explicitly (the deliberately-off hard-termination switch). No real blockers.

if (::mkdir(dir.c_str(), 0700) == 0) {
return true;
}
return errno == EEXIST;

@edsavage edsavage Sep 22, 2026 •

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

There's a mismatch between docs and implementation: the comment above (and the E_CreationFailed enum doc in the header, "a non-directory in the way") says a non-directory occupying dir is reported as a failure, but mkdir on an existing regular file or symlink returns EEXIST, so this returns true (success). Only an ENOTDIR from a non-directory parent component would be caught. It's not exploitable (validateChildIpcLaunchSpec canonicalizes and rejects afterward via E_MutableSymlinkOrAlias/shape checks), but either fix the comment or tighten with an lstat/S_ISDIR check on the EEXIST branch so the code matches its stated contract.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same fix as the Copilot thread (11dc1407f).

// is not logged again here: an empty deployment_id in the signal is
// itself the observable symptom, and the Sandbox2 spawner (when that
// route is actually taken) logs the failure with detail.
ml::sandbox::ensureChildIpcDirectory(trustedTmpDir, args);

@edsavage edsavage Sep 22, 2026 •

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I can't spot anything in the router or spawner that ever removes $TMPDIR/ml-child-ipc/<child-id>, and this call now creates it on every sandbox-eligible spawn, including the legacy/degraded route, purely so the sandbox2_launch signal has a real deployment_id. So a plain no-token launch of pytorch_inference now leaves a 0700 directory behind even when Sandbox2 is never involved. I'm just wondering what reaps these across the child's lifetime and on controller restart? If Elasticsearch owns cleanup then a brief comment would help.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Documented on ensureChildIpcDirectory() in 11dc1407f: per-deployment directory, reused across restarts, empty after FIFO unlink. Removal tracked in #3214.

} else {
// Not a sandboxed process path: unrelated processes always go via
// the legacy spawner, unchanged from today's behaviour.
spawned = m_LegacySpawner.spawn(processPath, args, childPid);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If a caller passes route == E_Sandbox2 but processPath is not a configured sandboxed path, this falls through to the legacy spawner and emits no sandbox2_launch signal (sandboxEligible == false). CCommandProcessor makes this unreachable in production (it rejects --requireSandbox on non-sandboxed paths), but this router is a standalone public class whose contract permits the combination. A LOG_WARN/assert on "E_Sandbox2 requested for a non-sandboxed path" would harden it against future callers rather than silently downgrading to legacy.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Addressed in 11dc1407f by documenting on ERoute::E_Sandbox2 and the router else branch that the enum default is not a routing decision for non-sandboxed paths (so we do not LOG_WARN on every ./autodetect launch).

Comment thread bin/controller/Main.cc
// ./pytorch_inference always takes the legacy route (see
// CCommandProcessor), so listing it here fails nothing on macOS,
// Windows, or a Linux build without Sandbox2 support.
ml::controller::CCommandProcessor::TStrVec sandboxedProcessPaths{"./pytorch_inference"};

@edsavage edsavage Sep 22, 2026 •

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This comment ("fails nothing on macOS, Windows, or a Linux build without Sandbox2 support") is only true for the no-token path. Now that ./pytorch_inference is listed as being sandboxed on every platform, an explicit --requireSandbox for it on a non-Sandbox2 build routes to E_Sandbox2, then hits the router's #else, and then fails the launch. Which is all arguably correct (since the operator asked for a sandbox the build can't provide), but it's probably worth (a) confirming that ES never sends --requireSandbox on non-Linux, and (b) tightening this comment so it doesn't read as "no token ever fails here."

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Comment rewritten in 11dc1407f: no-token never fails; --requireSandbox without Sandbox2 fails closed on this PR; ES sends tokens only on Linux.

//! legacyBpfAllowedSyscalls() - tracked so sandbox2AllowsAllLegacySyscalls() can
//! assert Sandbox2 never grants strictly less than the legacy filter without
//! requiring every one of these to be repeated in sandbox2ExplicitSyscalls().
inline std::vector<int> sandbox2HelperCoveredSyscalls() {

@edsavage edsavage Sep 22, 2026 •

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

sandbox2AllowsAllLegacySyscalls() trusts this hand-maintained list to reflect what the Sandbox2 PolicyBuilder helpers (AllowRead/AllowWrite/...) actually grant. A future sandboxed-api upgrade could change helper coverage, and the assertion would still pass while the real applied policy diverges (granting less than the legacy filter). I think its worth a one-line note here that this list must be re-verified whenever the pinned Sandbox2 version is bumped.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Removed sandbox2AllowsAllLegacySyscalls() / sandbox2HelperCoveredSyscalls() in 11dc1407f — the policy builder’s legacyBpfAllowedSyscalls() loop is what actually mirrors the legacy filter.

__NR_getgid,
__NR_geteuid,
__NR_getegid,
ML_NR_statx,

@edsavage edsavage Sep 22, 2026 •

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This newly-added list (and also sandbox2ExplicitSyscalls() above) now depends on ML_NR_statx/ML_NR_rseq, which are only #defined inside the __x86_64__/__aarch64__ branches near the top of this header, yet are used unconditionally under __linux__. ML_NR_clone3 has a global fallback; these two don't, so a Linux build on any other arch fails with a confusing "undeclared identifier" from right here. Given that ml-cpp only supports x86_64/aarch64 today this is a minor nit, but a #else #error "unsupported architecture" closing the arch block would make the unsupported-arch failure explicit.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Added #else #error for unsupported Linux arch in 11dc1407f.

__NR_getpriority,
__NR_tgkill,
__NR_statfs,
__NR_connect,

@edsavage edsavage Sep 22, 2026 •

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

__NR_connect is granted to the Sandbox2 policy here (and also carried in the legacy list), but this policy adds no network namespace, so connect is actually reachable inside the sandbox. Is this because it's needed for the log-pipe/unix socket? Maybe add a justifying trailing comment? Just a one-liner on why connect (and mknod/mknodat) are required would keep the allowlist self-documenting.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Trailing comments on __NR_mknod* (mkfifo) and __NR_connect (AF_UNIX pipe connect) in sandbox2ExplicitSyscalls() in 11dc1407f.

// pytorch_inference receives its --input=/--output=/--restore=/--logPipe=
// argv from Elasticsearch as host paths under this root; a remap would
// leave those paths unresolvable inside the sandbox's own mount namespace.
policyBuilder.AddDirectory(validated.s_Spec.s_ChildIpcRoot, /*is_ro=*/false);

@edsavage edsavage Sep 22, 2026 •

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit on the /dev/null//dev/urandom//dev/random loop earlier in this function (inherited from #3187, and unchanged here, so anchored on this line): is_ro = std::strcmp(devFile, "/dev/null") != 0 is correct (only /dev/null is writable, the RNG devices read-only) but took me a while to understand. Again a one-line comment ("only /dev/null needs to be writable") would help readability.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

One-line /dev/null writable-only comment in 11dc1407f.

// sandbox2_launch signal reports as "route":"sandbox2".
const bool sandbox2Launched{ml::seccomp::sandbox2LaunchedChild()};
const ml::seccomp::SInProcessFilterResult seccompResult{ml::seccomp::applyInProcessSeccompFilter(
sandbox2Launched, TERMINATE_ON_DEGRADED_SECCOMP_FAILURE,

@edsavage edsavage Sep 22, 2026 •

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

With TERMINATE_ON_DEGRADED_SECCOMP_FAILURE deliberately false, a legacy-route launch whose in-process seccomp install fails (mechanism unavailable) still proceeds to process untrusted model input unsandboxed. This is intentional and very well documented here and in CSystemCallFilter.h, gated on the future guarantee that every launch carries an explicit token. We should make sure there's a tracked follow-up issue for the flip, since this is the one place the design is knowingly open.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Follow-up filed as #3213; Main.cc links it in 9eadbddd6. Landlock/step-down behaviour for hosts that cannot run Sandbox2 is in stacked #3215 (not this PR).

@edsavage edsavage left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM

Just a few non-blocking changes/nits

@valeriy42
valeriy42 force-pushed the feature/sandbox2-pr-e-typed-routing branch from 0d366ad to 5174aa8 Compare September 22, 2026 07:43
valeriy42 and others added 7 commits September 23, 2026 10:36
The clean rebuild's Sandbox2 policy builder grants syscalls to
pytorch_inference by looping over legacyBpfAllowedSyscalls() alone - the
same list used to build the legacy in-process BPF filter. That is not
sufficient: Sandbox2's namespace/threading setup makes pytorch_inference
exercise syscalls (scheduling, epoll, pipes, directory/file management for
forecast temp storage) that the simpler legacy filter never needed a grant
for, since it never sets up namespaces or Sandbox2's own thread/monitor
machinery.

#2873's enhancement/sandbox2 branch already solved this with
a dedicated sandbox2ExplicitSyscalls() list, granted in addition to what
Sandbox2's PolicyBuilder helpers (AllowRead/AllowWrite/AllowOpen/etc.)
cover implicitly. That list was never carried into this rebuild's
CPytorchInferenceSyscallAllowlist.h, so any syscall in it (confirmed via a
real run: sched_getaffinity) got denied and killed the sandboxed process.

Ports sandbox2ExplicitSyscalls() and sandbox2HelperCoveredSyscalls() from
the original branch, wires the explicit list into
buildPytorchInferencePolicy() alongside the existing legacy-list loop, and
adds sandbox2AllowsAllLegacySyscalls() plus a regression test so a future
change to legacyBpfAllowedSyscalls() without a corresponding Sandbox2 grant
fails loudly instead of silently regressing.
…box2

buildPytorchInferenceFilesystemPolicy() mounted spec.s_ChildIpcRoot into the
sandbox at a fixed remapped path (/run/elastic/ml-ipc) instead of its own
host path. Elasticsearch constructs pytorch_inference's
--input=/--output=/--restore=/--logPipe= argv using the real host path
under $TMPDIR/ml-child-ipc/<child-id>, so once Sandbox2 enforcement actually
engaged (after the previous two fixes), pytorch_inference could not find
its own pipes: it looked for them at the host path baked into its argv,
which does not exist inside its own mount namespace once mounted under a
different name.

Mounts the child IPC root at the same absolute path inside and outside the
sandbox instead, matching the convention every other mount in this policy
already uses (AddDirectory, not AddDirectoryAt). Updates the doc comments
in CPytorchInferenceSandboxPolicy.h and build.gradle's controller-protocol
contract comment that described the old remap, and updates
CPytorchInferenceSandboxPolicyMechanismTest_Linux's probe invocation to
pass the host-visible childRoot instead of the now-removed hardcoded
/run/elastic/ml-ipc literal.

Verified on the devbox: CPytorchInferenceSandboxPolicyMechanismTest_Linux
passes (ipc_readwrite: allowed), and
PyTorchSandboxIT#testConcurrentDeploymentsDoNotCollideUnderIsolatedChildIpcDir
passes end-to-end against a rebuilt ml-cpp artifact (0 failures, 0 errors).
Sandbox2 mounts a fresh, PID-namespaced procfs at /proc on the outer root
before it builds and pivots into the sandbox chroot, then detaches the old
root - so the pivoted rootfs has no /proc unless the policy adds one. The
E_MountNamespacedProcfs case was a no-op on the false assumption that
Sandbox2 provides /proc inside the rootfs automatically.

With /proc absent, readlink(/proc/self/exe) and open(/proc/self/maps) both
fail with ENOENT. Intel oneMKL's runtime dispatcher reads /proc/self/exe to
self-locate and dlopen its CPU-specific libmkl_*.so.3 kernels; the failed
read makes it abort with "Intel oneMKL FATAL ERROR: Cannot load
<mkl-loader>", killing every sandboxed pytorch_inference under enforced
Sandbox2.

Bind /proc into the rootfs. Because Sandbox2 mounts the fresh procfs after
CLONE_NEWPID and before PrepareChroot, the bind captures that already
namespaced procfs (never the host's): verified inside the sandbox, /proc
shows only the sandboxee's own PIDs (2) versus 194 on the host. /sys is left
unmounted (E_Skip) - no fresh namespaced /sys exists to bind outside a
network namespace, and pytorch_inference/libtorch run without it.

Verified on the qaf local-deployment harness with
xpack.ml.trained_models.sandbox_enabled=true (mode:enforced): the
trained-model boot check and 7/8 test_scenario_buildly inference scenarios
pass with zero MKL crashes across all enforced launches.
The Sandbox2 filesystem policy had no test exercising /proc, so the missing
/proc mount that broke Intel oneMKL's runtime dispatcher went undetected. Add
a proc_self_exe mechanism to ml_sandbox_probe (readlink /proc/self/exe inside
the real sandbox) and assert it in the policy mechanism test. Without the
/proc mount this readlink fails with ENOENT and the check reports
"unreadable", turning the "Cannot load <mkl-loader>" crash into a build-time
test failure.
@valeriy42
valeriy42 force-pushed the feature/sandbox2-pr-e-typed-routing branch from 4d05aad to 0d432b1 Compare September 23, 2026 08:36
valeriy42 and others added 7 commits September 23, 2026 12:09
install_libs() guarded its per-library copy loop with a single coarse
glob: "did the source directory contain anything at all matching
*<prefix>*<postfix>?". Any unrelated library sharing that prefix and
suffix satisfied the guard, after which every individual library that
did not match was skipped silently and the build still reported success.

On a toolchain whose Intel MKL SONAMEs have moved to .so.3, the
unrelated libmkl_scalapack_*.so.2 / libmkl_cdft_core.so.2 cluster
libraries kept the guard happy while all twelve requested MKL libraries
were skipped, producing a distribution in which pytorch_inference cannot
resolve libmkl_intel_lp64.so.3, libmkl_gnu_thread.so.3 or
libmkl_core.so.3. The dynamic loader then kills the process with exit
code 127 before it has reconfigured logging, so there is no log pipe and
no diagnostic anywhere - Elasticsearch only sees the named pipe never
appear.

Check each requested library individually and fail the build with the
name of the pattern that did not match, so a toolchain change can never
again silently produce an unrunnable distribution.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Set the RPATH of every bundled 3rd-party library to $ORIGIN
unconditionally, rather than only overwriting libraries that already
declared one. Some prebuilt libraries (notably Boost) ship with no RPATH
at all, and because DT_RUNPATH is not inherited transitively a library
with a sibling dependency (e.g. libboost_log -> libboost_atomic) failed
to load at runtime even though the dependency sits right beside it.
Elasticsearch's Spawner clears the native controller's environment, so
RPATH is the sole resolution mechanism - there is no LD_LIBRARY_PATH
fallback. Also skip non-ELF files via patchelf exit status.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
A failed RunAsync() discarded the Result returned by AwaitResult() and
logged only "Sandbox2 failed to start <path>". This is a fail-closed path
with no legacy fallback, so the deployment start fails outright and the
router's sandbox2_launch signal can only report mode="fail_closed" - it
has no field for a cause. The sandboxee's own stderr dies with the
sandboxee, so an operator was left with a launch that failed for no
stated reason.

Log the Result's status and reason code instead, which distinguishes (for
example) a SETUP_ERROR/FAILED_SUBPROCESS - the forkserver could not
create its initial namespaces, typical of a container runtime whose
seccomp policy blocks clone(CLONE_NEWUSER) - from a policy VIOLATION or
an internal error.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
A host that denies any one of Sandbox2's forkserver startup prerequisites
fails every --requireSandbox launch with the same opaque
SETUP_ERROR/FAILED_SUBPROCESS result. The real reason is written to the
sandboxee's stderr, which dies with the sandboxee; the controller's own
stderr is redirected onto the ML log pipe, where Elasticsearch discards
anything not framed as a JSON log message (CppLogMessageHandler stores it
as a fatal-error string that is only surfaced when the process dies, and
the controller does not die). So the cause was unrecoverable from ES logs.

Restore CSandbox2Diagnostics, dropped when ml-cpp#2873 was recreated as
the PR A-E stack, and make it active rather than passive. The original
read kernel.unprivileged_userns_clone and user.max_user_namespaces; both
are host-global and are inherited unchanged by a container whose seccomp
or LSM policy denies the operation anyway, so on their own they report a
healthy environment on exactly the hosts where the sandbox cannot start.

The probe instead performs the forkserver's own sequence in throwaway
children - unshare(CLONE_NEWUSER), id-map writes,
unshare(CLONE_NEWNS|CLONE_NEWPID), fork, mount(tmpfs), mount(procfs) -
and names the step that was denied. Verified against four container
configurations whose behaviour was established independently: default
Docker reports E_UserNamespaceDenied, seccomp=unconfined reports
E_TmpfsMountDenied, seccomp+apparmor=unconfined reports
E_ProcMountDenied, and all three unconfined reports E_Available. The
tmpfs case is the one where a real launch deadlocks rather than failing,
so the probe is also the only signal available for it.

Logged once at controller start, not lazily on first launch: an operator
deciding whether to enable xpack.ml.trained_models.sandbox_enabled needs
the answer before a deployment fails closed. Runs entirely in forked
children, so the controller's own namespaces and mount table are never
touched.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
testSandbox2ExplicitSyscallsCarriedForwardFromPr2873 did not compile: it
called ml::seccomp::pytorch_inference::sandbox2AllowsAllLegacySyscalls(),
but the function lives in ml::seccomp - a leftover from the ml-cpp#2873
layout, where it sat in a pytorch_inference sub-namespace. So the whole
ml_test_seccomp target failed to build on this branch.

Fixing the qualifier exposed a second, latent bug in the same test. It
built its set as

    std::set<int>{sandbox2ExplicitSyscalls().begin(),
                  sandbox2ExplicitSyscalls().end()}

and sandbox2ExplicitSyscalls() returns std::vector<int> by value, so
begin() and end() came from two different temporaries. Walking from one
buffer to the end of another is undefined behaviour: the test passed when
run on its own and segfaulted inside the full suite, depending only on
heap layout. Call it once and build the set from that vector.

Verified: the full ml_test_seccomp suite now passes on five consecutive
runs, where it previously segfaulted in this test case.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…lity

The controller marks itself non-dumpable (PR_SET_DUMPABLE=0) during
startup, and probeSandbox2Capability()'s forked child inherited that. A
non-dumpable process's /proc/self files are owned by root, so the child's
open("/proc/self/uid_map") failed with EACCES and the probe reported
E_IdMapWriteDenied on a host that fully supports Sandbox2.

It went unnoticed because the one production caller, the startup
self-check, happens to run just before makeProcessNonDumpable(). Any
caller after that point - for example a routing decision taken at spawn
time - got the wrong answer, and the logged self-check and the decision
would silently disagree. Sandbox2 itself is unaffected: its forkserver is
freshly exec()ed, which resets dumpability.

Restore dumpability in the throwaway probe child, matching the state the
real forkserver starts in. The caller's own dumpability is untouched.

The new regression test runs the probe from a non-dumpable child and
requires the same verdict as from a dumpable caller. Verified it fails
without this change (E_IdMapWriteDenied vs E_Available) and passes with
it; the full ml_test_sandbox suite passes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Tighten ensureChildIpcDirectory EEXIST handling, document router/ERoute
semantics, remove the non-enforcing sandbox2AllowsAllLegacySyscalls check,
harden the userns probe harness against signal deaths, and correct the
sandbox2_launch route/mode documentation ahead of splitting Landlock work.
valeriy42 and others added 4 commits September 24, 2026 10:19
Setting $ORIGIN on every bundled library (085b8de) also rewrote the Intel
MKL libraries, which previously shipped untouched. On glibc 2.34 (Amazon
Linux 2023, the Java Inference Integration Test agents) pytorch_inference
then dies with SIGSEGV during the first inference of ELSER and E5, shortly
after installing its seccomp filter, and Elasticsearch reports "inference
canceled as process is stopping". Tiny test models and newer glibc are
unaffected, which is why the multi-node tests and local runs still passed.

Reproduced with the CI build image and an AL2023 container: the same
binaries fail with the MKL libraries patched and pass with only those
libraries restored. MKL does not need the RPATH: libtorch_cpu (already
$ORIGIN) pulls in the MKL entry points, and the kernels MKL dlopen()s
only NEED libmkl_core, which is already loaded.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@valeriy42
valeriy42 merged commit 16c5935 into main Sep 24, 2026
24 checks passed
valeriy42 added a commit that referenced this pull request Sep 25, 2026
## Summary

Stacks on #3188. When Elasticsearch sends `--requireSandbox` but the host cannot run Sandbox2 (ECH allocators with user namespaces disabled, container seccomp blocking `CLONE_NEWUSER`, and similar), the controller steps down to a Landlock filesystem ruleset plus the existing in-process seccomp filter instead of failing every deployment closed.

- `hostConfinement()` probes once at controller start (same cached verdict as the startup self-check) and walks a fixed ladder: Sandbox2, then Landlock, then refusal with an operator-actionable message returned to Elasticsearch.
- New `sandbox2_launch` mode `"landlock"` (`route:"sandbox2"`, `sandbox2_established:false`). Consumers must not treat `route` alone as full isolation.
- Router-only `--restrictFilesystem` token; `CCommandProcessor` rejects caller-supplied copies.
- Documented in `docs/sandbox2_production_failure_modes.md`. Companion: elastic/elasticsearch#159052 must accept `mode:"landlock"`.

A failed Sandbox2 launch on a host that can run Sandbox2 is never retried under Landlock.
valeriy42 added a commit to elastic/elasticsearch that referenced this pull request Sep 25, 2026
## Summary

Adds `xpack.ml.trained_models.sandbox_enabled` (operator-dynamic, node-scoped, default `false`) to control whether `pytorch_inference` runs under Sandbox2 or the legacy `posix_spawn` + in-process-seccomp path, plus the isolated per-child IPC layout and validation Sandbox2 needs.

- `false` (default): `PyTorchBuilder` sends `--disableSandbox` on Linux; the controller uses the legacy spawner. Dark-launched for 9.6 — `true` is not yet supported for on-prem deployments.
- `true`: sends `--requireSandbox` on Linux instead, so the controller must use Sandbox2, with no automatic fallback to legacy on a failed spawn attempt. Non-Linux nodes send neither token either way (Sandbox2 is Linux-only).
- Both are explicit, per-launch tokens on the controller command channel, never omitted and never inferred from a controller-side default. A dynamic setting change only affects deployments started after the change; already-running deployments keep their original launch mode.

## Isolated per-child IPC directory (Linux, `sandbox_enabled=true`)

`NamedPipeHelper`/`ProcessPipes` gained a second pipe-naming layout: when isolation is requested, `input`/`output`/`restore`/`logPipe` all live under `$TMPDIR/ml-child-ipc/<deployment_id>/` instead of the legacy flat, pid-suffixed names. Elasticsearch only constructs the path strings; the native controller creates and owns the `0700` directory. Autodetect/DFA/normalizer callers are unaffected and keep the legacy layout.

## `deployment_id` validation

`StartTrainedModelDeploymentAction.Request#validate()` rejects a `deployment_id` that isn't safe as a single path component (empty, `.`, `..`, or containing `/`, `\`, or NUL) since it shapes the isolated IPC path. Deliberately narrower than the ML plugin's general `isValidId` charset check, so existing (possibly mixed-case) inference-endpoint ids used verbatim as `deployment_id` keep working. `NamedPipeHelper#getChildIpcDirectoryPrefix` re-checks the same property as defense-in-depth at the point the path is actually built.

## Cross-repo dependency (must merge as a coordinated pair)

Companion: elastic/ml-cpp#3188. That PR defines the `--disableSandbox`/`--requireSandbox` controller wire protocol and bumps `controller-protocol.version` to 2. `x-pack/plugin/ml/build.gradle`'s `verifyControllerProtocolVersion` task requires >= 2 and hard-fails the build if the resolved ml-cpp bundle doesn't publish it — do not merge this ahead of the ml-cpp dependency bump.

## Tests

- `PyTorchBuilderTests`: `--disableSandbox` / `--requireSandbox` emitted correctly per setting value, on Linux only.
- `ProcessPipesTests`, `NamedPipeHelperTests`: legacy naming unchanged byte-for-byte; isolated naming construction; childId safety guard.
- `NativePyTorchProcessFactoryTests`: the `sandboxEnabled` snapshot is shared between the isolation-flag and command-line-token decisions within one launch.
- `StartTrainedModelDeploymentRequestTests`: `deployment_id` path-safety, including packaged/default model ids and `_SNAPSHOT` suffixes.
- `PyTorchSandboxIT`: default/explicit-setting deployment start and inference, concurrent-deployment isolation under legacy naming. The full enforced-sandbox case is `@AwaitsFix` pending the ml-cpp artifact.

## Notes for reviewers

- Superseding #154825, which carried approvals against an earlier design iteration (default `true`, ml-cpp#2873, no IPC isolation, single-token protocol). Re-review requested.
- Labels to apply: `:ml`, `>enhancement`, `v9.6.0`.
- Surfacing/countering ml-cpp's structured enforced-mode signal (deployment_id/model_id/route/mode) is tracked as a separate follow-up, not implemented here.

### Companion PRs

- elastic/ml-cpp#3188 — defines the two-token controller protocol this PR sends
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants