From df8c5bf1091d4c53d0bb5010c75065a05a5de1ac Mon Sep 17 00:00:00 2001
From: Valeriy Khakhutskyy <1292899+valeriy42@users.noreply.github.com>
Date: Fri, 18 Sep 2026 10:24:06 +0200
Subject: [PATCH 01/10] [ML] Land dormant Sandbox2/Abseil dependency foundation
(#3181)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
## Summary
Reconstructs the dormant dependency/build foundation for Sandbox2 from
current `main`, as a clean first slice ahead of the sandbox policy,
spawner, and controller-routing changes that land in follow-up PRs. No
controller or `pytorch_inference` routing changes in this PR — Sandbox2
is not selectable from any production code path yet.
Frozen PR #2873 attempted this feature in one large branch; this PR
takes just its dependency/build layer and replaces its inline
`file(WRITE)`/`string(REGEX REPLACE)` source rewrites with checked-in,
version-pinned patches that fail the configure step loudly instead of
silently no-op'ing on upstream drift.
- `3rd_party/CMakeLists.txt`: FetchContent `sandboxed-api` `v20241008` on
Linux, applying 4 checked-in patches via `git apply` (fails configure
loudly on upstream drift, idempotent across reconfigure).
- `3rd_party/patches/sandboxed-api/`: the 4 patches (disable vendored
gtest, stop `-fno-exceptions` propagating into ml-cpp targets, make
Python3 optional, link zlib + static libstdc++/libgcc into the
forkserver binary).
- `3rd_party/licenses/{abseil,sandbox2}-*`: license/attribution files.
- `lib/sandbox/`: dormant `MlSandbox` target (`CMlSandboxAvailability`
query only — no policy/spawner/diagnostics) plus a Linux-only
forkserver runtime smoke test that forks/execs/reaps a
dynamically-linked payload via `PolicyBuilder::AddLibrariesForBinary()`.
Verified locally (Linux x86_64, both a plain configure and
`-DCMAKE_UNITY_BUILD=ON`) before pushing, and green on this repo's own
Linux/macOS/Windows CI, license/security scans, and Java integration
suites.
Stack created with GitHub Stacks CLI • Give Feedback 💬
(cherry picked from commit 4a8b7ae337cbb464c741281c8a5e9d5ea594a88b)
---
3rd_party/CMakeLists.txt | 118 ++++++++++
3rd_party/licenses/abseil-INFO.csv | 2 +
3rd_party/licenses/abseil-LICENSE.txt | 202 ++++++++++++++++++
3rd_party/licenses/abseil-NOTICE.txt | 0
3rd_party/licenses/sandbox2-INFO.csv | 2 +
3rd_party/licenses/sandbox2-LICENSE.txt | 202 ++++++++++++++++++
3rd_party/licenses/sandbox2-NOTICE.txt | 0
.../0001-abseil-cpp-disable-gtest.patch | 15 ++
.../0002-no-fno-exceptions-propagation.patch | 17 ++
.../sandboxed-api/0003-python3-optional.patch | 25 +++
...004-forkserver-zlib-static-libstdcxx.patch | 22 ++
3rd_party/patches/sandboxed-api/README.md | 41 ++++
include/sandbox/CMlSandboxAvailability.h | 43 ++++
lib/CMakeLists.txt | 1 +
lib/sandbox/CMakeLists.txt | 46 ++++
lib/sandbox/CMlSandboxAvailability.cc | 24 +++
lib/sandbox/unittest/CMakeLists.txt | 62 ++++++
.../unittest/CMlSandboxAvailabilityTest.cc | 25 +++
.../unittest/CSandboxForkserverSmokeTest.cc | 76 +++++++
lib/sandbox/unittest/Main.cc | 30 +++
.../payloads/sandbox_smoke_payload.cc | 26 +++
test/CMakeLists.txt | 1 +
22 files changed, 980 insertions(+)
create mode 100644 3rd_party/licenses/abseil-INFO.csv
create mode 100644 3rd_party/licenses/abseil-LICENSE.txt
create mode 100644 3rd_party/licenses/abseil-NOTICE.txt
create mode 100644 3rd_party/licenses/sandbox2-INFO.csv
create mode 100644 3rd_party/licenses/sandbox2-LICENSE.txt
create mode 100644 3rd_party/licenses/sandbox2-NOTICE.txt
create mode 100644 3rd_party/patches/sandboxed-api/0001-abseil-cpp-disable-gtest.patch
create mode 100644 3rd_party/patches/sandboxed-api/0002-no-fno-exceptions-propagation.patch
create mode 100644 3rd_party/patches/sandboxed-api/0003-python3-optional.patch
create mode 100644 3rd_party/patches/sandboxed-api/0004-forkserver-zlib-static-libstdcxx.patch
create mode 100644 3rd_party/patches/sandboxed-api/README.md
create mode 100644 include/sandbox/CMlSandboxAvailability.h
create mode 100644 lib/sandbox/CMakeLists.txt
create mode 100644 lib/sandbox/CMlSandboxAvailability.cc
create mode 100644 lib/sandbox/unittest/CMakeLists.txt
create mode 100644 lib/sandbox/unittest/CMlSandboxAvailabilityTest.cc
create mode 100644 lib/sandbox/unittest/CSandboxForkserverSmokeTest.cc
create mode 100644 lib/sandbox/unittest/Main.cc
create mode 100644 lib/sandbox/unittest/payloads/sandbox_smoke_payload.cc
diff --git a/3rd_party/CMakeLists.txt b/3rd_party/CMakeLists.txt
index f2b092f913..5d1c612f4e 100644
--- a/3rd_party/CMakeLists.txt
+++ b/3rd_party/CMakeLists.txt
@@ -39,3 +39,121 @@ execute_process(
COMMAND ${CMAKE_COMMAND} -P ./pull-valijson.cmake
WORKING_DIRECTORY ${CMAKE_CURRENT_SOURCE_DIR}
)
+
+# Build Abseil and Sandbox2 on Linux only. MlSandbox (lib/sandbox) is a
+# dormant target: it is built everywhere Sandbox2 is available, but nothing
+# in the controller/pytorch_inference wiring routes to it yet. The sandbox
+# policy, spawner, and controller routing land in follow-up PRs.
+if (CMAKE_SYSTEM_NAME STREQUAL "Linux")
+ include(FetchContent)
+
+ # Save and restore CMake cache state this block flips so it cannot change
+ # the caller's build configuration for anything outside Sandbox2/Abseil.
+ set(_saved_BUILD_TESTING ${BUILD_TESTING})
+ set(BUILD_TESTING OFF CACHE BOOL "" FORCE)
+ set(_saved_BUILD_SHARED_LIBS ${BUILD_SHARED_LIBS})
+ set(BUILD_SHARED_LIBS OFF CACHE BOOL "" FORCE)
+
+ # The vendored Abseil and Sandboxed API sources are not unity-build safe:
+ # e.g. absl_time_zone defines kDigits in an anonymous namespace in both
+ # time_zone_fixed.cc and time_zone_posix.cc, which collide when merged
+ # into one unity translation unit. The top-level build configures
+ # -DCMAKE_UNITY_BUILD=ON, so disable it for these third-party targets only.
+ set(_saved_CMAKE_UNITY_BUILD ${CMAKE_UNITY_BUILD})
+ set(CMAKE_UNITY_BUILD OFF)
+
+ set(ABSL_PROPAGATE_CXX_STD ON CACHE INTERNAL "" FORCE)
+ set(ABSL_USE_EXTERNAL_GOOGLETEST OFF CACHE INTERNAL "" FORCE)
+ set(ABSL_FIND_GOOGLETEST OFF CACHE INTERNAL "" FORCE)
+ set(ABSL_ENABLE_INSTALL OFF CACHE INTERNAL "" FORCE)
+ set(ABSL_BUILD_TESTING OFF CACHE INTERNAL "" FORCE)
+ set(ABSL_BUILD_TEST_HELPERS OFF CACHE INTERNAL "" FORCE)
+ set(SAPI_BUILD_EXAMPLES OFF CACHE BOOL "" FORCE)
+ set(SAPI_BUILD_TESTING OFF CACHE BOOL "" FORCE)
+
+ set(ML_SANDBOXED_API_TAG v20241008)
+ set(ML_SANDBOXED_API_GIT_SHA 9e07542a03fefa2cf982ba093b099805362df05d)
+ set(ML_SANDBOXED_API_PATCH_DIR ${CMAKE_CURRENT_SOURCE_DIR}/patches/sandboxed-api)
+ set(ML_SANDBOXED_API_PATCHES
+ 0001-abseil-cpp-disable-gtest.patch
+ 0002-no-fno-exceptions-propagation.patch
+ 0003-python3-optional.patch
+ 0004-forkserver-zlib-static-libstdcxx.patch
+ )
+
+ FetchContent_Declare(
+ sandboxed-api
+ GIT_REPOSITORY https://github.com/google/sandboxed-api.git
+ GIT_TAG ${ML_SANDBOXED_API_GIT_SHA}
+ )
+
+ FetchContent_GetProperties(sandboxed-api)
+ if(NOT sandboxed-api_POPULATED)
+ FetchContent_Populate(sandboxed-api)
+
+ find_package(Git REQUIRED)
+ foreach(_patch ${ML_SANDBOXED_API_PATCHES})
+ # Re-running configure in an existing build directory can re-enter this
+ # block even though the checked-out source was already patched in an
+ # earlier configure (observed: FetchContent's populated-tracking does
+ # not reliably short-circuit this across separate `cmake` invocations
+ # on every CMake/generator combination). `git apply --check` alone
+ # cannot distinguish "already applied" from "genuinely drifted" - both
+ # fail to apply cleanly - so try a reverse-check first: if the patch
+ # reverses cleanly, its change is already present and this is the
+ # idempotent-rerun case, not drift.
+ execute_process(
+ COMMAND ${GIT_EXECUTABLE} apply --reverse --check ${ML_SANDBOXED_API_PATCH_DIR}/${_patch}
+ WORKING_DIRECTORY ${sandboxed-api_SOURCE_DIR}
+ RESULT_VARIABLE _patch_already_applied_result
+ OUTPUT_QUIET
+ ERROR_QUIET
+ )
+ if(_patch_already_applied_result EQUAL 0)
+ message(STATUS "sandboxed-api patch already applied (reconfigure): ${_patch}")
+ continue()
+ endif()
+
+ execute_process(
+ COMMAND ${GIT_EXECUTABLE} apply --check ${ML_SANDBOXED_API_PATCH_DIR}/${_patch}
+ WORKING_DIRECTORY ${sandboxed-api_SOURCE_DIR}
+ RESULT_VARIABLE _patch_check_result
+ OUTPUT_QUIET
+ ERROR_VARIABLE _patch_check_error
+ )
+ if(NOT _patch_check_result EQUAL 0)
+ message(FATAL_ERROR
+ "sandboxed-api patch ${_patch} no longer applies to pinned tag "
+ "${ML_SANDBOXED_API_TAG} (${ML_SANDBOXED_API_GIT_SHA}) - the "
+ "upstream source has drifted since this patch was written. "
+ "Regenerate it against the current tag content (see "
+ "3rd_party/patches/sandboxed-api/README.md).\n"
+ "${_patch_check_error}")
+ endif()
+ execute_process(
+ COMMAND ${GIT_EXECUTABLE} apply ${ML_SANDBOXED_API_PATCH_DIR}/${_patch}
+ WORKING_DIRECTORY ${sandboxed-api_SOURCE_DIR}
+ RESULT_VARIABLE _patch_apply_result
+ ERROR_VARIABLE _patch_apply_error
+ )
+ if(NOT _patch_apply_result EQUAL 0)
+ message(FATAL_ERROR "sandboxed-api patch ${_patch} failed to apply: ${_patch_apply_error}")
+ endif()
+ message(STATUS "Applied sandboxed-api patch: ${_patch}")
+ endforeach()
+ endif()
+
+ add_subdirectory(${sandboxed-api_SOURCE_DIR} ${sandboxed-api_BINARY_DIR} EXCLUDE_FROM_ALL)
+
+ if(TARGET sandbox2::sandbox2)
+ set(SANDBOX2_LIBRARIES sandbox2::sandbox2 CACHE INTERNAL "Sandbox2 libraries")
+ message(STATUS "Sandbox2 enabled: using sandbox2::sandbox2")
+ else()
+ message(FATAL_ERROR "Sandbox2 required on Linux but sandbox2::sandbox2 was not built")
+ endif()
+
+ # Restore the caller's settings for the rest of the build.
+ set(BUILD_TESTING ${_saved_BUILD_TESTING} CACHE BOOL "" FORCE)
+ set(BUILD_SHARED_LIBS ${_saved_BUILD_SHARED_LIBS} CACHE BOOL "" FORCE)
+ set(CMAKE_UNITY_BUILD ${_saved_CMAKE_UNITY_BUILD})
+endif()
diff --git a/3rd_party/licenses/abseil-INFO.csv b/3rd_party/licenses/abseil-INFO.csv
new file mode 100644
index 0000000000..8f3404a519
--- /dev/null
+++ b/3rd_party/licenses/abseil-INFO.csv
@@ -0,0 +1,2 @@
+name,version,revision,url,license,copyright,sourceURL
+abseil-cpp,2024-04-05,61e47a454c81eb07147b0315485f476513cc1230,https://abseil.io,Apache License 2.0,,https://github.com/abseil/abseil-cpp/archive/61e47a454c81eb07147b0315485f476513cc1230.zip
diff --git a/3rd_party/licenses/abseil-LICENSE.txt b/3rd_party/licenses/abseil-LICENSE.txt
new file mode 100644
index 0000000000..62589edd12
--- /dev/null
+++ b/3rd_party/licenses/abseil-LICENSE.txt
@@ -0,0 +1,202 @@
+
+ Apache License
+ Version 2.0, January 2004
+ https://www.apache.org/licenses/
+
+ TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
+
+ 1. Definitions.
+
+ "License" shall mean the terms and conditions for use, reproduction,
+ and distribution as defined by Sections 1 through 9 of this document.
+
+ "Licensor" shall mean the copyright owner or entity authorized by
+ the copyright owner that is granting the License.
+
+ "Legal Entity" shall mean the union of the acting entity and all
+ other entities that control, are controlled by, or are under common
+ control with that entity. For the purposes of this definition,
+ "control" means (i) the power, direct or indirect, to cause the
+ direction or management of such entity, whether by contract or
+ otherwise, or (ii) ownership of fifty percent (50%) or more of the
+ outstanding shares, or (iii) beneficial ownership of such entity.
+
+ "You" (or "Your") shall mean an individual or Legal Entity
+ exercising permissions granted by this License.
+
+ "Source" form shall mean the preferred form for making modifications,
+ including but not limited to software source code, documentation
+ source, and configuration files.
+
+ "Object" form shall mean any form resulting from mechanical
+ transformation or translation of a Source form, including but
+ not limited to compiled object code, generated documentation,
+ and conversions to other media types.
+
+ "Work" shall mean the work of authorship, whether in Source or
+ Object form, made available under the License, as indicated by a
+ copyright notice that is included in or attached to the work
+ (an example is provided in the Appendix below).
+
+ "Derivative Works" shall mean any work, whether in Source or Object
+ form, that is based on (or derived from) the Work and for which the
+ editorial revisions, annotations, elaborations, or other modifications
+ represent, as a whole, an original work of authorship. For the purposes
+ of this License, Derivative Works shall not include works that remain
+ separable from, or merely link (or bind by name) to the interfaces of,
+ the Work and Derivative Works thereof.
+
+ "Contribution" shall mean any work of authorship, including
+ the original version of the Work and any modifications or additions
+ to that Work or Derivative Works thereof, that is intentionally
+ submitted to Licensor for inclusion in the Work by the copyright owner
+ or by an individual or Legal Entity authorized to submit on behalf of
+ the copyright owner. For the purposes of this definition, "submitted"
+ means any form of electronic, verbal, or written communication sent
+ to the Licensor or its representatives, including but not limited to
+ communication on electronic mailing lists, source code control systems,
+ and issue tracking systems that are managed by, or on behalf of, the
+ Licensor for the purpose of discussing and improving the Work, but
+ excluding communication that is conspicuously marked or otherwise
+ designated in writing by the copyright owner as "Not a Contribution."
+
+ "Contributor" shall mean Licensor and any individual or Legal Entity
+ on behalf of whom a Contribution has been received by Licensor and
+ subsequently incorporated within the Work.
+
+ 2. Grant of Copyright License. Subject to the terms and conditions of
+ this License, each Contributor hereby grants to You a perpetual,
+ worldwide, non-exclusive, no-charge, royalty-free, irrevocable
+ copyright license to reproduce, prepare Derivative Works of,
+ publicly display, publicly perform, sublicense, and distribute the
+ Work and such Derivative Works in Source or Object form.
+
+ 3. Grant of Patent License. Subject to the terms and conditions of
+ this License, each Contributor hereby grants to You a perpetual,
+ worldwide, non-exclusive, no-charge, royalty-free, irrevocable
+ (except as stated in this section) patent license to make, have made,
+ use, offer to sell, sell, import, and otherwise transfer the Work,
+ where such license applies only to those patent claims licensable
+ by such Contributor that are necessarily infringed by their
+ Contribution(s) alone or by combination of their Contribution(s)
+ with the Work to which such Contribution(s) was submitted. If You
+ institute patent litigation against any entity (including a
+ cross-claim or counterclaim in a lawsuit) alleging that the Work
+ or a Contribution incorporated within the Work constitutes direct
+ or contributory patent infringement, then any patent licenses
+ granted to You under this License for that Work shall terminate
+ as of the date such litigation is filed.
+
+ 4. Redistribution. You may reproduce and distribute copies of the
+ Work or Derivative Works thereof in any medium, with or without
+ modifications, and in Source or Object form, provided that You
+ meet the following conditions:
+
+ (a) You must give any other recipients of the Work or
+ Derivative Works a copy of this License; and
+
+ (b) You must cause any modified files to carry prominent notices
+ stating that You changed the files; and
+
+ (c) You must retain, in the Source form of any Derivative Works
+ that You distribute, all copyright, patent, trademark, and
+ attribution notices from the Source form of the Work,
+ excluding those notices that do not pertain to any part of
+ the Derivative Works; and
+
+ (d) If the Work includes a "NOTICE" text file as part of its
+ distribution, then any Derivative Works that You distribute must
+ include a readable copy of the attribution notices contained
+ within such NOTICE file, excluding those notices that do not
+ pertain to any part of the Derivative Works, in at least one
+ of the following places: within a NOTICE text file distributed
+ as part of the Derivative Works; within the Source form or
+ documentation, if provided along with the Derivative Works; or,
+ within a display generated by the Derivative Works, if and
+ wherever such third-party notices normally appear. The contents
+ of the NOTICE file are for informational purposes only and
+ do not modify the License. You may add Your own attribution
+ notices within Derivative Works that You distribute, alongside
+ or as an addendum to the NOTICE text from the Work, provided
+ that such additional attribution notices cannot be construed
+ as modifying the License.
+
+ You may add Your own copyright statement to Your modifications and
+ may provide additional or different license terms and conditions
+ for use, reproduction, or distribution of Your modifications, or
+ for any such Derivative Works as a whole, provided Your use,
+ reproduction, and distribution of the Work otherwise complies with
+ the conditions stated in this License.
+
+ 5. Submission of Contributions. Unless You explicitly state otherwise,
+ any Contribution intentionally submitted for inclusion in the Work
+ by You to the Licensor shall be under the terms and conditions of
+ this License, without any additional terms or conditions.
+ Notwithstanding the above, nothing herein shall supersede or modify
+ the terms of any separate license agreement you may have executed
+ with Licensor regarding such Contributions.
+
+ 6. Trademarks. This License does not grant permission to use the trade
+ names, trademarks, service marks, or product names of the Licensor,
+ except as required for reasonable and customary use in describing the
+ origin of the Work and reproducing the content of the NOTICE file.
+
+ 7. Disclaimer of Warranty. Unless required by applicable law or
+ agreed to in writing, Licensor provides the Work (and each
+ Contributor provides its Contributions) on an "AS IS" BASIS,
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
+ implied, including, without limitation, any warranties or conditions
+ of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
+ PARTICULAR PURPOSE. You are solely responsible for determining the
+ appropriateness of using or redistributing the Work and assume any
+ risks associated with Your exercise of permissions under this License.
+
+ 8. Limitation of Liability. In no event and under no legal theory,
+ whether in tort (including negligence), contract, or otherwise,
+ unless required by applicable law (such as deliberate and grossly
+ negligent acts) or agreed to in writing, shall any Contributor be
+ liable to You for damages, including any direct, indirect, special,
+ incidental, or consequential damages of any character arising as a
+ result of this License or out of the use or inability to use the
+ Work (including but not limited to damages for loss of goodwill,
+ work stoppage, computer failure or malfunction, or any and all
+ other commercial damages or losses), even if such Contributor
+ has been advised of the possibility of such damages.
+
+ 9. Accepting Warranty or Additional Liability. While redistributing
+ the Work or Derivative Works thereof, You may choose to offer,
+ and charge a fee for, acceptance of support, warranty, indemnity,
+ or other liability obligations and/or rights consistent with this
+ License. However, in accepting such obligations, You may act only
+ on Your own behalf and on Your sole responsibility, not on behalf
+ of any other Contributor, and only if You agree to indemnify,
+ defend, and hold each Contributor harmless for any liability
+ incurred by, or claims asserted against, such Contributor by reason
+ of your accepting any such warranty or additional liability.
+
+ END OF TERMS AND CONDITIONS
+
+ APPENDIX: How to apply the Apache License to your work.
+
+ To apply the Apache License to your work, attach the following
+ boilerplate notice, with the fields enclosed by brackets "[]"
+ replaced with your own identifying information. (Don't include
+ the brackets!) The text should be enclosed in the appropriate
+ comment syntax for the file format. We also recommend that a
+ file or class name and description of purpose be included on the
+ same "printed page" as the copyright notice for easier
+ identification within third-party archives.
+
+ Copyright [yyyy] [name of copyright owner]
+
+ Licensed under the Apache License, Version 2.0 (the "License");
+ you may not use this file except in compliance with the License.
+ You may obtain a copy of the License at
+
+ https://www.apache.org/licenses/LICENSE-2.0
+
+ Unless required by applicable law or agreed to in writing, software
+ distributed under the License is distributed on an "AS IS" BASIS,
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ See the License for the specific language governing permissions and
+ limitations under the License.
diff --git a/3rd_party/licenses/abseil-NOTICE.txt b/3rd_party/licenses/abseil-NOTICE.txt
new file mode 100644
index 0000000000..e69de29bb2
diff --git a/3rd_party/licenses/sandbox2-INFO.csv b/3rd_party/licenses/sandbox2-INFO.csv
new file mode 100644
index 0000000000..925a93b13e
--- /dev/null
+++ b/3rd_party/licenses/sandbox2-INFO.csv
@@ -0,0 +1,2 @@
+name,version,revision,url,license,copyright,sourceURL
+sandboxed-api,v20241008,9e07542a03fefa2cf982ba093b099805362df05d,https://developers.google.com/code-sandboxing/sandboxed-api,Apache License 2.0,,https://github.com/google/sandboxed-api
diff --git a/3rd_party/licenses/sandbox2-LICENSE.txt b/3rd_party/licenses/sandbox2-LICENSE.txt
new file mode 100644
index 0000000000..c6b4a3bbcf
--- /dev/null
+++ b/3rd_party/licenses/sandbox2-LICENSE.txt
@@ -0,0 +1,202 @@
+
+ Apache License
+ Version 2.0, January 2004
+ http://www.apache.org/licenses/
+
+ TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
+
+ 1. Definitions.
+
+ "License" shall mean the terms and conditions for use, reproduction,
+ and distribution as defined by Sections 1 through 9 of this document.
+
+ "Licensor" shall mean the copyright owner or entity authorized by
+ the copyright owner that is granting the License.
+
+ "Legal Entity" shall mean the union of the acting entity and all
+ other entities that control, are controlled by, or are under common
+ control with that entity. For the purposes of this definition,
+ "control" means (i) the power, direct or indirect, to cause the
+ direction or management of such entity, whether by contract or
+ otherwise, or (ii) ownership of fifty percent (50%) or more of the
+ outstanding shares, or (iii) beneficial ownership of such entity.
+
+ "You" (or "Your") shall mean an individual or Legal Entity
+ exercising permissions granted by this License.
+
+ "Source" form shall mean the preferred form for making modifications,
+ including but not limited to software source code, documentation
+ source, and configuration files.
+
+ "Object" form shall mean any form resulting from mechanical
+ transformation or translation of a Source form, including but
+ not limited to compiled object code, generated documentation,
+ and conversions to other media types.
+
+ "Work" shall mean the work of authorship, whether in Source or
+ Object form, made available under the License, as indicated by a
+ copyright notice that is included in or attached to the work
+ (an example is provided in the Appendix below).
+
+ "Derivative Works" shall mean any work, whether in Source or Object
+ form, that is based on (or derived from) the Work and for which the
+ editorial revisions, annotations, elaborations, or other modifications
+ represent, as a whole, an original work of authorship. For the purposes
+ of this License, Derivative Works shall not include works that remain
+ separable from, or merely link (or bind by name) to the interfaces of,
+ the Work and Derivative Works thereof.
+
+ "Contribution" shall mean any work of authorship, including
+ the original version of the Work and any modifications or additions
+ to that Work or Derivative Works thereof, that is intentionally
+ submitted to Licensor for inclusion in the Work by the copyright owner
+ or by an individual or Legal Entity authorized to submit on behalf of
+ the copyright owner. For the purposes of this definition, "submitted"
+ means any form of electronic, verbal, or written communication sent
+ to the Licensor or its representatives, including but not limited to
+ communication on electronic mailing lists, source code control systems,
+ and issue tracking systems that are managed by, or on behalf of, the
+ Licensor for the purpose of discussing and improving the Work, but
+ excluding communication that is conspicuously marked or otherwise
+ designated in writing by the copyright owner as "Not a Contribution."
+
+ "Contributor" shall mean Licensor and any individual or Legal Entity
+ on behalf of whom a Contribution has been received by Licensor and
+ subsequently incorporated within the Work.
+
+ 2. Grant of Copyright License. Subject to the terms and conditions of
+ this License, each Contributor hereby grants to You a perpetual,
+ worldwide, non-exclusive, no-charge, royalty-free, irrevocable
+ copyright license to reproduce, prepare Derivative Works of,
+ publicly display, publicly perform, sublicense, and distribute the
+ Work and such Derivative Works in Source or Object form.
+
+ 3. Grant of Patent License. Subject to the terms and conditions of
+ this License, each Contributor hereby grants to You a perpetual,
+ worldwide, non-exclusive, no-charge, royalty-free, irrevocable
+ (except as stated in this section) patent license to make, have made,
+ use, offer to sell, sell, import, and otherwise transfer the Work,
+ where such license applies only to those patent claims licensable
+ by such Contributor that are necessarily infringed by their
+ Contribution(s) alone or by combination of their Contribution(s)
+ with the Work to which such Contribution(s) was submitted. If You
+ institute patent litigation against any entity (including a
+ cross-claim or counterclaim in a lawsuit) alleging that the Work
+ or a Contribution incorporated within the Work constitutes direct
+ or contributory patent infringement, then any patent licenses
+ granted to You under this License for that Work shall terminate
+ as of the date such litigation is filed.
+
+ 4. Redistribution. You may reproduce and distribute copies of the
+ Work or Derivative Works thereof in any medium, with or without
+ modifications, and in Source or Object form, provided that You
+ meet the following conditions:
+
+ (a) You must give any other recipients of the Work or
+ Derivative Works a copy of this License; and
+
+ (b) You must cause any modified files to carry prominent notices
+ stating that You changed the files; and
+
+ (c) You must retain, in the Source form of any Derivative Works
+ that You distribute, all copyright, patent, trademark, and
+ attribution notices from the Source form of the Work,
+ excluding those notices that do not pertain to any part of
+ the Derivative Works; and
+
+ (d) If the Work includes a "NOTICE" text file as part of its
+ distribution, then any Derivative Works that You distribute must
+ include a readable copy of the attribution notices contained
+ within such NOTICE file, excluding those notices that do not
+ pertain to any part of the Derivative Works, in at least one
+ of the following places: within a NOTICE text file distributed
+ as part of the Derivative Works; within the Source form or
+ documentation, if provided along with the Derivative Works; or,
+ within a display generated by the Derivative Works, if and
+ wherever such third-party notices normally appear. The contents
+ of the NOTICE file are for informational purposes only and
+ do not modify the License. You may add Your own attribution
+ notices within Derivative Works that You distribute, alongside
+ or as an addendum to the NOTICE text from the Work, provided
+ that such additional attribution notices cannot be construed
+ as modifying the License.
+
+ You may add Your own copyright statement to Your modifications and
+ may provide additional or different license terms and conditions
+ for use, reproduction, or distribution of Your modifications, or
+ for any such Derivative Works as a whole, provided Your use,
+ reproduction, and distribution of the Work otherwise complies with
+ the conditions stated in this License.
+
+ 5. Submission of Contributions. Unless You explicitly state otherwise,
+ any Contribution intentionally submitted for inclusion in the Work
+ by You to the Licensor shall be under the terms and conditions of
+ this License, without any additional terms or conditions.
+ Notwithstanding the above, nothing herein shall supersede or modify
+ the terms of any separate license agreement you may have executed
+ with Licensor regarding such Contributions.
+
+ 6. Trademarks. This License does not grant permission to use the trade
+ names, trademarks, service marks, or product names of the Licensor,
+ except as required for reasonable and customary use in describing the
+ origin of the Work and reproducing the content of the NOTICE file.
+
+ 7. Disclaimer of Warranty. Unless required by applicable law or
+ agreed to in writing, Licensor provides the Work (and each
+ Contributor provides its Contributions) on an "AS IS" BASIS,
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
+ implied, including, without limitation, any warranties or conditions
+ of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
+ PARTICULAR PURPOSE. You are solely responsible for determining the
+ appropriateness of using or redistributing the Work and assume any
+ risks associated with Your exercise of permissions under this License.
+
+ 8. Limitation of Liability. In no event and under no legal theory,
+ whether in tort (including negligence), contract, or otherwise,
+ unless required by applicable law (such as deliberate and grossly
+ negligent acts) or agreed to in writing, shall any Contributor be
+ liable to You for damages, including any direct, indirect, special,
+ incidental, or consequential damages of any character arising as a
+ result of this License or out of the use or inability to use the
+ Work (including but not limited to damages for loss of goodwill,
+ work stoppage, computer failure or malfunction, or any and all
+ other commercial damages or losses), even if such Contributor
+ has been advised of the possibility of such damages.
+
+ 9. Accepting Warranty or Additional Liability. While redistributing
+ the Work or Derivative Works thereof, You may choose to offer,
+ and charge a fee for, acceptance of support, warranty, indemnity,
+ or other liability obligations and/or rights consistent with this
+ License. However, in accepting such obligations, You may act only
+ on Your own behalf and on Your sole responsibility, not on behalf
+ of any other Contributor, and only if You agree to indemnify,
+ defend, and hold each Contributor harmless for any liability
+ incurred by, or claims asserted against, such Contributor by reason
+ of your accepting any such warranty or additional liability.
+
+ END OF TERMS AND CONDITIONS
+
+ APPENDIX: How to apply the Apache License to your work.
+
+ To apply the Apache License to your work, attach the following
+ boilerplate notice, with the fields enclosed by brackets "[]"
+ replaced with your own identifying information. (Don't include
+ the brackets!) The text should be enclosed in the appropriate
+ comment syntax for the file format. We also recommend that a
+ file or class name and description of purpose be included on the
+ same "printed page" as the copyright notice for easier
+ identification within third-party archives.
+
+ Copyright [yyyy] [name of copyright owner]
+
+ Licensed under the Apache License, Version 2.0 (the "License");
+ you may not use this file except in compliance with the License.
+ You may obtain a copy of the License at
+
+ https://www.apache.org/licenses/LICENSE-2.0
+
+ Unless required by applicable law or agreed to in writing, software
+ distributed under the License is distributed on an "AS IS" BASIS,
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ See the License for the specific language governing permissions and
+ limitations under the License.
\ No newline at end of file
diff --git a/3rd_party/licenses/sandbox2-NOTICE.txt b/3rd_party/licenses/sandbox2-NOTICE.txt
new file mode 100644
index 0000000000..e69de29bb2
diff --git a/3rd_party/patches/sandboxed-api/0001-abseil-cpp-disable-gtest.patch b/3rd_party/patches/sandboxed-api/0001-abseil-cpp-disable-gtest.patch
new file mode 100644
index 0000000000..7510ab6645
--- /dev/null
+++ b/3rd_party/patches/sandboxed-api/0001-abseil-cpp-disable-gtest.patch
@@ -0,0 +1,15 @@
+diff --git a/cmake/abseil-cpp.cmake b/cmake/abseil-cpp.cmake
+index cc9d9fd..d69bfe8 100644
+--- a/cmake/abseil-cpp.cmake
++++ b/cmake/abseil-cpp.cmake
+@@ -19,8 +19,8 @@ FetchContent_Declare(absl
+ set(ABSL_CXX_STANDARD ${SAPI_CXX_STANDARD} CACHE STRING "" FORCE)
+ set(ABSL_PROPAGATE_CXX_STD ON CACHE BOOL "" FORCE)
+ set(ABSL_RUN_TESTS OFF CACHE BOOL "" FORCE)
+-set(ABSL_BUILD_TEST_HELPERS ON CACHE BOOL "" FORCE)
+-set(ABSL_USE_EXTERNAL_GOOGLETEST ON)
++set(ABSL_BUILD_TEST_HELPERS OFF CACHE BOOL "" FORCE)
++set(ABSL_USE_EXTERNAL_GOOGLETEST OFF)
+ set(ABSL_FIND_GOOGLETEST OFF)
+ set(ABSL_USE_GOOGLETEST_HEAD OFF CACHE BOOL "" FORCE)
+
diff --git a/3rd_party/patches/sandboxed-api/0002-no-fno-exceptions-propagation.patch b/3rd_party/patches/sandboxed-api/0002-no-fno-exceptions-propagation.patch
new file mode 100644
index 0000000000..0d6a1f4c46
--- /dev/null
+++ b/3rd_party/patches/sandboxed-api/0002-no-fno-exceptions-propagation.patch
@@ -0,0 +1,17 @@
+diff --git a/CMakeLists.txt b/CMakeLists.txt
+index c2b9704..0af9111 100644
+--- a/CMakeLists.txt
++++ b/CMakeLists.txt
+@@ -111,9 +111,9 @@ target_include_directories(sapi_base PUBLIC
+ "${SAPI_SOURCE_DIR}"
+ "${Protobuf_INCLUDE_DIR}"
+ )
+-target_compile_options(sapi_base PUBLIC
+- -fno-exceptions
+-)
++# target_compile_options(sapi_base PUBLIC
++# -fno-exceptions
++# )
+ if(CMAKE_CXX_COMPILER_ID MATCHES "Clang")
+ target_compile_options(sapi_base PUBLIC
+ # The syscall tables in sandbox2/syscall_defs.cc are `std::array`s using
diff --git a/3rd_party/patches/sandboxed-api/0003-python3-optional.patch b/3rd_party/patches/sandboxed-api/0003-python3-optional.patch
new file mode 100644
index 0000000000..fcbad09cf3
--- /dev/null
+++ b/3rd_party/patches/sandboxed-api/0003-python3-optional.patch
@@ -0,0 +1,25 @@
+diff --git a/cmake/SapiDeps.cmake b/cmake/SapiDeps.cmake
+index 2e595c6..3ee7514 100644
+--- a/cmake/SapiDeps.cmake
++++ b/cmake/SapiDeps.cmake
+@@ -104,8 +104,18 @@ if(SAPI_ENABLE_CLANG_TOOL)
+ else()
+ # Find Python 3 and add its location to the cache so that its available in
+ # the add_sapi_library() macro in embedding projects.
+- find_package(Python3 COMPONENTS Interpreter REQUIRED)
+- set(SAPI_PYTHON3_EXECUTABLE "${Python3_EXECUTABLE}" CACHE INTERNAL "" FORCE)
++ #
++ # ml-cpp patch: made optional. Python3 is only needed for protobuf code
++ # generation; a missing interpreter should not fail configuration when
++ # protobuf sources are already generated or unused by the caller.
++ find_package(Python3 QUIET COMPONENTS Interpreter)
++ if(Python3_Interpreter_FOUND)
++ set(SAPI_PYTHON3_EXECUTABLE "${Python3_EXECUTABLE}" CACHE INTERNAL "" FORCE)
++ else()
++ set(SAPI_PYTHON3_EXECUTABLE "" CACHE INTERNAL "" FORCE)
++ message(STATUS "Python3 interpreter not found - continuing without it "
++ "(protobuf code generation via add_sapi_library() will be unavailable)")
++ endif()
+ endif()
+
+ # Undo global changes
diff --git a/3rd_party/patches/sandboxed-api/0004-forkserver-zlib-static-libstdcxx.patch b/3rd_party/patches/sandboxed-api/0004-forkserver-zlib-static-libstdcxx.patch
new file mode 100644
index 0000000000..eb8b5f98ea
--- /dev/null
+++ b/3rd_party/patches/sandboxed-api/0004-forkserver-zlib-static-libstdcxx.patch
@@ -0,0 +1,22 @@
+diff --git a/sandboxed_api/sandbox2/CMakeLists.txt b/sandboxed_api/sandbox2/CMakeLists.txt
+index 8246938..0718763 100644
+--- a/sandboxed_api/sandbox2/CMakeLists.txt
++++ b/sandboxed_api/sandbox2/CMakeLists.txt
+@@ -245,6 +245,17 @@ target_link_libraries(sandbox2_forkserver_bin PRIVATE
+ sandbox2::util
+ sapi::base
+ sapi::raw_logging
++ # ml-cpp patch: sandbox2::unwind (libunwind_ptrace) calls uncompress(),
++ # which requires libz; link it explicitly instead of relying on transitive
++ # discovery, which --as-needed can drop.
++ z
++)
++# ml-cpp patch: statically link libstdc++/libgcc so the embedded forkserver
++# binary (sandbox2::forkserver_bin_embed below) does not depend on the
++# host's runtime GLIBCXX version at exec time.
++target_link_options(sandbox2_forkserver_bin PRIVATE
++ -static-libstdc++
++ -static-libgcc
+ )
+
+ # sandboxed_api/sandbox2:forkserver_bin_embed
diff --git a/3rd_party/patches/sandboxed-api/README.md b/3rd_party/patches/sandboxed-api/README.md
new file mode 100644
index 0000000000..e65e971225
--- /dev/null
+++ b/3rd_party/patches/sandboxed-api/README.md
@@ -0,0 +1,41 @@
+# Sandboxed API source patches
+
+These patches are applied by [`3rd_party/CMakeLists.txt`](../CMakeLists.txt)
+to the vendored `sandboxed-api` checkout (pinned via `FetchContent` to
+`GIT_TAG` below) before it is added as a build subdirectory. They replace an
+earlier approach that rewrote these files with inline `string(REGEX REPLACE
+...)`/`file(WRITE ...)` calls at configure time — fragile because a silent
+non-match left the intended change unapplied instead of failing the build.
+
+Applying via `git apply` instead means a patch that no longer matches the
+pinned tag's content **fails the configure step loudly** (`FATAL_ERROR`)
+rather than degrading into an unpatched build.
+
+Pinned tag: `v20241008` at commit `9e07542a03fefa2cf982ba093b099805362df05d`
+(see `ML_SANDBOXED_API_TAG` / `ML_SANDBOXED_API_GIT_SHA` in
+`3rd_party/CMakeLists.txt`).
+
+## Patches
+
+| File | Target | Why |
+|---|---|---|
+| `0001-abseil-cpp-disable-gtest.patch` | `cmake/abseil-cpp.cmake` | The vendored Abseil `FetchContent` override otherwise builds gtest, which ml-cpp does not vendor and does not need. |
+| `0002-no-fno-exceptions-propagation.patch` | `CMakeLists.txt` | `sapi_base` exports `-fno-exceptions` as `PUBLIC`; linking against it would propagate that flag into ml-cpp targets, which use exceptions. |
+| `0003-python3-optional.patch` | `cmake/SapiDeps.cmake` | `find_package(Python3 ... REQUIRED)` is only needed for `add_sapi_library()` protobuf code generation, which `MlSandbox` does not use; a missing interpreter should not fail configuration. |
+| `0004-forkserver-zlib-static-libstdcxx.patch` | `sandboxed_api/sandbox2/CMakeLists.txt` | `sandbox2::unwind` (`libunwind_ptrace`) calls `uncompress()` from libz, which `--as-needed` can drop without an explicit link; the embedded forkserver binary also needs static `libstdc++`/`libgcc` so it does not depend on the host's runtime GLIBCXX version at exec time. |
+
+## Bumping the pinned tag
+
+1. Update `ML_SANDBOXED_API_TAG`, resolve its commit SHA into
+ `ML_SANDBOXED_API_GIT_SHA`, and update `sandbox2-INFO.csv` `revision`
+ in `3rd_party/CMakeLists.txt`.
+2. Re-run configure. A patch that no longer applies fails with
+ `FATAL_ERROR: sandboxed-api patch failed to apply` — this is the
+ version-drift signal.
+3. For each failing patch, regenerate it against the new tag's real file
+ content (clone the tag, make the same edit, `git diff`) rather than
+ hand-editing the `.patch` file — hand-edited patches drift from what the
+ new tag's file actually contains.
+4. Reconfigure again to confirm every patch now applies cleanly, then
+ rebuild `lib/sandbox` (`ml_test_sandbox`) to confirm the resulting
+ Sandbox2 build still passes.
diff --git a/include/sandbox/CMlSandboxAvailability.h b/include/sandbox/CMlSandboxAvailability.h
new file mode 100644
index 0000000000..1e1075ab58
--- /dev/null
+++ b/include/sandbox/CMlSandboxAvailability.h
@@ -0,0 +1,43 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License
+ * 2.0 and the following additional limitation. Functionality enabled by the
+ * files subject to the Elastic License 2.0 may only be used in production when
+ * invoked by an Elasticsearch process with a license key installed that permits
+ * use of machine learning features. You may not use this file except in
+ * compliance with the Elastic License 2.0 and the foregoing additional
+ * limitation.
+ */
+#ifndef INCLUDED_ml_sandbox_CMlSandboxAvailability_h
+#define INCLUDED_ml_sandbox_CMlSandboxAvailability_h
+
+#include
+
+namespace ml {
+namespace sandbox {
+
+//! \brief
+//! Reports whether this binary was built with Sandbox2 support.
+//!
+//! DESCRIPTION:\n
+//! MlSandbox is a dormant dependency foundation: it links Sandbox2/Abseil
+//! and builds a runnable forkserver on Linux, but nothing in the controller
+//! or pytorch_inference wiring routes to it yet. This query is the only
+//! symbol callers outside this library may currently depend on; the actual
+//! sandbox policy, spawner, and controller routing land in follow-up PRs.
+//!
+//! IMPLEMENTATION DECISIONS:\n
+//! Backed by the SANDBOX2_AVAILABLE compile definition set in
+//! lib/sandbox/CMakeLists.txt, which is only defined when the Sandbox2
+//! FetchContent target built successfully (Linux only).
+class CMlSandboxAvailability : private core::CNonInstantiatable {
+public:
+ //! \return true if this binary was compiled with Sandbox2 linked in
+ //! (Linux builds only); false on macOS/Windows or if the dependency
+ //! foundation build step did not run.
+ static bool isCompiledIn();
+};
+}
+}
+
+#endif // INCLUDED_ml_sandbox_CMlSandboxAvailability_h
diff --git a/lib/CMakeLists.txt b/lib/CMakeLists.txt
index a740c13ad8..2d790c68ac 100644
--- a/lib/CMakeLists.txt
+++ b/lib/CMakeLists.txt
@@ -27,4 +27,5 @@ add_subdirectory(api/dump_state EXCLUDE_FROM_ALL)
add_subdirectory(test)
add_subdirectory(ver)
add_subdirectory(seccomp)
+add_subdirectory(sandbox)
diff --git a/lib/sandbox/CMakeLists.txt b/lib/sandbox/CMakeLists.txt
new file mode 100644
index 0000000000..508a46029d
--- /dev/null
+++ b/lib/sandbox/CMakeLists.txt
@@ -0,0 +1,46 @@
+#
+# Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+# or more contributor license agreements. Licensed under the Elastic License
+# 2.0 and the following additional limitation. Functionality enabled by the
+# files subject to the Elastic License 2.0 may only be used in production when
+# invoked by an Elasticsearch process with a license key installed that permits
+# use of machine learning features. You may not use this file except in
+# compliance with the Elastic License 2.0 and the foregoing additional
+# limitation.
+#
+
+# MlSandbox is a dormant dependency foundation: it links Sandbox2/Abseil and
+# builds/tests a runnable Sandbox2 forkserver on Linux, but no controller or
+# pytorch_inference routing depends on it yet. The typed launch policy,
+# process spawner, and controller wiring land in follow-up PRs.
+
+project("ML Sandbox")
+
+set(ML_LINK_LIBRARIES
+ MlCore
+ )
+
+set(SRCS
+ CMlSandboxAvailability.cc
+ )
+
+ml_add_library(MlSandbox STATIC ${SRCS})
+
+if(TARGET sandbox2::sandbox2)
+ target_compile_definitions(MlSandbox PUBLIC SANDBOX2_AVAILABLE)
+ if(CMAKE_SYSTEM_NAME STREQUAL "Linux")
+ # libunwind_ptrace (a Sandbox2 dependency, via sandbox2::unwind) calls
+ # uncompress() from libz. CMake may de-duplicate ZLIB::ZLIB with MlCore
+ # and place -lz before the sandbox2 archives on the link line; with
+ # --as-needed that silently drops -lz from downstream executable links
+ # (observed on aarch64). A raw -Wl group is a distinct link item, so -lz
+ # stays ordered after sandbox2 regardless of de-duplication.
+ # sandbox2::sandbox2 is an ALIAS target - do not target_link_libraries
+ # against the alias name from outside this cache variable.
+ target_link_libraries(MlSandbox PUBLIC sandbox2::sandbox2)
+ target_link_libraries(MlSandbox PUBLIC "-Wl,--no-as-needed,-lz,--as-needed")
+ message(STATUS "MlSandbox: Sandbox2 enabled and linked")
+ endif()
+else()
+ message(STATUS "MlSandbox: Sandbox2 not available on this platform - building dormant stub only")
+endif()
diff --git a/lib/sandbox/CMlSandboxAvailability.cc b/lib/sandbox/CMlSandboxAvailability.cc
new file mode 100644
index 0000000000..ca6c077e13
--- /dev/null
+++ b/lib/sandbox/CMlSandboxAvailability.cc
@@ -0,0 +1,24 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License
+ * 2.0 and the following additional limitation. Functionality enabled by the
+ * files subject to the Elastic License 2.0 may only be used in production when
+ * invoked by an Elasticsearch process with a license key installed that permits
+ * use of machine learning features. You may not use this file except in
+ * compliance with the Elastic License 2.0 and the foregoing additional
+ * limitation.
+ */
+#include
+
+namespace ml {
+namespace sandbox {
+
+bool CMlSandboxAvailability::isCompiledIn() {
+#ifdef SANDBOX2_AVAILABLE
+ return true;
+#else
+ return false;
+#endif
+}
+}
+}
diff --git a/lib/sandbox/unittest/CMakeLists.txt b/lib/sandbox/unittest/CMakeLists.txt
new file mode 100644
index 0000000000..ba783acc4f
--- /dev/null
+++ b/lib/sandbox/unittest/CMakeLists.txt
@@ -0,0 +1,62 @@
+#
+# Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+# or more contributor license agreements. Licensed under the Elastic License
+# 2.0 and the following additional limitation. Functionality enabled by the
+# files subject to the Elastic License 2.0 may only be used in production when
+# invoked by an Elasticsearch process with a license key installed that permits
+# use of machine learning features. You may not use this file except in
+# compliance with the Elastic License 2.0 and the foregoing additional
+# limitation.
+#
+
+project("ML Sandbox unit tests")
+
+set(SRCS
+ Main.cc
+ CMlSandboxAvailabilityTest.cc
+ )
+
+set(ML_LINK_LIBRARIES
+ ${Boost_LIBRARIES_WITH_UNIT_TEST}
+ MlCore
+ MlSandbox
+ MlTest
+ )
+
+if(TARGET sandbox2::sandbox2 AND CMAKE_SYSTEM_NAME STREQUAL "Linux")
+ # The forkserver runtime smoke test links the Sandbox2 API directly (not
+ # just MlSandbox, which exposes no sandbox2 symbols yet) to prove the
+ # vendored forkserver - built via 3rd_party/patches/sandboxed-api/ - can
+ # fork/exec/reap a real child. It is Linux-only and dropped entirely
+ # elsewhere rather than compiled out with #ifdef, since sandbox2 headers
+ # are unavailable on non-Linux configure runs.
+ list(APPEND SRCS CSandboxForkserverSmokeTest.cc)
+ list(APPEND ML_LINK_LIBRARIES sandbox2::sandbox2)
+
+ # Deliberately-dependency-free sandboxee payload for the smoke test above.
+ # Built with plain add_executable rather than ml_add_non_distributed_executable:
+ # it has no ml-cpp library dependencies, no ML_LINK_LIBRARIES, and must not
+ # be confused with a distributable ml-cpp binary. Dynamically linked (the
+ # default) - a static build was tried first to sidestep
+ # PolicyBuilder::AddLibrariesForBinary(), matching upstream sandboxed-api's
+ # own examples/static/static_bin.cc, but the ml-cpp CI build image
+ # (docker.elastic.co/ml-dev/ml-linux-build) has no static libc/libm
+ # archives (`ld: cannot find -lm/-lc`), so the smoke test itself now calls
+ # AddLibrariesForBinary() on the dynamically-linked payload instead.
+ add_executable(sandbox2_smoke_payload EXCLUDE_FROM_ALL
+ payloads/sandbox_smoke_payload.cc
+ )
+ set_target_properties(sandbox2_smoke_payload PROPERTIES
+ POSITION_INDEPENDENT_CODE TRUE
+ RUNTIME_OUTPUT_DIRECTORY ${CMAKE_CURRENT_BINARY_DIR}/payloads
+ )
+endif()
+
+ml_add_test_executable(sandbox ${SRCS})
+
+if(TARGET sandbox2_smoke_payload)
+ add_dependencies(ml_test_sandbox sandbox2_smoke_payload)
+ target_compile_definitions(ml_test_sandbox PRIVATE
+ "ML_SANDBOX2_SMOKE_PAYLOAD=\"$\""
+ )
+endif()
diff --git a/lib/sandbox/unittest/CMlSandboxAvailabilityTest.cc b/lib/sandbox/unittest/CMlSandboxAvailabilityTest.cc
new file mode 100644
index 0000000000..c782e896cc
--- /dev/null
+++ b/lib/sandbox/unittest/CMlSandboxAvailabilityTest.cc
@@ -0,0 +1,25 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License
+ * 2.0 and the following additional limitation. Functionality enabled by the
+ * files subject to the Elastic License 2.0 may only be used in production when
+ * invoked by an Elasticsearch process with a license key installed that permits
+ * use of machine learning features. You may not use this file except in
+ * compliance with the Elastic License 2.0 and the foregoing additional
+ * limitation.
+ */
+#include
+
+#include
+
+BOOST_AUTO_TEST_SUITE(CMlSandboxAvailabilityTest)
+
+BOOST_AUTO_TEST_CASE(testMatchesPlatformExpectation) {
+#if defined(SANDBOX2_AVAILABLE)
+ BOOST_TEST_REQUIRE(ml::sandbox::CMlSandboxAvailability::isCompiledIn());
+#else
+ BOOST_TEST_REQUIRE(!ml::sandbox::CMlSandboxAvailability::isCompiledIn());
+#endif
+}
+
+BOOST_AUTO_TEST_SUITE_END()
diff --git a/lib/sandbox/unittest/CSandboxForkserverSmokeTest.cc b/lib/sandbox/unittest/CSandboxForkserverSmokeTest.cc
new file mode 100644
index 0000000000..f62ab9db07
--- /dev/null
+++ b/lib/sandbox/unittest/CSandboxForkserverSmokeTest.cc
@@ -0,0 +1,76 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License
+ * 2.0 and the following additional limitation. Functionality enabled by the
+ * files subject to the Elastic License 2.0 may only be used in production when
+ * invoked by an Elasticsearch process with a license key installed that permits
+ * use of machine learning features. You may not use this file except in
+ * compliance with the Elastic License 2.0 and the foregoing additional
+ * limitation.
+ */
+
+// Linux-only forkserver runtime smoke test for the dormant MlSandbox
+// dependency foundation. This is NOT a security test: it uses
+// PolicyBuilder::DangerDefaultAllowAll(), which imposes no seccomp
+// restriction. Its only purpose is to prove that the vendored Sandbox2
+// forkserver - built via the checked-in patches under
+// 3rd_party/patches/sandboxed-api/ - can actually fork, exec, and reap a
+// child process end-to-end. Typed launch policy and syscall filtering are
+// out of scope here and land in follow-up PRs.
+//
+// The payload is dynamically linked, so AddLibrariesForBinary() mounts its
+// shared-library dependencies into the sandbox namespace; without it,
+// Sandbox2's forkserver fails execveat with ENOENT. A static-linked payload
+// (matching upstream sandboxed-api's own examples/static/static_bin.cc, to
+// sidestep AddLibrariesForBinary entirely) was tried first, but this CI's
+// build image has no static libc/libm archives (`ld: cannot find -lm/-lc`).
+
+#include
+
+#include
+
+#include
+
+#ifndef ML_SANDBOX2_SMOKE_PAYLOAD
+#error "ML_SANDBOX2_SMOKE_PAYLOAD must be defined by lib/sandbox/unittest/CMakeLists.txt"
+#endif
+
+#include "absl/time/time.h"
+#include "sandboxed_api/sandbox2/executor.h"
+#include "sandboxed_api/sandbox2/policybuilder.h"
+#include "sandboxed_api/sandbox2/result.h"
+#include "sandboxed_api/sandbox2/sandbox2.h"
+
+#include
+#include
+
+BOOST_AUTO_TEST_SUITE(CSandboxForkserverSmokeTest_Linux)
+
+BOOST_AUTO_TEST_CASE(testForkserverRunsPayloadToCompletion) {
+ BOOST_TEST_REQUIRE(ml::sandbox::CMlSandboxAvailability::isCompiledIn());
+
+ const std::string payloadPath{ML_SANDBOX2_SMOKE_PAYLOAD};
+ std::vector args{payloadPath};
+
+ auto executor = std::make_unique(payloadPath, args);
+ executor->limits()->set_rlimit_cpu(10).set_walltime_limit(absl::Seconds(10));
+
+ // DangerDefaultAllowAll is deliberately permissive: this test exercises
+ // the forkserver plumbing only, not the (not-yet-implemented) sandbox
+ // policy. Do not copy this policy into production or security-relevant
+ // test code. AddLibrariesForBinary mounts the payload's shared-library
+ // dependencies (ldd-derived) so the dynamic loader can find them inside
+ // the sandbox namespace.
+ auto policy = sandbox2::PolicyBuilder()
+ .DangerDefaultAllowAll()
+ .AddLibrariesForBinary(payloadPath)
+ .BuildOrDie();
+
+ sandbox2::Sandbox2 s2(std::move(executor), std::move(policy));
+ sandbox2::Result result = s2.Run();
+
+ BOOST_TEST_REQUIRE(result.final_status() == sandbox2::Result::OK);
+ BOOST_TEST_REQUIRE(result.reason_code() == 0);
+}
+
+BOOST_AUTO_TEST_SUITE_END()
diff --git a/lib/sandbox/unittest/Main.cc b/lib/sandbox/unittest/Main.cc
new file mode 100644
index 0000000000..15b5b5324a
--- /dev/null
+++ b/lib/sandbox/unittest/Main.cc
@@ -0,0 +1,30 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License
+ * 2.0 and the following additional limitation. Functionality enabled by the
+ * files subject to the Elastic License 2.0 may only be used in production when
+ * invoked by an Elasticsearch process with a license key installed that permits
+ * use of machine learning features. You may not use this file except in
+ * compliance with the Elastic License 2.0 and the foregoing additional
+ * limitation.
+ */
+
+#define BOOST_TEST_MODULE lib.sandbox
+// Defining BOOST_TEST_MODULE usually auto-generates main(), but we don't want
+// this as we need custom initialisation to allow for output in both console and
+// Boost.Test XML formats
+#define BOOST_TEST_NO_MAIN
+
+#include
+#include
+
+#include
+
+int main(int argc, char** argv) {
+ ml::test::CTestObserver observer;
+ boost::unit_test::framework::register_observer(observer);
+ int result{boost::unit_test::unit_test_main(&ml::test::CBoostTestXmlOutput::init,
+ argc, argv)};
+ boost::unit_test::framework::deregister_observer(observer);
+ return result;
+}
diff --git a/lib/sandbox/unittest/payloads/sandbox_smoke_payload.cc b/lib/sandbox/unittest/payloads/sandbox_smoke_payload.cc
new file mode 100644
index 0000000000..e092e0ef08
--- /dev/null
+++ b/lib/sandbox/unittest/payloads/sandbox_smoke_payload.cc
@@ -0,0 +1,26 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License
+ * 2.0 and the following additional limitation. Functionality enabled by the
+ * files subject to the Elastic License 2.0 may only be used in production when
+ * invoked by an Elasticsearch process with a license key installed that permits
+ * use of machine learning features. You may not use this file except in
+ * compliance with the Elastic License 2.0 and the foregoing additional
+ * limitation.
+ */
+
+// Deliberately dependency-free sandboxee for CSandboxForkserverSmokeTest_Linux.
+// It exists only to prove the vendored Sandbox2 forkserver can fork, exec,
+// and reap a child through the full pipeline patched in
+// 3rd_party/patches/sandboxed-api/0004-forkserver-zlib-static-libstdcxx.patch.
+// It carries no ml-cpp library dependencies and no sandbox policy of its
+// own - policy design is out of scope for this dormant dependency
+// foundation and lands in a follow-up PR.
+
+#include
+#include
+
+int main() {
+ std::printf("sandbox2-smoke-ok\n");
+ return EXIT_SUCCESS;
+}
diff --git a/test/CMakeLists.txt b/test/CMakeLists.txt
index b4d0ea8219..db573082cc 100644
--- a/test/CMakeLists.txt
+++ b/test/CMakeLists.txt
@@ -17,6 +17,7 @@ ml_add_test(lib/model/unittest model)
ml_add_test(lib/api/unittest api)
ml_add_test(lib/ver/unittest ver)
ml_add_test(lib/seccomp/unittest seccomp)
+ml_add_test(lib/sandbox/unittest sandbox)
ml_add_test(bin/controller/unittest controller)
ml_add_test(bin/pytorch_inference/unittest pytorch_inference)
From 31129f1dde78e9718e40a1db66de2afad059ee76 Mon Sep 17 00:00:00 2001
From: Valeriy Khakhutskyy <1292899+valeriy42@users.noreply.github.com>
Date: Mon, 21 Sep 2026 15:49:19 +0200
Subject: [PATCH 02/10] [ML] Generate syscall policies from one declaration;
fail-closed degraded seccomp (#3182)
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.
`CSystemCallFilter::installSystemCallFilter()` returns a typed `ESystemCallFilterInstallOutcome` across all three platform implementations instead of `void`, and logs an `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 the controller has a typed way to know a degraded-mode launch was a deliberate operator choice rather than the only option available. Flipping it on today would terminate every launch on a host lacking seccomp BPF, with no operator fallback to select instead. The four non-PyTorch callers now make their unchanged log-and-continue policy explicit instead of silently discarding the result.
Also adds an explicit structured `degradedModeAttestationMarker()` so a controller/Elasticsearch observer can assert seccomp installation directly instead of inferring it from the absence of a fatal log line, and carries forward two pytorch_inference/libtorch compatibility fixes from #2873 into the shared declaration (`clone3` allowed by literal syscall number, and `prlimit64`) with a named regression test, so a from-scratch rewrite doesn't silently drop them.
`CSeccompFilterBuilderTest.cc` decodes the actually-built BPF program to prove it matches the declaration and that jump offsets are derived, not hand-maintained, plus fault-injected coverage of `decideDegradedModeAction()` for every install-failure class.
A future Sandbox2 filesystem/network policy doesn't exist yet in this branch's history; this change establishes the single declaration for that policy to consume once it's written.
(cherry picked from commit a210a301bb31139e86d34ec29aa909c93dab41dd)
---
bin/autodetect/Main.cc | 8 +-
bin/categorize/Main.cc | 8 +-
bin/data_frame_analyzer/Main.cc | 8 +-
bin/normalize/Main.cc | 8 +-
bin/pytorch_inference/Main.cc | 28 +-
.../seccomp/CMlLegacyBpfSyscallAllowlist.h | 132 ++++++++
include/seccomp/CSeccompFilterBuilder.h | 44 +++
include/seccomp/CSystemCallFilter.h | 80 ++++-
lib/seccomp/CSystemCallFilter_Linux.cc | 239 +++++++--------
lib/seccomp/CSystemCallFilter_MacOSX.cc | 11 +-
lib/seccomp/CSystemCallFilter_Windows.cc | 12 +-
lib/seccomp/unittest/CMakeLists.txt | 1 +
.../unittest/CSeccompFilterBuilderTest.cc | 281 ++++++++++++++++++
lib/seccomp/unittest/CSystemCallFilterTest.cc | 4 +-
14 files changed, 713 insertions(+), 151 deletions(-)
create mode 100644 include/seccomp/CMlLegacyBpfSyscallAllowlist.h
create mode 100644 include/seccomp/CSeccompFilterBuilder.h
create mode 100644 lib/seccomp/unittest/CSeccompFilterBuilderTest.cc
diff --git a/bin/autodetect/Main.cc b/bin/autodetect/Main.cc
index 4c328fa5e6..047cbe49ee 100644
--- a/bin/autodetect/Main.cc
+++ b/bin/autodetect/Main.cc
@@ -177,7 +177,13 @@ int main(int argc, char** argv) {
// Reduce memory priority before installing system call filters.
ml::core::CProcessPriority::reduceMemoryPriority();
- ml::seccomp::CSystemCallFilter::installSystemCallFilter();
+ // Log and continue on a degraded install. This
+ // binary does not process untrusted model input, unlike
+ // pytorch_inference.
+ if (ml::seccomp::CSystemCallFilter::installSystemCallFilter() !=
+ ml::seccomp::ESystemCallFilterInstallOutcome::E_Installed) {
+ LOG_INFO(<< "Continuing without full syscall filtering");
+ }
if (ioMgr.initIo() == false) {
LOG_FATAL(<< "Failed to initialise IO");
diff --git a/bin/categorize/Main.cc b/bin/categorize/Main.cc
index aa4a1a4aaf..ae60e880d3 100644
--- a/bin/categorize/Main.cc
+++ b/bin/categorize/Main.cc
@@ -137,7 +137,13 @@ int main(int argc, char** argv) {
// Reduce memory priority before installing system call filters.
ml::core::CProcessPriority::reduceMemoryPriority();
- ml::seccomp::CSystemCallFilter::installSystemCallFilter();
+ // Log and continue on a degraded install. This
+ // binary does not process untrusted model input, unlike
+ // pytorch_inference.
+ if (ml::seccomp::CSystemCallFilter::installSystemCallFilter() !=
+ ml::seccomp::ESystemCallFilterInstallOutcome::E_Installed) {
+ LOG_INFO(<< "Continuing without full syscall filtering");
+ }
if (ioMgr.initIo() == false) {
LOG_FATAL(<< "Failed to initialise IO");
diff --git a/bin/data_frame_analyzer/Main.cc b/bin/data_frame_analyzer/Main.cc
index 4b7b3d1ff1..78e172e438 100644
--- a/bin/data_frame_analyzer/Main.cc
+++ b/bin/data_frame_analyzer/Main.cc
@@ -160,7 +160,13 @@ int main(int argc, char** argv) {
// Reduce memory priority before installing system call filters.
ml::core::CProcessPriority::reduceMemoryPriority();
- ml::seccomp::CSystemCallFilter::installSystemCallFilter();
+ // Log and continue on a degraded install. This
+ // binary does not process untrusted model input, unlike
+ // pytorch_inference.
+ if (ml::seccomp::CSystemCallFilter::installSystemCallFilter() !=
+ ml::seccomp::ESystemCallFilterInstallOutcome::E_Installed) {
+ LOG_INFO(<< "Continuing without full syscall filtering");
+ }
if (ioMgr.initIo() == false) {
LOG_FATAL(<< "Failed to initialise IO");
diff --git a/bin/normalize/Main.cc b/bin/normalize/Main.cc
index f6a79a7b65..b50723f0b3 100644
--- a/bin/normalize/Main.cc
+++ b/bin/normalize/Main.cc
@@ -115,7 +115,13 @@ int main(int argc, char** argv) {
// Reduce memory priority before installing system call filters.
ml::core::CProcessPriority::reduceMemoryPriority();
- ml::seccomp::CSystemCallFilter::installSystemCallFilter();
+ // Log and continue on a degraded install. This
+ // binary does not process untrusted model input, unlike
+ // pytorch_inference.
+ if (ml::seccomp::CSystemCallFilter::installSystemCallFilter() !=
+ ml::seccomp::ESystemCallFilterInstallOutcome::E_Installed) {
+ LOG_INFO(<< "Continuing without full syscall filtering");
+ }
if (ioMgr.initIo() == false) {
LOG_FATAL(<< "Failed to initialise IO");
diff --git a/bin/pytorch_inference/Main.cc b/bin/pytorch_inference/Main.cc
index cb0e4393a7..800c7525b6 100644
--- a/bin/pytorch_inference/Main.cc
+++ b/bin/pytorch_inference/Main.cc
@@ -295,7 +295,33 @@ int main(int argc, char** argv) {
// Reduce memory priority before installing system call filters.
ml::core::CProcessPriority::reduceMemoryPriority();
- ml::seccomp::CSystemCallFilter::installSystemCallFilter();
+
+ // Internal switch, not an operator setting: it stays false until the
+ // controller can route around Sandbox2 explicitly and guarantee that a
+ // degraded-mode (no-Sandbox2) launch was a deliberate operator choice
+ // rather than the only option this process has. Flipping it on today
+ // would terminate every launch on a host lacking seccomp BPF, with no
+ // operator fallback to select instead.
+ constexpr bool TERMINATE_ON_DEGRADED_SECCOMP_FAILURE{false};
+
+ const ml::seccomp::ESystemCallFilterInstallOutcome seccompOutcome{
+ ml::seccomp::CSystemCallFilter::installSystemCallFilter()};
+
+ if (ml::seccomp::decideDegradedModeAction(seccompOutcome, TERMINATE_ON_DEGRADED_SECCOMP_FAILURE) ==
+ ml::seccomp::EDegradedModeAction::E_TerminateBeforeIo) {
+ LOG_FATAL(<< "Seccomp installation " << ml::seccomp::describe(seccompOutcome)
+ << "; terminating before untrusted model processing");
+ return EXIT_FAILURE;
+ }
+
+ // Explicit structured attestation the controller/Elasticsearch can
+ // assert on directly, rather than inferring readiness from the absence
+ // of a fatal log line above.
+ const std::string degradedModeMarker{
+ ml::seccomp::degradedModeAttestationMarker(seccompOutcome)};
+ if (degradedModeMarker.empty() == false) {
+ LOG_INFO(<< degradedModeMarker);
+ }
if (ioMgr.initIo() == false) {
LOG_FATAL(<< "Failed to initialise IO");
diff --git a/include/seccomp/CMlLegacyBpfSyscallAllowlist.h b/include/seccomp/CMlLegacyBpfSyscallAllowlist.h
new file mode 100644
index 0000000000..f15b5f277d
--- /dev/null
+++ b/include/seccomp/CMlLegacyBpfSyscallAllowlist.h
@@ -0,0 +1,132 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License
+ * 2.0 and the following additional limitation. Functionality enabled by the
+ * files subject to the Elastic License 2.0 may only be used in production when
+ * invoked by an Elasticsearch process with a license key installed that permits
+ * use of machine learning features. You may not use this file except in
+ * compliance with the Elastic License 2.0 and the foregoing additional
+ * limitation.
+ */
+#ifndef INCLUDED_ml_seccomp_CMlLegacyBpfSyscallAllowlist_h
+#define INCLUDED_ml_seccomp_CMlLegacyBpfSyscallAllowlist_h
+
+#include
+#include
+
+#ifdef __linux__
+#include
+#endif
+
+namespace ml {
+namespace seccomp {
+
+#ifdef __linux__
+
+// statx, rseq and clone3 won't be defined on a RHEL/CentOS 7 build machine,
+// but might exist on the kernel we run on, so fall back to the raw numbers.
+#if defined(__x86_64__)
+#ifndef __NR_statx
+#define ML_NR_statx 332
+#else
+#define ML_NR_statx __NR_statx
+#endif
+#ifndef __NR_rseq
+#define ML_NR_rseq 334
+#else
+#define ML_NR_rseq __NR_rseq
+#endif
+#elif defined(__aarch64__)
+#ifndef __NR_statx
+#define ML_NR_statx 291
+#else
+#define ML_NR_statx __NR_statx
+#endif
+#ifndef __NR_rseq
+#define ML_NR_rseq 293
+#else
+#define ML_NR_rseq __NR_rseq
+#endif
+#endif
+#ifndef __NR_clone3
+#define ML_NR_clone3 435
+#else
+#define ML_NR_clone3 __NR_clone3
+#endif
+
+//! Syscalls permitted by the legacy in-process BPF filter
+//! (CSystemCallFilter_Linux.cc) for every process that installs it, currently
+//! shared by pytorch_inference, autodetect, categorize, normalize and
+//! data_frame_analyzer. This is the single machine-readable declaration that
+//! the applied BPF program is generated from: CSystemCallFilter_Linux.cc
+//! contains no independent syscall list and no manually maintained jump
+//! offsets. A future Sandbox2 policy is expected to consume the same
+//! declaration for its explicit grants, so both mechanisms stay in sync.
+//!
+//! Carry-forward note: PR #2873 fixed several pytorch_inference/libtorch
+//! compatibility gaps the hard way, and this declaration is a rewrite from
+//! scratch rather than a copy of that work, so it deliberately keeps two of
+//! them. ML_NR_clone3 (see 57f00ed1b) and __NR_prlimit64 (see 03b1ee4a) are
+//! carried into this shared declaration so a future Sandbox2 policy
+//! inherits them automatically instead of rediscovering them the same way;
+//! CSeccompFilterBuilderTest.cc asserts both stay present. The x86_64
+//! legacy filesystem syscalls below (see ec7d3ed85) were already part of
+//! this filter's syscall set prior to this declaration and remain
+//! unchanged. PR #2873's futex-op broadening (see d9a856d5f) and CI
+//! link-order/test-bundle packaging fixes (see 730933db, f8b0a534) apply to
+//! the Sandbox2 policy and its Buildkite pipeline respectively, not to this
+//! file — carry those forward when that code is written instead of
+//! rediscovering them.
+inline constexpr int kLegacyBpfAllowedSyscalls[] {
+#if defined(__x86_64__)
+ __NR_access, __NR_open, __NR_dup2, __NR_unlink, __NR_stat, __NR_lstat,
+ __NR_time, __NR_readlink, __NR_getdents, // for forecast temp storage
+ __NR_rmdir, // for forecast temp storage
+ __NR_mkdir, // for forecast temp storage
+ __NR_mknod,
+#elif defined(__aarch64__)
+ __NR_faccessat,
+#endif
+ __NR_fcntl, // for fdopendir
+ __NR_getrusage,
+ __NR_getpid, // for pthread_kill
+ ML_NR_statx, // for create_directories
+ __NR_getrandom, // for unique_path
+ __NR_mknodat, __NR_newfstatat, __NR_readlinkat, __NR_dup3,
+ __NR_getpriority, // for nice
+ __NR_setpriority, // for nice
+ __NR_read, __NR_write, __NR_writev, __NR_lseek, __NR_clock_gettime,
+ __NR_gettimeofday, __NR_fstat, __NR_close, __NR_connect, ML_NR_clone3,
+ __NR_clone, __NR_statfs,
+ __NR_mkdirat, // for forecast temp storage
+ __NR_unlinkat, // for forecast temp storage
+ __NR_getdents64, // for forecast temp storage
+ __NR_openat, // for forecast temp storage
+ __NR_tgkill, // for the crash handler
+ __NR_rt_sigaction, // for the crash handler
+ __NR_rt_sigreturn,
+ __NR_rt_sigprocmask, // for recent pthread_create
+ ML_NR_rseq, // for recent pthread_create
+ __NR_futex, __NR_madvise, __NR_nanosleep, __NR_set_robust_list,
+ __NR_mprotect, // for malloc arenas and pthread stacks
+ __NR_mremap, // for malloc arenas
+ __NR_munmap, // for malloc arenas
+ __NR_mmap, // for malloc arenas
+ __NR_getuid, __NR_exit_group, __NR_brk, __NR_exit,
+ __NR_prlimit64, // libtorch/Sandbox2-monitor query rlimits under load (03b1ee4a)
+};
+
+static_assert(std::size(kLegacyBpfAllowedSyscalls) <= 255,
+ "legacy BPF allowlist exceeds classic BPF jt (8-bit)");
+
+inline std::vector legacyBpfAllowedSyscalls() {
+ return {kLegacyBpfAllowedSyscalls,
+ kLegacyBpfAllowedSyscalls + std::size(kLegacyBpfAllowedSyscalls)};
+}
+
+#endif // __linux__
+
+} // namespace seccomp
+} // namespace ml
+
+#endif // INCLUDED_ml_seccomp_CMlLegacyBpfSyscallAllowlist_h
diff --git a/include/seccomp/CSeccompFilterBuilder.h b/include/seccomp/CSeccompFilterBuilder.h
new file mode 100644
index 0000000000..f142c42206
--- /dev/null
+++ b/include/seccomp/CSeccompFilterBuilder.h
@@ -0,0 +1,44 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License
+ * 2.0 and the following additional limitation. Functionality enabled by the
+ * files subject to the Elastic License 2.0 may only be used in production when
+ * invoked by an Elasticsearch process with a license key installed that permits
+ * use of machine learning features. You may not use this file except in
+ * compliance with the Elastic License 2.0 and the foregoing additional
+ * limitation.
+ */
+#ifndef INCLUDED_ml_seccomp_CSeccompFilterBuilder_h
+#define INCLUDED_ml_seccomp_CSeccompFilterBuilder_h
+
+#ifdef __linux__
+
+#include
+
+#include
+
+namespace ml {
+namespace seccomp {
+
+//! Builds a seccomp BPF program that allows exactly allowedSyscalls, on the
+//! native architecture only, and denies everything else with EACCES.
+//!
+//! The caller supplies allowedSyscalls in any order: every generated jump
+//! offset is derived from the vector's size and the row's own index, so
+//! adding, removing or reordering a syscall never requires updating any
+//! other row. This is the mechanism that lets CSystemCallFilter_Linux.cc
+//! apply CMlLegacyBpfSyscallAllowlist.h's declaration directly, instead
+//! of maintaining a second, hand-written BPF program with manual jump
+//! offsets that can silently drift from the declaration.
+//!
+//! Returns an empty program if allowedSyscalls has more than 255 entries:
+//! classic BPF jt/jf are 8-bit, so a larger list cannot be encoded without
+//! wrapping a matching syscall onto the wrong row. Callers must treat empty
+//! as a failed build and must not install it.
+std::vector buildSyscallAllowlistProgram(const std::vector& allowedSyscalls);
+}
+}
+
+#endif // __linux__
+
+#endif // INCLUDED_ml_seccomp_CSeccompFilterBuilder_h
diff --git a/include/seccomp/CSystemCallFilter.h b/include/seccomp/CSystemCallFilter.h
index 9855d27002..7d98e7ecca 100644
--- a/include/seccomp/CSystemCallFilter.h
+++ b/include/seccomp/CSystemCallFilter.h
@@ -13,6 +13,8 @@
#include
+#include
+
namespace ml {
namespace seccomp {
@@ -41,9 +43,85 @@ namespace seccomp {
//! Windows:
//! Job Objects prevent the process spawning another.
//!
+enum class ESystemCallFilterInstallOutcome {
+ E_Installed,
+ //! The platform mechanism itself is unavailable (e.g. kernel not built
+ //! with CONFIG_SECCOMP_FILTER).
+ E_MechanismUnavailable,
+ //! The mechanism is available but a required privilege-restriction step
+ //! failed (e.g. PR_SET_NO_NEW_PRIVS on Linux).
+ E_PrivilegeRestrictionFailed,
+ //! The mechanism is available but installing the filter/profile itself
+ //! failed.
+ E_FilterInstallFailed
+};
+
+//! Human-readable description of an install outcome, for diagnostics only;
+//! not a stable machine-parsed value.
+inline const char* describe(ESystemCallFilterInstallOutcome outcome) {
+ switch (outcome) {
+ case ESystemCallFilterInstallOutcome::E_Installed:
+ return "installed";
+ case ESystemCallFilterInstallOutcome::E_MechanismUnavailable:
+ return "mechanism unavailable";
+ case ESystemCallFilterInstallOutcome::E_PrivilegeRestrictionFailed:
+ return "privilege restriction failed";
+ case ESystemCallFilterInstallOutcome::E_FilterInstallFailed:
+ return "filter install failed";
+ }
+ return "unknown";
+}
+
+//! What a caller should do, given an install outcome and whether hard
+//! termination is currently enabled at that call site.
+enum class EDegradedModeAction {
+ E_ContinueDespiteFailure,
+ E_TerminateBeforeIo
+};
+
+//! Pure decision function: does this install outcome require terminating
+//! before untrusted IO/model processing?
+//!
+//! terminateOnFailure is an internal switch, not an operator setting. Every
+//! degraded-mode seccomp failure should eventually terminate before
+//! processing, but flipping that on for every call site before the
+//! ml-cpp/Elasticsearch controller protocol can guarantee a degraded-mode
+//! launch was a deliberate operator choice would fail every launch on a
+//! host lacking seccomp BPF, with no operator fallback setting to select
+//! instead. Callers pass false today; a later change wires the real route
+//! decision through this parameter once that guarantee exists.
+inline EDegradedModeAction decideDegradedModeAction(ESystemCallFilterInstallOutcome outcome,
+ bool terminateOnFailure) {
+ if (outcome == ESystemCallFilterInstallOutcome::E_Installed || !terminateOnFailure) {
+ return EDegradedModeAction::E_ContinueDespiteFailure;
+ }
+ return EDegradedModeAction::E_TerminateBeforeIo;
+}
+
+//! Structured signal a controller/Elasticsearch observer asserts to confirm
+//! that a legacy/degraded-mode pytorch_inference launch actually installed
+//! its in-process seccomp filter before processing untrusted model input.
+//! Replaces attesting readiness by inference — "no fatal log line appeared
+//! before initIo() ran" — with an explicit signal a test or observer can
+//! assert on directly. Returns empty when installation did not succeed so
+//! this marker can never falsely attest a filter that isn't there.
+//! Terminate-before-initIo() applies only when decideDegradedModeAction()
+//! is called with terminateOnFailure true (not today's production default).
+//! Logged over the existing per-process log pipe; this is not a new startup
+//! channel.
+inline std::string degradedModeAttestationMarker(ESystemCallFilterInstallOutcome outcome) {
+ if (outcome != ESystemCallFilterInstallOutcome::E_Installed) {
+ return std::string();
+ }
+ return R"({"ml_sandbox2_route":"legacy","event":"seccomp_installed"})";
+}
+
class CSystemCallFilter : private core::CNonInstantiatable {
public:
- static void installSystemCallFilter();
+ //! Installs the platform syscall filter. Returns the typed outcome so a
+ //! caller can decide whether to continue or terminate; callers must not
+ //! silently discard the result (see decideDegradedModeAction()).
+ [[nodiscard]] static ESystemCallFilterInstallOutcome installSystemCallFilter();
};
}
}
diff --git a/lib/seccomp/CSystemCallFilter_Linux.cc b/lib/seccomp/CSystemCallFilter_Linux.cc
index 466b58cd41..cc0c61174a 100644
--- a/lib/seccomp/CSystemCallFilter_Linux.cc
+++ b/lib/seccomp/CSystemCallFilter_Linux.cc
@@ -8,14 +8,27 @@
* compliance with the Elastic License 2.0 and the foregoing additional
* limitation.
*/
+
+/*
+ * NOTE: This seccomp filter is being gradually replaced by Sandbox2 policies
+ * for processes that are spawned via CDetachedProcessSpawner. The allowed
+ * syscall set lives in CMlLegacyBpfSyscallAllowlist.h, the single
+ * machine-readable declaration this filter is generated from; a future
+ * Sandbox2 policy is expected to consume the same declaration for its
+ * explicit grants.
+ */
#include
#include
+#include
+#include
+
#include
#include
#include
#include
+#include
#include
#include
@@ -30,125 +43,69 @@ namespace {
// The old x32 ABI always has bit 30 set in the sys call numbers.
// The x64 ABI should fail these calls
const std::uint32_t UPPER_NR_LIMIT = 0x3FFFFFFF;
+}
+
+std::vector buildSyscallAllowlistProgram(const std::vector& allowedSyscalls) {
+ // BPF_JMP jt/jf are 8-bit. Casting a larger count to uint8_t wraps, so the
+ // first matching syscall would fall through instead of reaching ALLOW.
+ if (allowedSyscalls.size() > std::numeric_limits::max()) {
+ return {};
+ }
+ const auto numSyscalls = static_cast(allowedSyscalls.size());
+
+ std::vector program;
+ program.reserve(numSyscalls + 6);
-const struct sock_filter FILTER[] = {
// Reject non-native ABIs before matching syscall numbers. Without this,
// an x86_64 process can issue int 0x80 (i386) and hit number collisions —
- // e.g. i386 socketcall (102) matches the allowlisted x86_64 getuid (102).
- // Hardening in response to a privately reported ML seccomp-bypass finding.
- // This prefix is self-contained (immediate RET on mismatch) so the relative
- // jump offsets in the nr allowlist below are unchanged.
- BPF_STMT(BPF_LD | BPF_W | BPF_ABS, offsetof(struct seccomp_data, arch)),
+ // e.g. i386 socketcall (102) matches an allowlisted x86_64 syscall with
+ // the same number. Hardening in response to a privately reported ML
+ // seccomp-bypass finding. This prefix is self-contained (immediate RET
+ // on mismatch), so it never affects the jump offsets below.
+ program.push_back(
+ BPF_STMT(BPF_LD | BPF_W | BPF_ABS, offsetof(struct seccomp_data, arch)));
#ifdef __x86_64__
- BPF_JUMP(BPF_JMP | BPF_JEQ | BPF_K, AUDIT_ARCH_X86_64, 1, 0),
+ program.push_back(BPF_JUMP(BPF_JMP | BPF_JEQ | BPF_K, AUDIT_ARCH_X86_64, 1, 0));
#elif defined(__aarch64__)
- BPF_JUMP(BPF_JMP | BPF_JEQ | BPF_K, AUDIT_ARCH_AARCH64, 1, 0),
+ program.push_back(BPF_JUMP(BPF_JMP | BPF_JEQ | BPF_K, AUDIT_ARCH_AARCH64, 1, 0));
+#else
+#error Unsupported hardware architecture
#endif
- BPF_STMT(BPF_RET | BPF_K, SECCOMP_RET_ERRNO | (EACCES & SECCOMP_RET_DATA)),
+ program.push_back(BPF_STMT(BPF_RET | BPF_K,
+ SECCOMP_RET_ERRNO | (EACCES & SECCOMP_RET_DATA)));
// Load the system call number into accumulator
- BPF_STMT(BPF_LD | BPF_W | BPF_ABS, offsetof(struct seccomp_data, nr)),
+ program.push_back(BPF_STMT(BPF_LD | BPF_W | BPF_ABS, offsetof(struct seccomp_data, nr)));
#ifdef __x86_64__
-// The statx, rseq and clone3 syscalls won't be defined on a RHEL/CentOS 7 build
-// machine, but might exist on the kernel we run on
-#ifndef __NR_statx
-#define __NR_statx 332
-#endif
-#ifndef __NR_rseq
-#define __NR_rseq 334
-#endif
-#ifndef __NR_clone3
-#define __NR_clone3 435
-#endif
- // Only applies to x86_64 arch. Jump to disallow for calls using the x32 ABI
- BPF_JUMP(BPF_JMP | BPF_JGT | BPF_K, UPPER_NR_LIMIT, 56, 0),
- // If any sys call filters are added or removed then the jump
- // destination for each statement including the one above must
- // be updated accordingly
-
- // Allowed architecture-specific sys calls, jump to return allow on match
- // Some of these are not used in latest glibc, and not supported in Linux
- // kernels for recent architectures, but in a few cases different sys calls
- // are used on different architectures
- BPF_JUMP(BPF_JMP | BPF_JEQ | BPF_K, __NR_access, 56, 0),
- BPF_JUMP(BPF_JMP | BPF_JEQ | BPF_K, __NR_open, 55, 0),
- BPF_JUMP(BPF_JMP | BPF_JEQ | BPF_K, __NR_dup2, 54, 0),
- BPF_JUMP(BPF_JMP | BPF_JEQ | BPF_K, __NR_unlink, 53, 0),
- BPF_JUMP(BPF_JMP | BPF_JEQ | BPF_K, __NR_stat, 52, 0),
- BPF_JUMP(BPF_JMP | BPF_JEQ | BPF_K, __NR_lstat, 51, 0),
- BPF_JUMP(BPF_JMP | BPF_JEQ | BPF_K, __NR_time, 50, 0),
- BPF_JUMP(BPF_JMP | BPF_JEQ | BPF_K, __NR_readlink, 49, 0),
- BPF_JUMP(BPF_JMP | BPF_JEQ | BPF_K, __NR_getdents, 48, 0), // for forecast temp storage
- BPF_JUMP(BPF_JMP | BPF_JEQ | BPF_K, __NR_rmdir, 47, 0), // for forecast temp storage
- BPF_JUMP(BPF_JMP | BPF_JEQ | BPF_K, __NR_mkdir, 46, 0), // for forecast temp storage
- BPF_JUMP(BPF_JMP | BPF_JEQ | BPF_K, __NR_mknod, 45, 0),
-#elif defined(__aarch64__)
-// The statx, rseq and clone3 syscalls won't be defined on a RHEL/CentOS 7 build
-// machine, but might exist on the kernel we run on
-#ifndef __NR_statx
-#define __NR_statx 291
-#endif
-#ifndef __NR_rseq
-#define __NR_rseq 293
-#endif
-#ifndef __NR_clone3
-#define __NR_clone3 435
-#endif
- BPF_JUMP(BPF_JMP | BPF_JEQ | BPF_K, __NR_faccessat, 45, 0),
-#else
-#error Unsupported hardware architecture
+ // Jump to the deny row (immediately after the last syscall row below,
+ // i.e. numSyscalls rows ahead) for calls using the x32 ABI, without
+ // checking any allowlisted syscall.
+ program.push_back(BPF_JUMP(BPF_JMP | BPF_JGT | BPF_K, UPPER_NR_LIMIT,
+ static_cast(numSyscalls), 0));
#endif
- // Allowed sys calls for all architectures, jump to return allow on match
- BPF_JUMP(BPF_JMP | BPF_JEQ | BPF_K, __NR_fcntl, 44, 0), // for fdopendir
- BPF_JUMP(BPF_JMP | BPF_JEQ | BPF_K, __NR_getrusage, 43, 0),
- BPF_JUMP(BPF_JMP | BPF_JEQ | BPF_K, __NR_getpid, 42, 0), // for pthread_kill
- BPF_JUMP(BPF_JMP | BPF_JEQ | BPF_K, __NR_statx, 41, 0), // for create_directories
- BPF_JUMP(BPF_JMP | BPF_JEQ | BPF_K, __NR_getrandom, 40, 0), // for unique_path
- BPF_JUMP(BPF_JMP | BPF_JEQ | BPF_K, __NR_mknodat, 39, 0),
- BPF_JUMP(BPF_JMP | BPF_JEQ | BPF_K, __NR_newfstatat, 38, 0),
- BPF_JUMP(BPF_JMP | BPF_JEQ | BPF_K, __NR_readlinkat, 37, 0),
- BPF_JUMP(BPF_JMP | BPF_JEQ | BPF_K, __NR_dup3, 36, 0),
- BPF_JUMP(BPF_JMP | BPF_JEQ | BPF_K, __NR_getpriority, 35, 0), // for nice
- BPF_JUMP(BPF_JMP | BPF_JEQ | BPF_K, __NR_setpriority, 34, 0), // for nice
- BPF_JUMP(BPF_JMP | BPF_JEQ | BPF_K, __NR_read, 33, 0),
- BPF_JUMP(BPF_JMP | BPF_JEQ | BPF_K, __NR_write, 32, 0),
- BPF_JUMP(BPF_JMP | BPF_JEQ | BPF_K, __NR_writev, 31, 0),
- BPF_JUMP(BPF_JMP | BPF_JEQ | BPF_K, __NR_lseek, 30, 0),
- BPF_JUMP(BPF_JMP | BPF_JEQ | BPF_K, __NR_clock_gettime, 29, 0),
- BPF_JUMP(BPF_JMP | BPF_JEQ | BPF_K, __NR_gettimeofday, 28, 0),
- BPF_JUMP(BPF_JMP | BPF_JEQ | BPF_K, __NR_fstat, 27, 0),
- BPF_JUMP(BPF_JMP | BPF_JEQ | BPF_K, __NR_close, 26, 0),
- BPF_JUMP(BPF_JMP | BPF_JEQ | BPF_K, __NR_connect, 25, 0),
- BPF_JUMP(BPF_JMP | BPF_JEQ | BPF_K, __NR_clone3, 24, 0),
- BPF_JUMP(BPF_JMP | BPF_JEQ | BPF_K, __NR_clone, 23, 0),
- BPF_JUMP(BPF_JMP | BPF_JEQ | BPF_K, __NR_statfs, 22, 0),
- BPF_JUMP(BPF_JMP | BPF_JEQ | BPF_K, __NR_mkdirat, 21, 0), // for forecast temp storage
- BPF_JUMP(BPF_JMP | BPF_JEQ | BPF_K, __NR_unlinkat, 20, 0), // for forecast temp storage
- BPF_JUMP(BPF_JMP | BPF_JEQ | BPF_K, __NR_getdents64, 19, 0), // for forecast temp storage
- BPF_JUMP(BPF_JMP | BPF_JEQ | BPF_K, __NR_openat, 18, 0), // for forecast temp storage
- BPF_JUMP(BPF_JMP | BPF_JEQ | BPF_K, __NR_tgkill, 17, 0), // for the crash handler
- BPF_JUMP(BPF_JMP | BPF_JEQ | BPF_K, __NR_rt_sigaction, 16, 0), // for the crash handler
- BPF_JUMP(BPF_JMP | BPF_JEQ | BPF_K, __NR_rt_sigreturn, 15, 0),
- BPF_JUMP(BPF_JMP | BPF_JEQ | BPF_K, __NR_rt_sigprocmask, 14, 0), // for recent pthread_create
- BPF_JUMP(BPF_JMP | BPF_JEQ | BPF_K, __NR_rseq, 13, 0), // for recent pthread_create
- BPF_JUMP(BPF_JMP | BPF_JEQ | BPF_K, __NR_futex, 12, 0),
- BPF_JUMP(BPF_JMP | BPF_JEQ | BPF_K, __NR_madvise, 11, 0),
- BPF_JUMP(BPF_JMP | BPF_JEQ | BPF_K, __NR_nanosleep, 10, 0),
- BPF_JUMP(BPF_JMP | BPF_JEQ | BPF_K, __NR_set_robust_list, 9, 0),
- BPF_JUMP(BPF_JMP | BPF_JEQ | BPF_K, __NR_mprotect, 8, 0), // for malloc arenas and pthread stacks
- BPF_JUMP(BPF_JMP | BPF_JEQ | BPF_K, __NR_mremap, 7, 0), // for malloc arenas
- BPF_JUMP(BPF_JMP | BPF_JEQ | BPF_K, __NR_munmap, 6, 0), // for malloc arenas
- BPF_JUMP(BPF_JMP | BPF_JEQ | BPF_K, __NR_mmap, 5, 0), // for malloc arenas
- BPF_JUMP(BPF_JMP | BPF_JEQ | BPF_K, __NR_getuid, 4, 0),
- BPF_JUMP(BPF_JMP | BPF_JEQ | BPF_K, __NR_exit_group, 3, 0),
- BPF_JUMP(BPF_JMP | BPF_JEQ | BPF_K, __NR_brk, 2, 0),
- BPF_JUMP(BPF_JMP | BPF_JEQ | BPF_K, __NR_exit, 1, 0),
+ // Every syscall row jumps to the terminal SECCOMP_RET_ALLOW row on match.
+ // The jump distance is derived from the row's own index and the total
+ // count, so adding, removing or reordering an entry in allowedSyscalls
+ // never requires touching any other row.
+ for (std::uint32_t i = 0; i < numSyscalls; ++i) {
+ const auto jumpToAllow = static_cast(numSyscalls - i);
+ program.push_back(BPF_JUMP(BPF_JMP | BPF_JEQ | BPF_K,
+ static_cast(allowedSyscalls[i]),
+ jumpToAllow, 0));
+ }
+
// Disallow call with error code EACCES
- BPF_STMT(BPF_RET | BPF_K, SECCOMP_RET_ERRNO | (EACCES & SECCOMP_RET_DATA)),
+ program.push_back(BPF_STMT(BPF_RET | BPF_K,
+ SECCOMP_RET_ERRNO | (EACCES & SECCOMP_RET_DATA)));
// Allow call
- BPF_STMT(BPF_RET | BPF_K, SECCOMP_RET_ALLOW)};
+ program.push_back(BPF_STMT(BPF_RET | BPF_K, SECCOMP_RET_ALLOW));
+
+ return program;
+}
+
+namespace {
bool canUseSeccompBpf() {
// This call is expected to fail due to the nullptr argument
@@ -170,40 +127,48 @@ bool canUseSeccompBpf() {
}
}
-void CSystemCallFilter::installSystemCallFilter() {
- if (canUseSeccompBpf()) {
- LOG_DEBUG(<< "Seccomp BPF filters available");
-
- // Ensure more permissive privileges cannot be set in future.
- // This must be set before installing the filter.
- // PR_SET_NO_NEW_PRIVS was aded in kernel 3.5
- if (prctl(PR_SET_NO_NEW_PRIVS, 1, 0, 0, 0)) {
- LOG_ERROR(<< "prctl PR_SET_NO_NEW_PRIVS failed: " << std::strerror(errno));
- return;
- }
-
- struct sock_fprog prog = {
- .len = static_cast(sizeof(FILTER) / sizeof(FILTER[0])),
- .filter = const_cast(FILTER)};
-
- // Install the filter.
- // prctl(PR_SET_SECCOMP, SECCOMP_MODE_FILTER, filter) was introduced
- // in kernel 3.5. This is functionally equivalent to
- // seccomp(SECCOMP_SET_MODE_FILTER, 0, filter) which was added in
- // kernel 3.17. We choose the older more compatible function.
- // Note this precludes the use of calling seccomp() with the
- // SECCOMP_FILTER_FLAG_TSYNC which is acceptable if the filter
- // is installed by the main thread before any other threads are
- // spawned.
- if (prctl(PR_SET_SECCOMP, SECCOMP_MODE_FILTER, &prog)) {
- LOG_ERROR(<< "Unable to install Seccomp BPF: " << std::strerror(errno));
- } else {
- LOG_DEBUG(<< "Seccomp BPF installed");
- }
-
- } else {
+ESystemCallFilterInstallOutcome CSystemCallFilter::installSystemCallFilter() {
+ if (canUseSeccompBpf() == false) {
LOG_DEBUG(<< "Seccomp BPF not available");
+ return ESystemCallFilterInstallOutcome::E_MechanismUnavailable;
+ }
+ LOG_DEBUG(<< "Seccomp BPF filters available");
+
+ // Ensure more permissive privileges cannot be set in future.
+ // This must be set before installing the filter.
+ // PR_SET_NO_NEW_PRIVS was added in kernel 3.5
+ if (prctl(PR_SET_NO_NEW_PRIVS, 1, 0, 0, 0)) {
+ LOG_ERROR(<< "prctl PR_SET_NO_NEW_PRIVS failed: " << std::strerror(errno));
+ return ESystemCallFilterInstallOutcome::E_PrivilegeRestrictionFailed;
+ }
+
+ const std::vector program{
+ buildSyscallAllowlistProgram(legacyBpfAllowedSyscalls())};
+ if (program.empty()) {
+ LOG_ERROR(<< "Seccomp BPF program generation failed");
+ return ESystemCallFilterInstallOutcome::E_FilterInstallFailed;
}
+
+ struct sock_fprog prog = {.len = static_cast(program.size()),
+ .filter = const_cast(program.data())};
+
+ // Install the filter.
+ // prctl(PR_SET_SECCOMP, SECCOMP_MODE_FILTER, filter) was introduced
+ // in kernel 3.5. This is functionally equivalent to
+ // seccomp(SECCOMP_SET_MODE_FILTER, 0, filter) which was added in
+ // kernel 3.17. We choose the older more compatible function.
+ // Note this precludes the use of calling seccomp() with the
+ // SECCOMP_FILTER_FLAG_TSYNC which is acceptable if the filter
+ // is installed by the main thread before any other threads are
+ // spawned.
+ if (prctl(PR_SET_SECCOMP, SECCOMP_MODE_FILTER, &prog)) {
+ LOG_ERROR(<< "Unable to install Seccomp BPF: " << std::strerror(errno));
+ return ESystemCallFilterInstallOutcome::E_FilterInstallFailed;
+ }
+
+ LOG_DEBUG(<< "Seccomp BPF installed");
+ LOG_INFO(<< "ml.seccomp.installed");
+ return ESystemCallFilterInstallOutcome::E_Installed;
}
}
}
diff --git a/lib/seccomp/CSystemCallFilter_MacOSX.cc b/lib/seccomp/CSystemCallFilter_MacOSX.cc
index 3756875d06..2b7c14377b 100644
--- a/lib/seccomp/CSystemCallFilter_MacOSX.cc
+++ b/lib/seccomp/CSystemCallFilter_MacOSX.cc
@@ -87,13 +87,17 @@ std::string writeTempRulesFile() {
}
}
-void CSystemCallFilter::installSystemCallFilter() {
+ESystemCallFilterInstallOutcome CSystemCallFilter::installSystemCallFilter() {
std::string profileFilename{writeTempRulesFile()};
if (profileFilename.empty()) {
LOG_WARN(<< "Cannot write sandbox rules. macOS sandbox will not be initialized");
- return;
+ // mkstemps / temp-file I/O failure is a setup failure. It does not
+ // prove the sandbox facility is absent, so this is not
+ // E_MechanismUnavailable.
+ return ESystemCallFilterInstallOutcome::E_FilterInstallFailed;
}
+ ESystemCallFilterInstallOutcome outcome{ESystemCallFilterInstallOutcome::E_Installed};
char* errorbuf{nullptr};
if (::sandbox_init(profileFilename.c_str(), SANDBOX_NAMED, &errorbuf) != 0) {
std::string msg("Error initializing macOS sandbox");
@@ -103,11 +107,14 @@ void CSystemCallFilter::installSystemCallFilter() {
::sandbox_free_error(errorbuf);
}
LOG_ERROR(<< msg);
+ outcome = ESystemCallFilterInstallOutcome::E_FilterInstallFailed;
} else {
LOG_DEBUG(<< "macOS sandbox initialized");
+ LOG_INFO(<< "ml.seccomp.installed");
}
std::remove(profileFilename.c_str());
+ return outcome;
}
}
}
diff --git a/lib/seccomp/CSystemCallFilter_Windows.cc b/lib/seccomp/CSystemCallFilter_Windows.cc
index ce4924c629..16a3928f10 100644
--- a/lib/seccomp/CSystemCallFilter_Windows.cc
+++ b/lib/seccomp/CSystemCallFilter_Windows.cc
@@ -27,11 +27,11 @@ struct SCheckedHandle {
};
}
-void CSystemCallFilter::installSystemCallFilter() {
+ESystemCallFilterInstallOutcome CSystemCallFilter::installSystemCallFilter() {
HANDLE job = CreateJobObject(nullptr, nullptr);
if (job == nullptr) {
LOG_ERROR(<< "Failed to create Job Object: " << ml::core::CWindowsError());
- return;
+ return ESystemCallFilterInstallOutcome::E_MechanismUnavailable;
}
// The job is not destroyed until the handle is closed
@@ -44,7 +44,7 @@ void CSystemCallFilter::installSystemCallFilter() {
if (QueryInformationJobObject(job, JobObjectBasicLimitInformation, &limits,
sizeof(limits), nullptr) == 0) {
LOG_ERROR(<< "Error querying Job Object information: " << ml::core::CWindowsError());
- return;
+ return ESystemCallFilterInstallOutcome::E_FilterInstallFailed;
}
// Limit the number of active processes to 1 and
@@ -54,16 +54,18 @@ void CSystemCallFilter::installSystemCallFilter() {
if (SetInformationJobObject(job, JobObjectBasicLimitInformation, &limits,
sizeof(limits)) == 0) {
LOG_ERROR(<< "Error setting Job information: " << ml::core::CWindowsError());
- return;
+ return ESystemCallFilterInstallOutcome::E_FilterInstallFailed;
}
// Assign current process to the job
if (AssignProcessToJobObject(job, GetCurrentProcess()) == 0) {
LOG_ERROR(<< "Error assigning process to Job Object: " << ml::core::CWindowsError());
- return;
+ return ESystemCallFilterInstallOutcome::E_FilterInstallFailed;
}
LOG_DEBUG(<< "ActiveProcessLimit set to 1 for new Job Object");
+ LOG_INFO(<< "ml.seccomp.installed");
+ return ESystemCallFilterInstallOutcome::E_Installed;
}
}
}
diff --git a/lib/seccomp/unittest/CMakeLists.txt b/lib/seccomp/unittest/CMakeLists.txt
index 7af78c795c..2656170e85 100644
--- a/lib/seccomp/unittest/CMakeLists.txt
+++ b/lib/seccomp/unittest/CMakeLists.txt
@@ -13,6 +13,7 @@ project("ML Seccomp unit tests")
set (SRCS
Main.cc
+ CSeccompFilterBuilderTest.cc
CSystemCallFilterTest.cc
)
diff --git a/lib/seccomp/unittest/CSeccompFilterBuilderTest.cc b/lib/seccomp/unittest/CSeccompFilterBuilderTest.cc
new file mode 100644
index 0000000000..e9c3ec32d6
--- /dev/null
+++ b/lib/seccomp/unittest/CSeccompFilterBuilderTest.cc
@@ -0,0 +1,281 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License
+ * 2.0 and the following additional limitation. Functionality enabled by the
+ * files subject to the Elastic License 2.0 may only be used in production when
+ * invoked by an Elasticsearch process with a license key installed that permits
+ * use of machine learning features. You may not use this file except in
+ * compliance with the Elastic License 2.0 and the foregoing additional
+ * limitation.
+ */
+
+#include
+
+#include
+
+#include
+
+#ifdef __linux__
+
+// These must be included before BOOST_AUTO_TEST_SUITE() opens a namespace:
+// BOOST_AUTO_TEST_SUITE(name) expands to `namespace name { ... }`, so any
+// #include placed after it would get its declarations nested inside that
+// namespace instead of at global scope, shadowing ::ml::seccomp with an
+// incomplete duplicate.
+#include
+#include
+
+#include
+#include
+#include
+#include
+#include
+#include
+
+#endif // __linux__
+
+BOOST_AUTO_TEST_SUITE(CSeccompFilterBuilderTest)
+
+#ifdef __linux__
+
+namespace {
+
+//! Decodes the syscall numbers this builder actually applies, by walking the
+//! generated program rather than re-reading the declaration it was built
+//! from. This is the proof that the applied program matches the
+//! declaration, not a comparison of two independently maintained lists.
+//!
+//! Rows before the syscall-number load (the arch load/check prefix) also use
+//! BPF_JMP|BPF_JEQ|BPF_K, so decoding starts only once that load is seen.
+std::set decodeAppliedSyscalls(const std::vector& program) {
+ std::set applied;
+ bool sawNrLoad{false};
+ for (const auto& instr : program) {
+ if (instr.code == (BPF_LD | BPF_W | BPF_ABS) &&
+ instr.k == offsetof(struct seccomp_data, nr)) {
+ sawNrLoad = true;
+ continue;
+ }
+ if (sawNrLoad && instr.code == (BPF_JMP | BPF_JEQ | BPF_K) && instr.jt > 0) {
+ applied.insert(static_cast(instr.k));
+ }
+ }
+ return applied;
+}
+
+} // namespace
+
+BOOST_AUTO_TEST_CASE(testAppliedProgramMatchesDeclaration) {
+ const std::vector declared{ml::seccomp::legacyBpfAllowedSyscalls()};
+ const std::vector program{ml::seccomp::buildSyscallAllowlistProgram(declared)};
+
+ const std::set declaredSet{declared.begin(), declared.end()};
+ BOOST_REQUIRE_EQUAL(declaredSet.size(), declared.size()); // declaration has no duplicates
+ const std::set appliedSet{decodeAppliedSyscalls(program)};
+ BOOST_REQUIRE_EQUAL_COLLECTIONS(declaredSet.begin(), declaredSet.end(),
+ appliedSet.begin(), appliedSet.end());
+
+ // Structural invariants that must hold regardless of declaration content:
+ // native-arch load/check, syscall-number load, and a final deny/allow
+ // pair. No index into this vector is hand-maintained anywhere in
+ // production code.
+ BOOST_TEST_REQUIRE(program.size() >= declared.size() + 4);
+ BOOST_REQUIRE_EQUAL(static_cast(BPF_RET | BPF_K),
+ static_cast(program.back().code));
+ BOOST_REQUIRE_EQUAL(static_cast(SECCOMP_RET_ALLOW),
+ program.back().k);
+ const auto& denyRow = program[program.size() - 2];
+ BOOST_REQUIRE_EQUAL(static_cast(BPF_RET | BPF_K),
+ static_cast(denyRow.code));
+ BOOST_TEST_REQUIRE(denyRow.k != SECCOMP_RET_ALLOW);
+}
+
+BOOST_AUTO_TEST_CASE(testJumpOffsetsAreDerivedNotHandMaintained) {
+ // An arbitrary, deliberately unordered and out-of-production-order list.
+ // If any jump offset were hand-maintained rather than derived from the
+ // vector's size/index, reordering or resizing this list would desync it
+ // from the generated rows; this test would fail with a stale allowlist
+ // but pass immediately once regenerated, which is exactly the property
+ // "no manual BPF jump offsets remain" requires.
+ const std::vector arbitrarySyscalls{200, 1, 57, 9, 300};
+ const std::vector program{
+ ml::seccomp::buildSyscallAllowlistProgram(arbitrarySyscalls)};
+
+ const std::size_t allowIndex{program.size() - 1};
+ const std::size_t denyIndex{program.size() - 2};
+ BOOST_REQUIRE_EQUAL(static_cast(SECCOMP_RET_ALLOW),
+ program[allowIndex].k);
+ BOOST_TEST_REQUIRE(program[denyIndex].k != SECCOMP_RET_ALLOW);
+
+ // Every syscall row's own jt must land exactly on the allow row: for a
+ // row at absolute index i, i + jt + 1 == allowIndex. The arch load/check
+ // prefix also uses BPF_JMP|BPF_JEQ|BPF_K but targets the nr-load
+ // instruction, not the allow row, so decoding starts only after the
+ // syscall-number load is seen (mirrors decodeAppliedSyscalls() above).
+ std::set foundSyscalls;
+ bool sawNrLoad{false};
+ for (std::size_t i = 0; i < program.size(); ++i) {
+ if (program[i].code == (BPF_LD | BPF_W | BPF_ABS) &&
+ program[i].k == offsetof(struct seccomp_data, nr)) {
+ sawNrLoad = true;
+ continue;
+ }
+ if (sawNrLoad && program[i].code == (BPF_JMP | BPF_JEQ | BPF_K) &&
+ program[i].jt > 0) {
+ BOOST_REQUIRE_EQUAL(allowIndex, i + program[i].jt + 1);
+ foundSyscalls.insert(static_cast(program[i].k));
+ }
+ }
+ const std::set expected{arbitrarySyscalls.begin(), arbitrarySyscalls.end()};
+ BOOST_REQUIRE_EQUAL_COLLECTIONS(expected.begin(), expected.end(),
+ foundSyscalls.begin(), foundSyscalls.end());
+
+#ifdef __x86_64__
+ // The x32-ABI guard must jump to the deny row (numSyscalls rows ahead of
+ // the JGT instruction), not hand-maintained like the old static FILTER[].
+ constexpr std::uint32_t upperNrLimit{0x3FFFFFFF};
+ bool sawX32Guard{false};
+ sawNrLoad = false;
+ for (std::size_t i = 0; i < program.size(); ++i) {
+ if (program[i].code == (BPF_LD | BPF_W | BPF_ABS) &&
+ program[i].k == offsetof(struct seccomp_data, nr)) {
+ sawNrLoad = true;
+ continue;
+ }
+ if (sawNrLoad && program[i].code == (BPF_JMP | BPF_JGT | BPF_K)) {
+ BOOST_REQUIRE_EQUAL(upperNrLimit, program[i].k);
+ BOOST_REQUIRE_EQUAL(denyIndex, i + program[i].jt + 1);
+ sawX32Guard = true;
+ break;
+ }
+ }
+ BOOST_TEST_REQUIRE(sawX32Guard);
+#endif
+}
+
+BOOST_AUTO_TEST_CASE(testAllowlistAtEightBitJumpLimitStillBuilds) {
+ const std::vector atLimit(std::numeric_limits::max(), 1);
+ const std::vector program{ml::seccomp::buildSyscallAllowlistProgram(atLimit)};
+ BOOST_TEST_REQUIRE(program.empty() == false);
+ BOOST_REQUIRE_EQUAL(static_cast(SECCOMP_RET_ALLOW),
+ program.back().k);
+}
+
+BOOST_AUTO_TEST_CASE(testOversizedAllowlistProducesEmptyProgram) {
+ // The production declaration is compile-time capped at 255, but this
+ // builder accepts an arbitrary vector. A wrapped jt would still look like
+ // a well-formed program; fail closed with empty instead.
+ const std::vector oversized(
+ static_cast(std::numeric_limits::max()) + 1, 1);
+ const std::vector program{ml::seccomp::buildSyscallAllowlistProgram(oversized)};
+ BOOST_TEST_REQUIRE(program.empty());
+}
+
+BOOST_AUTO_TEST_CASE(testArchGuardRejectsNonNativeAbi) {
+ const std::vector program{
+ ml::seccomp::buildSyscallAllowlistProgram(std::vector{1})};
+
+ BOOST_TEST_REQUIRE(program.size() >= 3);
+ BOOST_REQUIRE_EQUAL(static_cast(BPF_LD | BPF_W | BPF_ABS),
+ static_cast(program[0].code));
+ BOOST_REQUIRE_EQUAL(static_cast(offsetof(struct seccomp_data, arch)),
+ program[0].k);
+ BOOST_REQUIRE_EQUAL(static_cast(BPF_JMP | BPF_JEQ | BPF_K),
+ static_cast(program[1].code));
+#ifdef __x86_64__
+ BOOST_REQUIRE_EQUAL(static_cast(AUDIT_ARCH_X86_64), program[1].k);
+#elif defined(__aarch64__)
+ BOOST_REQUIRE_EQUAL(static_cast(AUDIT_ARCH_AARCH64),
+ program[1].k);
+#endif
+ BOOST_REQUIRE_EQUAL(static_cast(BPF_RET | BPF_K),
+ static_cast(program[2].code));
+ BOOST_TEST_REQUIRE(program[2].k != SECCOMP_RET_ALLOW);
+}
+
+BOOST_AUTO_TEST_CASE(testCarryForwardSyscallsPresent) {
+ // This declaration must
+ // not silently drop pytorch_inference/libtorch compatibility fixes.
+ // Each assertion below is a named regression test for one carried-forward
+ // fix within this file's scope.
+ const std::vector syscalls{ml::seccomp::legacyBpfAllowedSyscalls()};
+ const std::set declared{syscalls.begin(), syscalls.end()};
+
+ // 57f00ed1b: clone3 must be allowed by its literal syscall number (435 on
+ // both x86_64 and aarch64), not only via __NR_clone3, because some build
+ // images' kernel headers predate clone3 while the runtime glibc uses it.
+ BOOST_TEST_REQUIRE(declared.count(435) == 1);
+
+ // 03b1ee4a: prlimit64, queried by libtorch/the Sandbox2 monitor under
+ // sustained load.
+ BOOST_TEST_REQUIRE(declared.count(__NR_prlimit64) == 1);
+
+#ifdef __x86_64__
+ // ec7d3ed85: glibc's x86_64 file-system wrappers issue these legacy
+ // syscalls (not their *at equivalents) when pytorch_inference creates
+ // and tears down its named pipes.
+ const int legacyFsSyscalls[]{__NR_mknod, __NR_unlink, __NR_rmdir,
+ __NR_mkdir, __NR_readlink, __NR_access,
+ __NR_dup2};
+ for (int nr : legacyFsSyscalls) {
+ BOOST_TEST_REQUIRE(declared.count(nr) == 1);
+ }
+#endif
+}
+
+#endif // __linux__
+
+BOOST_AUTO_TEST_CASE(testDegradedModeAttestationMarker) {
+ using ml::seccomp::ESystemCallFilterInstallOutcome;
+ using ml::seccomp::degradedModeAttestationMarker;
+
+ // The marker must be present and exact on success - this is what a
+ // controller/Elasticsearch observer asserts, replacing "no fatal log
+ // line appeared" as an implicit readiness signal.
+ BOOST_REQUIRE_EQUAL(
+ std::string("{\"ml_sandbox2_route\":\"legacy\",\"event\":\"seccomp_installed\"}"),
+ degradedModeAttestationMarker(ESystemCallFilterInstallOutcome::E_Installed));
+
+ // Every failure class must attest nothing - a caller that logged this
+ // marker on a failed install would falsely claim protection that isn't
+ // there.
+ BOOST_TEST_REQUIRE(degradedModeAttestationMarker(ESystemCallFilterInstallOutcome::E_MechanismUnavailable)
+ .empty());
+ BOOST_TEST_REQUIRE(degradedModeAttestationMarker(ESystemCallFilterInstallOutcome::E_PrivilegeRestrictionFailed)
+ .empty());
+ BOOST_TEST_REQUIRE(degradedModeAttestationMarker(ESystemCallFilterInstallOutcome::E_FilterInstallFailed)
+ .empty());
+}
+
+BOOST_AUTO_TEST_CASE(testDecideDegradedModeActionFaultInjection) {
+ using ml::seccomp::EDegradedModeAction;
+ using ml::seccomp::ESystemCallFilterInstallOutcome;
+ using ml::seccomp::decideDegradedModeAction;
+
+ // Successful installation never terminates, regardless of the switch.
+ BOOST_REQUIRE_EQUAL(static_cast(EDegradedModeAction::E_ContinueDespiteFailure),
+ static_cast(decideDegradedModeAction(
+ ESystemCallFilterInstallOutcome::E_Installed, false)));
+ BOOST_REQUIRE_EQUAL(static_cast(EDegradedModeAction::E_ContinueDespiteFailure),
+ static_cast(decideDegradedModeAction(
+ ESystemCallFilterInstallOutcome::E_Installed, true)));
+
+ // Every fault-injected failure class - capability probe failure,
+ // PR_SET_NO_NEW_PRIVS, and filter installation - with the internal
+ // switch off (today's production default), every call site continues;
+ // with it on (the behaviour a later change activates), every one
+ // terminates.
+ const ESystemCallFilterInstallOutcome failureModes[]{
+ ESystemCallFilterInstallOutcome::E_MechanismUnavailable,
+ ESystemCallFilterInstallOutcome::E_PrivilegeRestrictionFailed,
+ ESystemCallFilterInstallOutcome::E_FilterInstallFailed};
+
+ for (const auto outcome : failureModes) {
+ BOOST_REQUIRE_EQUAL(static_cast(EDegradedModeAction::E_ContinueDespiteFailure),
+ static_cast(decideDegradedModeAction(outcome, false)));
+ BOOST_REQUIRE_EQUAL(static_cast(EDegradedModeAction::E_TerminateBeforeIo),
+ static_cast(decideDegradedModeAction(outcome, true)));
+ }
+}
+
+BOOST_AUTO_TEST_SUITE_END()
diff --git a/lib/seccomp/unittest/CSystemCallFilterTest.cc b/lib/seccomp/unittest/CSystemCallFilterTest.cc
index a9024673d1..dd3983571f 100644
--- a/lib/seccomp/unittest/CSystemCallFilterTest.cc
+++ b/lib/seccomp/unittest/CSystemCallFilterTest.cc
@@ -275,7 +275,9 @@ BOOST_AUTO_TEST_CASE(testSystemCallFilter) {
#endif
// Install the filter
- ml::seccomp::CSystemCallFilter::installSystemCallFilter();
+ BOOST_REQUIRE_EQUAL(
+ static_cast(ml::seccomp::ESystemCallFilterInstallOutcome::E_Installed),
+ static_cast(ml::seccomp::CSystemCallFilter::installSystemCallFilter()));
#if defined(Linux) && defined(__x86_64__)
if (i386CompatUsable) {
From fac514e97cf1c8d4f470be011cf648f62ffdb7f4 Mon Sep 17 00:00:00 2001
From: Valeriy Khakhutskyy <1292899+valeriy42@users.noreply.github.com>
Date: Mon, 21 Sep 2026 20:29:53 +0200
Subject: [PATCH 03/10] [ML] Typed filesystem/network launch policy for
Sandbox2 pytorch_inference (#3185)
Stacks on #3182.
Replaces raw argument-directory inference in `CPytorchInferenceSandboxPolicy` with a typed launch spec: every `input`/`output`/`restore`/`logPipe` path is validated against a pinned child-root contract (`$TMPDIR/ml-child-ipc/`) before any policy is built. Rejects relative, root-level, dot-dot, out-of-root, wrong-depth, duplicate, mutable-symlink/alias, and cross-option child-id-mismatch paths - never widens a mount to recover a rejected argument.
Also minimizes the filesystem policy: enumerates and justifies all seven historically bulk-mounted fixed directories, replaces whole `/etc` with five individually justified files, never binds host `/proc`/`/sys` (relies on Sandbox2's own namespaced procfs/sysfs), uses a private bounded tmpfs at `/tmp` instead of the host's, and consumes the syscall allowlist already shared with the legacy BPF filter instead of hand-duplicating it.
Adds a purpose-built allowlisted mechanism probe (`ml_sandbox_probe`) proving allowed IPC access, denied host reads, denied external egress, loopback reachability, and mount enumeration, plus a portable validator unit-test suite and a Linux-only mechanism integration test.
Verified this session: the validator's core logic compiles clean with `-Wall -Wextra -Werror` and passes a standalone driver covering every rejection/acceptance path against real `mkdtemp`/`mkdir`/`symlink` fixtures. The `SANDBOX2_AVAILABLE`/Linux path compiles clean against stub sandbox2/seccomp headers (no vendored Sandbox2 headers available on this host). Not yet verified: an actual Sandbox2 run of the mechanism probe and the real Linux CMake/build integration - needs a Linux CI or devbox pass, in progress. Also fixes a Windows build break this change would otherwise have introduced (POSIX-only realpath/PATH_MAX used unconditionally in a file ml-cpp builds on every platform).
(cherry picked from commit 2ce41a8918348e89c8da075651119ec1f63af0ee)
---
.../sandbox/CPytorchInferenceSandboxPolicy.h | 114 +++++
lib/sandbox/CMakeLists.txt | 11 +-
lib/sandbox/CPytorchInferenceSandboxPolicy.cc | 427 ++++++++++++++++++
lib/sandbox/unittest/CMakeLists.txt | 30 ++
...ferenceSandboxPolicyMechanismTest_Linux.cc | 212 +++++++++
.../CPytorchInferenceSandboxPolicyTest.cc | 266 +++++++++++
.../unittest/payloads/ml_sandbox_probe.cc | 198 ++++++++
7 files changed, 1254 insertions(+), 4 deletions(-)
create mode 100644 include/sandbox/CPytorchInferenceSandboxPolicy.h
create mode 100644 lib/sandbox/CPytorchInferenceSandboxPolicy.cc
create mode 100644 lib/sandbox/unittest/CPytorchInferenceSandboxPolicyMechanismTest_Linux.cc
create mode 100644 lib/sandbox/unittest/CPytorchInferenceSandboxPolicyTest.cc
create mode 100644 lib/sandbox/unittest/payloads/ml_sandbox_probe.cc
diff --git a/include/sandbox/CPytorchInferenceSandboxPolicy.h b/include/sandbox/CPytorchInferenceSandboxPolicy.h
new file mode 100644
index 0000000000..e0e08b19ed
--- /dev/null
+++ b/include/sandbox/CPytorchInferenceSandboxPolicy.h
@@ -0,0 +1,114 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License
+ * 2.0 and the following additional limitation. Functionality enabled by the
+ * files subject to the Elastic License 2.0 may only be used in production when
+ * invoked by an Elasticsearch process with a license key installed that permits
+ * use of machine learning features. You may not use this file except in
+ * compliance with the Elastic License 2.0 and the foregoing additional
+ * limitation.
+ */
+#ifndef INCLUDED_ml_sandbox_CPytorchInferenceSandboxPolicy_h
+#define INCLUDED_ml_sandbox_CPytorchInferenceSandboxPolicy_h
+
+#include
+#include
+
+#ifdef SANDBOX2_AVAILABLE
+#include "absl/status/statusor.h"
+#include
+#endif
+
+namespace ml {
+namespace sandbox {
+
+//! Reasons a path-bearing launch argument fails typed validation against the
+//! pinned child-root contract (see validateChildIpcLaunchSpec below). Every
+//! value here must fail *before* a policy is constructed; none of them widen
+//! a mount to recover.
+enum class EChildIpcPathRejection {
+ E_NotAbsolute, //!< value does not start with '/'.
+ E_RootLevelPath, //!< value has no mountable parent directory below '/'.
+ E_ContainsDotDot, //!< value has a ".." path component.
+ E_CanonicalizationFailed, //!< the trusted base or the value's parent directory could not be
+ //!< resolved (realpath() on POSIX, _fullpath() on Windows).
+ E_OutsideTrustedBase, //!< canonical parent is not beneath the trusted $TMPDIR.
+ E_WrongDepth, //!< canonical parent is not exactly $TMPDIR/ml-child-ipc/.
+ E_ChildIdMismatch, //!< two path options resolved to a different .
+ E_MutableSymlinkOrAlias, //!< the literal and canonical parent directories diverge.
+ E_Duplicate //!< the same literal argument was supplied more than once.
+};
+
+//! One rejected path-bearing argument and why.
+struct SRejectedChildIpcPath {
+ std::string s_Arg;
+ EChildIpcPathRejection s_Reason;
+};
+
+//! A typed, validated launch specification for a single sandboxed
+//! pytorch_inference child, derived from its path-bearing launch options
+//! (input, output, restore, logPipe). Replaces raw argument-directory
+//! inference: every accepted path is provably beneath the one pinned
+//! per-child IPC root, never inferred from arbitrary argv content.
+struct SChildIpcLaunchSpec {
+ //! path component shared by every accepted path option.
+ //! Empty whenever the overall result is not s_Ok - either no
+ //! recognized path option was present, or at least one was rejected
+ //! (SChildIpcValidationResult clears the whole spec on any rejection).
+ std::string s_ChildId;
+ //! Canonical $TMPDIR/ml-child-ipc/ - the directory the native
+ //! controller creates (mode 0700) before policy construction, and the
+ //! only host directory CSandboxedProcessSpawner maps to
+ //! /run/elastic/ml-ipc. Empty iff s_ChildId is empty.
+ std::string s_ChildIpcRoot;
+ //! Canonical paths of every accepted path-bearing argument, always
+ //! s_ChildIpcRoot plus exactly one leaf component.
+ std::vector s_PipePaths;
+};
+
+//! Result of validating a pytorch_inference launch command line against the
+//! pinned child-root contract.
+struct SChildIpcValidationResult {
+ //! True only when at least one path option was present and every
+ //! path option that was present was accepted. False means the caller
+ //! must fail the spawn - never fall back to a partially-built policy.
+ bool s_Ok = false;
+ SChildIpcLaunchSpec s_Spec;
+ std::vector s_Rejected;
+};
+
+//! Validate every input/output/restore/logPipe argument in args against the
+//! pinned child-root contract: each must canonicalize to a parent directory
+//! of exactly trustedTmpDir/ml-child-ipc/, for one consistent
+//! , with no ".."; no relative, root, or out-of-root path; no
+//! divergent literal/canonical parent; and no duplicate literal argument.
+//! Scalar (non path-bearing) options are never inspected as candidate paths.
+//! trustedTmpDir must already be the canonical form of the operator's
+//! Environment.tmpDir(); this function does not itself decide what counts
+//! as trusted.
+SChildIpcValidationResult validateChildIpcLaunchSpec(const std::string& trustedTmpDir,
+ const std::vector& args);
+
+#ifdef SANDBOX2_AVAILABLE
+
+//! Builds the filesystem and network-shape portion of the pytorch_inference
+//! Sandbox2 policy: minimized fixed mounts, a private bounded tmpfs at /tmp,
+//! the one per-child IPC root mapped to /run/elastic/ml-ipc, and the syscall
+//! allowlist shared with the legacy BPF filter
+//! (seccomp::legacyBpfAllowedSyscalls, kept in sync per that header's own
+//! comment). Does not call TryBuild() - the caller owns final policy
+//! construction so tests can inspect the builder before commit. Returns an
+//! error when validated.s_Ok is false or s_ChildIpcRoot is not a canonical
+//! $TMPDIR/ml-child-ipc/ directory.
+absl::StatusOr
+buildPytorchInferenceFilesystemPolicy(const std::string& binDir,
+ const std::string& libDir,
+ const SChildIpcValidationResult& validated,
+ std::size_t tmpfsSizeBytes);
+
+#endif // SANDBOX2_AVAILABLE
+
+} // namespace sandbox
+} // namespace ml
+
+#endif // INCLUDED_ml_sandbox_CPytorchInferenceSandboxPolicy_h
diff --git a/lib/sandbox/CMakeLists.txt b/lib/sandbox/CMakeLists.txt
index 508a46029d..89170eb66a 100644
--- a/lib/sandbox/CMakeLists.txt
+++ b/lib/sandbox/CMakeLists.txt
@@ -9,19 +9,22 @@
# limitation.
#
-# MlSandbox is a dormant dependency foundation: it links Sandbox2/Abseil and
-# builds/tests a runnable Sandbox2 forkserver on Linux, but no controller or
-# pytorch_inference routing depends on it yet. The typed launch policy,
-# process spawner, and controller wiring land in follow-up PRs.
+# MlSandbox links Sandbox2/Abseil and builds a runnable Sandbox2 forkserver
+# on Linux, and now a typed filesystem/network launch policy for a
+# pytorch_inference child. No controller or pytorch_inference routing
+# depends on it yet - the process spawner and controller wiring land in
+# follow-up PRs.
project("ML Sandbox")
set(ML_LINK_LIBRARIES
MlCore
+ MlSeccomp
)
set(SRCS
CMlSandboxAvailability.cc
+ CPytorchInferenceSandboxPolicy.cc
)
ml_add_library(MlSandbox STATIC ${SRCS})
diff --git a/lib/sandbox/CPytorchInferenceSandboxPolicy.cc b/lib/sandbox/CPytorchInferenceSandboxPolicy.cc
new file mode 100644
index 0000000000..9b50b314c8
--- /dev/null
+++ b/lib/sandbox/CPytorchInferenceSandboxPolicy.cc
@@ -0,0 +1,427 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License
+ * 2.0 and the following additional limitation. Functionality enabled by the
+ * files subject to the Elastic License 2.0 may only be used in production when
+ * invoked by an Elasticsearch process with a license key installed that permits
+ * use of machine learning features. You may not use this file except in
+ * compliance with the Elastic License 2.0 and the foregoing additional
+ * limitation.
+ */
+#include
+
+#ifdef _WIN32
+#include // _fullpath, _MAX_PATH
+#else
+#include // PATH_MAX
+#include
+#endif
+
+#include
+#include
+#include
+
+#ifdef SANDBOX2_AVAILABLE
+#include "absl/status/status.h"
+#include
+#endif
+
+#ifdef __linux__
+#include
+#include
+#endif
+
+namespace ml {
+namespace sandbox {
+
+namespace {
+
+//! The only recognized path-bearing launch options. Adding or renaming one
+//! requires a change here, a policy test, and an end-to-end Elasticsearch
+//! invocation test.
+bool isPathOptionName(const std::string& name) {
+ return name == "input" || name == "output" || name == "restore" || name == "logPipe";
+}
+
+//! Split a path into components, without resolving "." or "..".
+std::vector splitPathComponents(const std::string& path) {
+ std::vector components;
+ std::string current;
+ for (char c : path) {
+ if (c == '/') {
+ if (current.empty() == false) {
+ components.push_back(current);
+ current.clear();
+ }
+ } else {
+ current.push_back(c);
+ }
+ }
+ if (current.empty() == false) {
+ components.push_back(current);
+ }
+ return components;
+}
+
+bool containsDotDot(const std::vector& components) {
+ return std::find(components.begin(), components.end(), "..") != components.end();
+}
+
+//! realpath() requires the target to exist. The leaf FIFO/file may not
+//! exist yet at validation time, but the native controller creates the
+//! per-child ml-child-ipc/ directory before policy construction,
+//! so canonicalizing the *parent* directory of the leaf is always
+//! meaningful.
+bool canonicalize(const std::string& dir, std::string& canonicalOut) {
+#ifdef _WIN32
+ // Sandbox2 (and therefore every caller of this validator) is Linux-only
+ // - nothing wires this function up on Windows today - but ml-cpp builds
+ // this file unconditionally on every platform (see
+ // lib/sandbox/CMakeLists.txt), so it still has to compile and behave
+ // sanely there. _fullpath() differs from realpath() in not requiring
+ // the target to exist; that is inert until a Windows caller exists.
+ char resolved[_MAX_PATH];
+ if (::_fullpath(resolved, dir.c_str(), _MAX_PATH) == nullptr) {
+ return false;
+ }
+#else
+ char resolved[PATH_MAX];
+ if (::realpath(dir.c_str(), resolved) == nullptr) {
+ return false;
+ }
+#endif
+ canonicalOut.assign(resolved);
+ return true;
+}
+
+#ifdef SANDBOX2_AVAILABLE
+
+//! What buildPytorchInferenceFilesystemPolicy does with one of the seven
+//! historically bulk-mounted fixed directories
+//! (/lib /lib64 /usr/lib /usr/lib64 /etc /proc /sys). Mounting whole /etc or
+//! binding the host's /proc or /sys directly is non-conformant.
+enum class EFixedMountAction {
+ E_MountReadOnlyDirectory, //!< the whole directory is demonstrated necessary read-only.
+ E_MountNamespacedProcfs, //!< Sandbox2 supplies this inside the sandbox's own PID/mount namespace; never bind the host directory.
+ E_Skip //!< not mapped at all; narrower entries (files) are added separately.
+};
+
+//! One fixed-mount decision plus the reason it is scoped that way.
+struct SFixedMountDecision {
+ std::string s_Path;
+ EFixedMountAction s_Action;
+ std::string s_Reason;
+};
+
+const std::vector& fixedMountDecisions() {
+ static const std::vector DECISIONS{
+ {"/lib", EFixedMountAction::E_MountReadOnlyDirectory,
+ "Dynamic loader resolves libc/libgcc/libstdc++ from here at "
+ "runtime; the set is unbounded and platform-dependent, so "
+ "per-file allowlisting would duplicate the loader's own search "
+ "logic."},
+ {"/lib64", EFixedMountAction::E_MountReadOnlyDirectory,
+ "Same reason as /lib, on the lib64 multilib path used by the "
+ "64-bit dynamic loader on our supported Linux distributions."},
+ {"/usr/lib", EFixedMountAction::E_MountReadOnlyDirectory,
+ "Same reason as /lib: libtorch and its transitive shared-library "
+ "dependencies resolve from here."},
+ {"/usr/lib64", EFixedMountAction::E_MountReadOnlyDirectory,
+ "Same reason as /lib64, for 64-bit multilib packages."},
+ {"/etc", EFixedMountAction::E_Skip,
+ "Whole /etc is never mounted; allowlistedEtcFiles() lists the "
+ "individually justified files pytorch_inference/libtorch actually "
+ "need instead."},
+ {"/proc", EFixedMountAction::E_MountNamespacedProcfs,
+ "Sandbox2 mounts a fresh procfs inside the sandbox's own PID "
+ "namespace; binding the host's /proc would leak every other "
+ "process's memory maps and command lines into the sandbox."},
+ {"/sys", EFixedMountAction::E_MountNamespacedProcfs,
+ "Same reason as /proc: nothing in this policy binds host /sys."},
+ };
+ return DECISIONS;
+}
+
+const std::vector& allowlistedEtcFiles() {
+ // NOTE: /etc/ssl/certs/ca-certificates.crt is the Debian/Ubuntu trust
+ // bundle path; the ml-cpp CI build image is CentOS7/RHEL-based, whose
+ // equivalent is /etc/pki/tls/certs/ca-bundle.crt. This list has not yet
+ // been verified against the actual supported-distro trust bundle path -
+ // tracked in elastic/ml-cpp#3200.
+ static const std::vector FILES{
+ "/etc/nsswitch.conf", "/etc/resolv.conf", "/etc/hosts",
+ "/etc/localtime", "/etc/ld.so.cache",
+ };
+ return FILES;
+}
+
+bool childIpcRootHasExpectedShape(const std::string& childIpcRoot) {
+ if (childIpcRoot.empty()) {
+ return false;
+ }
+ const std::vector components{splitPathComponents(childIpcRoot)};
+ if (components.size() < 2) {
+ return false;
+ }
+ return components[components.size() - 2] == "ml-child-ipc";
+}
+
+#endif // SANDBOX2_AVAILABLE
+
+} // namespace
+
+SChildIpcValidationResult validateChildIpcLaunchSpec(const std::string& trustedTmpDir,
+ const std::vector& args) {
+ SChildIpcValidationResult result;
+
+ std::string trustedTmpDirCanonical;
+ const bool trustedBaseResolved = canonicalize(trustedTmpDir, trustedTmpDirCanonical);
+
+ std::vector seenLiteralArgs;
+
+ for (const std::string& arg : args) {
+ const std::size_t eqPos = arg.find('=');
+ if (eqPos == std::string::npos) {
+ // NOTE (reviewed, not fixed): CCmdLineParser.cc's
+ // boost::program_options parser also accepts spellings other
+ // than the exact concatenated "--=" form this loop
+ // requires - a space-separated "--input /path", or (via boost's
+ // default allow_guessing style) an unambiguous abbreviation
+ // like "--inp=/path". None of those are a mount-widening bypass:
+ // an unrecognized option is never added to s_PipePaths, so its
+ // directory is simply never mounted and the spawn either fails
+ // closed (pipe unreachable) or gets rejected elsewhere. The sole
+ // production caller, ProcessPipes.addArgs() in
+ // elasticsearch/x-pack/plugin/ml, always emits the exact
+ // concatenated "--input=" + value form, so this is a defensive
+ // fail-closed gap rather than an active exploit path. Left
+ // unfixed rather than special-cased.
+ continue;
+ }
+
+ std::string optionName{arg.substr(0, eqPos)};
+ while (optionName.empty() == false && optionName[0] == '-') {
+ optionName.erase(0, 1);
+ }
+
+ if (isPathOptionName(optionName) == false) {
+ continue;
+ }
+
+ // eqPos + 1 == arg.size() means an empty value ("--input="). That
+ // must still be classified as a recognized-but-malformed path
+ // option and rejected below (E_NotAbsolute), not silently skipped
+ // as if the option were absent - skipping it here would let a spec
+ // with a missing input path validate as s_Ok if the other three
+ // options happened to be valid.
+ const std::string value{eqPos + 1 < arg.size() ? arg.substr(eqPos + 1)
+ : std::string{}};
+
+ if (std::find(seenLiteralArgs.begin(), seenLiteralArgs.end(), arg) !=
+ seenLiteralArgs.end()) {
+ result.s_Rejected.push_back({arg, EChildIpcPathRejection::E_Duplicate});
+ continue;
+ }
+ seenLiteralArgs.push_back(arg);
+
+ if (value.empty() || value[0] != '/') {
+ result.s_Rejected.push_back({arg, EChildIpcPathRejection::E_NotAbsolute});
+ continue;
+ }
+
+ const std::vector components{splitPathComponents(value)};
+ if (containsDotDot(components)) {
+ result.s_Rejected.push_back({arg, EChildIpcPathRejection::E_ContainsDotDot});
+ continue;
+ }
+ if (components.size() < 2) {
+ // Fewer than two components below '/' means either the root
+ // itself or a direct child of root - never a valid three-deep
+ // $TMPDIR/ml-child-ipc// path.
+ result.s_Rejected.push_back({arg, EChildIpcPathRejection::E_RootLevelPath});
+ continue;
+ }
+
+ const std::string leaf{components.back()};
+ const std::size_t lastSlash = value.rfind('/');
+ const std::string literalParent{value.substr(0, lastSlash)};
+
+ if (trustedBaseResolved == false) {
+ result.s_Rejected.push_back({arg, EChildIpcPathRejection::E_CanonicalizationFailed});
+ continue;
+ }
+
+ std::string canonicalParent;
+ if (canonicalize(literalParent, canonicalParent) == false) {
+ result.s_Rejected.push_back({arg, EChildIpcPathRejection::E_CanonicalizationFailed});
+ continue;
+ }
+
+ if (literalParent != canonicalParent) {
+ // The literal path traverses a symlink (or other alias) before
+ // reaching its parent directory. Accepting both forms - as the
+ // pre-PR-C raw inference did - would let a mutable link widen
+ // the mount after validation ran. Reject instead of mounting
+ // either form.
+ result.s_Rejected.push_back({arg, EChildIpcPathRejection::E_MutableSymlinkOrAlias});
+ continue;
+ }
+
+ const std::vector canonicalComponents{splitPathComponents(canonicalParent)};
+ const std::vector baseComponents{splitPathComponents(trustedTmpDirCanonical)};
+
+ const bool underBase = canonicalComponents.size() == baseComponents.size() + 2 &&
+ std::equal(baseComponents.begin(), baseComponents.end(),
+ canonicalComponents.begin());
+ if (underBase == false) {
+ const bool sharesBasePrefix =
+ canonicalComponents.size() >= baseComponents.size() &&
+ std::equal(baseComponents.begin(), baseComponents.end(),
+ canonicalComponents.begin());
+ result.s_Rejected.push_back(
+ {arg, sharesBasePrefix ? EChildIpcPathRejection::E_WrongDepth
+ : EChildIpcPathRejection::E_OutsideTrustedBase});
+ continue;
+ }
+
+ const std::string intermediateDir{canonicalComponents[baseComponents.size()]};
+ if (intermediateDir != "ml-child-ipc") {
+ result.s_Rejected.push_back({arg, EChildIpcPathRejection::E_WrongDepth});
+ continue;
+ }
+
+ const std::string childId{canonicalComponents.back()};
+ if (result.s_Spec.s_ChildId.empty() == false && result.s_Spec.s_ChildId != childId) {
+ result.s_Rejected.push_back({arg, EChildIpcPathRejection::E_ChildIdMismatch});
+ continue;
+ }
+
+ result.s_Spec.s_ChildId = childId;
+ result.s_Spec.s_ChildIpcRoot = canonicalParent;
+ result.s_Spec.s_PipePaths.push_back(canonicalParent + "/" + leaf);
+ }
+
+ result.s_Ok = result.s_Rejected.empty() && result.s_Spec.s_ChildId.empty() == false;
+ if (result.s_Ok == false) {
+ // A rejected argument or an entirely absent path option both fail
+ // the spawn; never return a partially-populated spec the caller
+ // might build a policy from by mistake.
+ result.s_Spec = SChildIpcLaunchSpec{};
+ }
+ return result;
+}
+
+#ifdef SANDBOX2_AVAILABLE
+
+absl::StatusOr
+buildPytorchInferenceFilesystemPolicy(const std::string& binDir,
+ const std::string& libDir,
+ const SChildIpcValidationResult& validated,
+ std::size_t tmpfsSizeBytes) {
+ if (validated.s_Ok == false ||
+ childIpcRootHasExpectedShape(validated.s_Spec.s_ChildIpcRoot) == false) {
+ return absl::InvalidArgumentError(
+ "buildPytorchInferenceFilesystemPolicy requires validated.s_Ok and a "
+ "canonical $TMPDIR/ml-child-ipc/ s_ChildIpcRoot");
+ }
+
+ sandbox2::PolicyBuilder policyBuilder;
+
+ policyBuilder.AllowDynamicStartup()
+ .AllowExit()
+ .AllowHandleSignals()
+ .AllowGetPIDs()
+ .AllowGetRandom()
+ .AllowTcMalloc()
+ .AllowMmap();
+
+#ifdef __linux__
+ // glibc/libtorch use futex for mutexes and condition variables; timed
+ // waits and broadcast/requeue paths need more than plain WAIT/WAKE (see
+ // the carry-forward note on d9a856d5f in
+ // include/seccomp/CMlLegacyBpfSyscallAllowlist.h).
+ policyBuilder.AllowFutexOp(FUTEX_WAIT)
+ .AllowFutexOp(FUTEX_WAKE)
+ .AllowFutexOp(FUTEX_WAIT_BITSET)
+ .AllowFutexOp(FUTEX_WAKE_BITSET)
+ .AllowFutexOp(FUTEX_REQUEUE)
+ .AllowFutexOp(FUTEX_CMP_REQUEUE)
+ .AllowFutexOp(FUTEX_WAKE_OP);
+#endif
+
+ // Consume the one machine-readable syscall declaration shared with the
+ // legacy in-process BPF filter instead of hand-maintaining a second list,
+ // so a future change to the allowlist keeps both mechanisms in sync
+ // automatically. Skip __NR_futex here: AllowFutexOp above is the Sandbox2
+ // grant (listed ops only). AllowSyscall(__NR_futex) would append
+ // SYSCALL(futex, ALLOW) because AllowFutexOp uses AddPolicyOnSyscall and
+ // does not insert into handled_syscalls_.
+ for (int syscallNr : seccomp::legacyBpfAllowedSyscalls()) {
+#ifdef __linux__
+ if (syscallNr == __NR_futex) {
+ continue;
+ }
+#endif
+ policyBuilder.AllowSyscall(syscallNr);
+ }
+
+ policyBuilder.AddDirectory(binDir, /*is_ro=*/true);
+ policyBuilder.AddDirectory(libDir, /*is_ro=*/true);
+
+ for (const SFixedMountDecision& decision : fixedMountDecisions()) {
+ switch (decision.s_Action) {
+ case EFixedMountAction::E_MountReadOnlyDirectory: {
+ // Sandbox2's Mounts API has no "mount if present" option - it
+ // fails the whole spawn (not just this entry) if the source
+ // path doesn't exist. /lib64 and /usr/lib64 are RHEL/Rocky
+ // multilib paths that some supported distros' layouts don't
+ // have under every name; skip a decision whose source is
+ // simply absent on this host rather than crash the spawn over
+ // a directory nothing needed.
+ struct stat dirStat {};
+ if (::stat(decision.s_Path.c_str(), &dirStat) == 0 &&
+ S_ISDIR(dirStat.st_mode)) {
+ policyBuilder.AddDirectory(decision.s_Path, /*is_ro=*/true);
+ }
+ break;
+ }
+ case EFixedMountAction::E_MountNamespacedProcfs:
+ case EFixedMountAction::E_Skip:
+ // Sandbox2 supplies its own namespaced procfs/sysfs
+ // automatically; nothing to add here for either case, and
+ // adding decision.s_Path would bind the host directory instead.
+ break;
+ }
+ }
+
+ for (const std::string& etcFile : allowlistedEtcFiles()) {
+ // Same reasoning as the fixed-directory guard above: a minimal or
+ // distroless-style host can be missing any one of these (e.g.
+ // /etc/resolv.conf under --network none), and Sandbox2's Mounts
+ // API fails the whole spawn, not just this entry, on an absent
+ // source.
+ struct stat fileStat {};
+ if (::stat(etcFile.c_str(), &fileStat) == 0 && S_ISREG(fileStat.st_mode)) {
+ policyBuilder.AddFile(etcFile, /*is_ro=*/true);
+ }
+ }
+
+ for (const char* devFile : {"/dev/null", "/dev/urandom", "/dev/random"}) {
+ policyBuilder.AddFile(devFile, /*is_ro=*/std::strcmp(devFile, "/dev/null") != 0);
+ }
+
+ // Private, bounded tmpfs - never the host's shared /tmp.
+ policyBuilder.AddTmpfs("/tmp", tmpfsSizeBytes);
+
+ // The one per-child IPC root, mapped read-write to a fixed in-sandbox
+ // path. validated.s_Ok and s_ChildIpcRoot shape were checked above.
+ policyBuilder.AddDirectoryAt(validated.s_Spec.s_ChildIpcRoot, "/run/elastic/ml-ipc",
+ /*is_ro=*/false);
+
+ return policyBuilder;
+}
+
+#endif // SANDBOX2_AVAILABLE
+
+} // namespace sandbox
+} // namespace ml
diff --git a/lib/sandbox/unittest/CMakeLists.txt b/lib/sandbox/unittest/CMakeLists.txt
index ba783acc4f..c0ad8e0a02 100644
--- a/lib/sandbox/unittest/CMakeLists.txt
+++ b/lib/sandbox/unittest/CMakeLists.txt
@@ -20,9 +20,20 @@ set(ML_LINK_LIBRARIES
${Boost_LIBRARIES_WITH_UNIT_TEST}
MlCore
MlSandbox
+ MlSeccomp
MlTest
)
+if(NOT WIN32)
+ # validateChildIpcLaunchSpec's production implementation is portable
+ # POSIX (Linux and macOS both verified), not Sandbox2/Linux-specific -
+ # unlike the smoke/mechanism tests below, it deliberately runs
+ # everywhere it can, which excludes only Windows (no realpath/mkdtemp/
+ # symlink equivalents wired up; see canonicalize()'s _WIN32 branch in
+ # the .cc for why production code still has to compile there).
+ list(APPEND SRCS CPytorchInferenceSandboxPolicyTest.cc)
+endif()
+
if(TARGET sandbox2::sandbox2 AND CMAKE_SYSTEM_NAME STREQUAL "Linux")
# The forkserver runtime smoke test links the Sandbox2 API directly (not
# just MlSandbox, which exposes no sandbox2 symbols yet) to prove the
@@ -31,6 +42,7 @@ if(TARGET sandbox2::sandbox2 AND CMAKE_SYSTEM_NAME STREQUAL "Linux")
# elsewhere rather than compiled out with #ifdef, since sandbox2 headers
# are unavailable on non-Linux configure runs.
list(APPEND SRCS CSandboxForkserverSmokeTest.cc)
+ list(APPEND SRCS CPytorchInferenceSandboxPolicyMechanismTest_Linux.cc)
list(APPEND ML_LINK_LIBRARIES sandbox2::sandbox2)
# Deliberately-dependency-free sandboxee payload for the smoke test above.
@@ -50,6 +62,17 @@ if(TARGET sandbox2::sandbox2 AND CMAKE_SYSTEM_NAME STREQUAL "Linux")
POSITION_INDEPENDENT_CODE TRUE
RUNTIME_OUTPUT_DIRECTORY ${CMAKE_CURRENT_BINARY_DIR}/payloads
)
+
+ # Purpose-built allowlisted mechanism-probe payload for the filesystem/
+ # network policy test below. Same dependency-free, dynamically-linked
+ # pattern as sandbox2_smoke_payload above, for the same CI-image reason.
+ add_executable(ml_sandbox_probe EXCLUDE_FROM_ALL
+ payloads/ml_sandbox_probe.cc
+ )
+ set_target_properties(ml_sandbox_probe PROPERTIES
+ POSITION_INDEPENDENT_CODE TRUE
+ RUNTIME_OUTPUT_DIRECTORY ${CMAKE_CURRENT_BINARY_DIR}/payloads
+ )
endif()
ml_add_test_executable(sandbox ${SRCS})
@@ -60,3 +83,10 @@ if(TARGET sandbox2_smoke_payload)
"ML_SANDBOX2_SMOKE_PAYLOAD=\"$\""
)
endif()
+
+if(TARGET ml_sandbox_probe)
+ add_dependencies(ml_test_sandbox ml_sandbox_probe)
+ target_compile_definitions(ml_test_sandbox PRIVATE
+ ML_SANDBOX2_PROBE_PAYLOAD="$"
+ )
+endif()
diff --git a/lib/sandbox/unittest/CPytorchInferenceSandboxPolicyMechanismTest_Linux.cc b/lib/sandbox/unittest/CPytorchInferenceSandboxPolicyMechanismTest_Linux.cc
new file mode 100644
index 0000000000..6190e0f12e
--- /dev/null
+++ b/lib/sandbox/unittest/CPytorchInferenceSandboxPolicyMechanismTest_Linux.cc
@@ -0,0 +1,212 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License
+ * 2.0 and the following additional limitation. Functionality enabled by the
+ * files subject to the Elastic License 2.0 may only be used in production when
+ * invoked by an Elasticsearch process with a license key installed that permits
+ * use of machine learning features. You may not use this file except in
+ * compliance with the Elastic License 2.0 and the foregoing additional
+ * limitation.
+ */
+
+// Linux-only mechanism-probe integration test for the typed
+// filesystem/network launch policy. Builds a real policy via
+// buildPytorchInferenceFilesystemPolicy, runs ml_sandbox_probe inside it,
+// and asserts on the probe's per-mechanism "outcome=" lines rather than
+// trusting a bare exit code - a policy that merely lets the probe start
+// would otherwise look identical to a correctly minimized one.
+//
+// This test has been reviewed against the Sandbox2 PolicyBuilder API as
+// used by CSandboxForkserverSmokeTest_Linux, but still needs a real
+// Linux/Sandbox2 build-and-run pass to confirm it actually passes.
+
+#include
+
+#include
+
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+
+#include "absl/time/time.h"
+#include "sandboxed_api/sandbox2/executor.h"
+#include "sandboxed_api/sandbox2/result.h"
+#include "sandboxed_api/sandbox2/sandbox2.h"
+
+#ifndef ML_SANDBOX2_PROBE_PAYLOAD
+#error "ML_SANDBOX2_PROBE_PAYLOAD must be defined by lib/sandbox/unittest/CMakeLists.txt"
+#endif
+
+namespace {
+
+//! Returns the outcome recorded for mechanism, or empty if the mechanism
+//! line never appeared - a missing line is itself a failure (the probe
+//! didn't reach that check, e.g. because it was killed earlier).
+std::string outcomeFor(const std::string& resultsFileContent, const std::string& mechanism) {
+ std::istringstream lines{resultsFileContent};
+ std::string line;
+ const std::string marker{"mechanism=" + mechanism + " outcome="};
+ while (std::getline(lines, line)) {
+ const std::size_t pos = line.find(marker);
+ if (pos == std::string::npos) {
+ continue;
+ }
+ const std::size_t start = pos + marker.size();
+ const std::size_t end = line.find(' ', start);
+ return line.substr(start, end == std::string::npos ? std::string::npos : end - start);
+ }
+ return {};
+}
+
+//! Returns the detail= field recorded for mechanism, or empty if the
+//! mechanism line never appeared.
+std::string detailFor(const std::string& resultsFileContent, const std::string& mechanism) {
+ std::istringstream lines{resultsFileContent};
+ std::string line;
+ const std::string outcomeMarker{"mechanism=" + mechanism + " outcome="};
+ const std::string detailMarker{" detail="};
+ while (std::getline(lines, line)) {
+ if (line.find(outcomeMarker) == std::string::npos) {
+ continue;
+ }
+ const std::size_t pos = line.find(detailMarker);
+ return pos == std::string::npos ? std::string{}
+ : line.substr(pos + detailMarker.size());
+ }
+ return {};
+}
+
+std::string readFileOrEmpty(const std::string& path) {
+ std::ifstream file{path};
+ if (file.is_open() == false) {
+ return {};
+ }
+ std::ostringstream contents;
+ contents << file.rdbuf();
+ return contents.str();
+}
+
+//! Removes probe artifacts and the per-child IPC tree created by the
+//! mechanism test, even when a BOOST_REQUIRE aborts the case mid-run.
+class CMechanismProbeFixture {
+public:
+ CMechanismProbeFixture() {
+ char pathTemplate[] = "/tmp/ml_sandbox_probe_test_XXXXXX";
+ char* created = ::mkdtemp(pathTemplate);
+ BOOST_TEST_REQUIRE(created != nullptr);
+ m_LiteralTmpDir.assign(created);
+
+ char resolved[PATH_MAX];
+ BOOST_TEST_REQUIRE(::realpath(m_LiteralTmpDir.c_str(), resolved) != nullptr);
+ m_TrustedTmpDir.assign(resolved);
+
+ BOOST_TEST_REQUIRE(::mkdir((m_TrustedTmpDir + "/ml-child-ipc").c_str(), 0700) == 0);
+ m_ChildRoot = m_TrustedTmpDir + "/ml-child-ipc/mechanism-probe-child";
+ BOOST_TEST_REQUIRE(::mkdir(m_ChildRoot.c_str(), 0700) == 0);
+ }
+
+ ~CMechanismProbeFixture() {
+ ::unlink((m_ChildRoot + "/probe.txt").c_str());
+ ::unlink((m_ChildRoot + "/results.txt").c_str());
+ ::rmdir(m_ChildRoot.c_str());
+ ::rmdir((m_TrustedTmpDir + "/ml-child-ipc").c_str());
+ if (m_LiteralTmpDir != m_TrustedTmpDir) {
+ ::rmdir(m_LiteralTmpDir.c_str());
+ }
+ ::rmdir(m_TrustedTmpDir.c_str());
+ }
+
+ CMechanismProbeFixture(const CMechanismProbeFixture&) = delete;
+ CMechanismProbeFixture& operator=(const CMechanismProbeFixture&) = delete;
+
+ const std::string& trustedTmpDir() const { return m_TrustedTmpDir; }
+ const std::string& childRoot() const { return m_ChildRoot; }
+
+private:
+ std::string m_LiteralTmpDir;
+ std::string m_TrustedTmpDir;
+ std::string m_ChildRoot;
+};
+
+} // namespace
+
+BOOST_AUTO_TEST_SUITE(CPytorchInferenceSandboxPolicyMechanismTest_Linux)
+
+BOOST_AUTO_TEST_CASE(testMinimizedPolicyEnforcesEveryMechanism) {
+ CMechanismProbeFixture fixture;
+
+ const std::vector args{"--input=" + fixture.childRoot() + "/input.fifo",
+ "--output=" + fixture.childRoot() + "/output.fifo",
+ "--logPipe=" + fixture.childRoot() + "/log.fifo"};
+ const ml::sandbox::SChildIpcValidationResult validated{
+ ml::sandbox::validateChildIpcLaunchSpec(fixture.trustedTmpDir(), args)};
+ BOOST_TEST_REQUIRE(validated.s_Ok);
+
+ const std::string payloadPath{ML_SANDBOX2_PROBE_PAYLOAD};
+ const std::vector probeArgs{payloadPath, "/run/elastic/ml-ipc"};
+
+ auto executor = std::make_unique(payloadPath, probeArgs);
+ executor->limits()->set_rlimit_cpu(10).set_walltime_limit(absl::Seconds(10));
+
+ auto built = ml::sandbox::buildPytorchInferenceFilesystemPolicy(
+ "/usr/bin", "/usr/lib", validated, /*tmpfsSizeBytes=*/16 * 1024 * 1024);
+ BOOST_TEST_REQUIRE(built.ok());
+ sandbox2::PolicyBuilder policyBuilder{std::move(*built)};
+ policyBuilder.AddLibrariesForBinary(payloadPath);
+ // legacyBpfAllowedSyscalls() grants __NR_connect but not __NR_socket -
+ // real libtorch/pytorch_inference apparently also needs a bare socket()
+ // for its own internal socket setup, so this is likely a real gap in
+ // that shared declaration, not something specific to this probe. Fixing
+ // the shared declaration belongs with whatever change owns that file;
+ // granting it here, scoped to this test's own policy only, is enough to
+ // prove ml_sandbox_probe's network mechanisms without widening the
+ // production policy this test doesn't own.
+ policyBuilder.AllowSyscall(__NR_socket);
+ auto policy = policyBuilder.BuildOrDie();
+
+ sandbox2::Sandbox2 s2(std::move(executor), std::move(policy));
+ sandbox2::Result result = s2.Run();
+
+ BOOST_TEST_REQUIRE(result.final_status() == sandbox2::Result::OK);
+
+ // The child IPC directory is genuinely shared with the host, so the
+ // probe's results file - written from inside the sandbox to the mapped
+ // /run/elastic/ml-ipc path - is readable here at its host-visible
+ // childRoot path once the sandbox has exited. This IS the "allowed IPC
+ // access" proof, not a separate assertion: if the mount/policy were
+ // wrong, this file would never appear.
+ const std::string resultsContent{readFileOrEmpty(fixture.childRoot() + "/results.txt")};
+ BOOST_TEST_REQUIRE(resultsContent.empty() == false);
+ BOOST_TEST_REQUIRE(resultsContent.find("reached=true") != std::string::npos);
+
+ BOOST_REQUIRE_EQUAL(outcomeFor(resultsContent, "ipc_readwrite"), "allowed");
+ BOOST_REQUIRE_EQUAL(outcomeFor(resultsContent, "host_read_etc_shadow"), "denied");
+ BOOST_REQUIRE_EQUAL(outcomeFor(resultsContent, "private_tmpfs_write"), "allowed");
+ BOOST_REQUIRE_EQUAL(outcomeFor(resultsContent, "external_egress"), "denied");
+
+ // Mount conformance: /etc must list only allowlistedEtcFiles() (5 entries)
+ // plus "." and "..", never a full directory bind. A regression back to
+ // AddDirectory("/etc", true) would spike this into the dozens/hundreds,
+ // so an upper bound catches it without hard-coding the exact count.
+ BOOST_REQUIRE_EQUAL(outcomeFor(resultsContent, "etc_enumeration"), "counted");
+ BOOST_TEST_REQUIRE(std::stoi(detailFor(resultsContent, "etc_enumeration")) <= 10);
+
+ BOOST_REQUIRE_EQUAL(outcomeFor(resultsContent, "pid_namespace"), "namespaced");
+ BOOST_REQUIRE_EQUAL(outcomeFor(resultsContent, "loopback_reachable"), "ok");
+}
+
+BOOST_AUTO_TEST_CASE(testBuildPolicyRejectsInvalidatedLaunchSpec) {
+ const ml::sandbox::SChildIpcValidationResult invalid{};
+ const auto built = ml::sandbox::buildPytorchInferenceFilesystemPolicy(
+ "/usr/bin", "/usr/lib", invalid, /*tmpfsSizeBytes=*/16 * 1024 * 1024);
+ BOOST_TEST_REQUIRE(built.ok() == false);
+}
+
+BOOST_AUTO_TEST_SUITE_END()
diff --git a/lib/sandbox/unittest/CPytorchInferenceSandboxPolicyTest.cc b/lib/sandbox/unittest/CPytorchInferenceSandboxPolicyTest.cc
new file mode 100644
index 0000000000..60c8e86aee
--- /dev/null
+++ b/lib/sandbox/unittest/CPytorchInferenceSandboxPolicyTest.cc
@@ -0,0 +1,266 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License
+ * 2.0 and the following additional limitation. Functionality enabled by the
+ * files subject to the Elastic License 2.0 may only be used in production when
+ * invoked by an Elasticsearch process with a license key installed that permits
+ * use of machine learning features. You may not use this file except in
+ * compliance with the Elastic License 2.0 and the foregoing additional
+ * limitation.
+ */
+
+// Exercises validateChildIpcLaunchSpec against the pinned child-root
+// contract. This suite needs only realpath()/mkdtemp()/mkdir()/symlink(), not Sandbox2
+// itself, so it runs on every POSIX ml-cpp CI platform (Linux and macOS),
+// not just Linux - but not Windows, which has none of those APIs; see
+// lib/sandbox/unittest/CMakeLists.txt's NOT WIN32 guard.
+
+#include
+
+#include
+
+#include
+#include
+#include
+#include
+#include
+#include
+
+namespace {
+
+//! Creates trustedTmpDir/ml-child-ipc/ (mode 0700), mirroring the
+//! native controller's pre-launch creation step, and returns the
+//! *canonical* trusted base so literal test paths built from it never
+//! diverge from realpath() output on hosts where /tmp is itself a symlink
+//! (e.g. macOS's /tmp -> /private/tmp) - that divergence is a real
+//! condition (E_MutableSymlinkOrAlias) this suite tests deliberately, so
+//! setup must not trigger it by accident.
+class CTempChildIpcFixture {
+public:
+ explicit CTempChildIpcFixture(const std::string& childId)
+ : m_ChildId(childId) {
+ char pathTemplate[] = "/tmp/ml_sandbox_policy_test_XXXXXX";
+ char* created = ::mkdtemp(pathTemplate);
+ BOOST_TEST_REQUIRE(created != nullptr);
+ m_LiteralBase.assign(created);
+
+ char resolved[PATH_MAX];
+ BOOST_TEST_REQUIRE(::realpath(m_LiteralBase.c_str(), resolved) != nullptr);
+ m_CanonicalBase.assign(resolved);
+
+ BOOST_TEST_REQUIRE(::mkdir((m_CanonicalBase + "/ml-child-ipc").c_str(), 0700) == 0);
+ m_ChildRoot = m_CanonicalBase + "/ml-child-ipc/" + m_ChildId;
+ BOOST_TEST_REQUIRE(::mkdir(m_ChildRoot.c_str(), 0700) == 0);
+ }
+
+ ~CTempChildIpcFixture() {
+ ::rmdir(m_ChildRoot.c_str());
+ ::rmdir((m_CanonicalBase + "/ml-child-ipc").c_str());
+ if (m_LiteralBase != m_CanonicalBase) {
+ ::rmdir(m_LiteralBase.c_str());
+ }
+ ::rmdir(m_CanonicalBase.c_str());
+ }
+
+ const std::string& canonicalTrustedBase() const { return m_CanonicalBase; }
+ const std::string& childRoot() const { return m_ChildRoot; }
+
+private:
+ std::string m_ChildId;
+ std::string m_LiteralBase;
+ std::string m_CanonicalBase;
+ std::string m_ChildRoot;
+};
+
+} // namespace
+
+BOOST_AUTO_TEST_SUITE(CPytorchInferenceSandboxPolicyTest)
+
+BOOST_AUTO_TEST_CASE(testAcceptsAllFourPathOptionsUnderPinnedChildRoot) {
+ CTempChildIpcFixture fixture{"child-1"};
+ const std::vector args{
+ "--input=" + fixture.childRoot() + "/input.fifo",
+ "--output=" + fixture.childRoot() + "/output.fifo",
+ "--restore=" + fixture.childRoot() + "/restore.fifo",
+ "--logPipe=" + fixture.childRoot() + "/log.fifo",
+ "--someScalarOption=not-a-path",
+ };
+
+ const ml::sandbox::SChildIpcValidationResult result{
+ ml::sandbox::validateChildIpcLaunchSpec(fixture.canonicalTrustedBase(), args)};
+
+ BOOST_TEST_REQUIRE(result.s_Ok);
+ BOOST_TEST_REQUIRE(result.s_Rejected.empty());
+ BOOST_REQUIRE_EQUAL(result.s_Spec.s_ChildId, "child-1");
+ BOOST_REQUIRE_EQUAL(result.s_Spec.s_ChildIpcRoot, fixture.childRoot());
+ BOOST_REQUIRE_EQUAL(result.s_Spec.s_PipePaths.size(), 4);
+}
+
+BOOST_AUTO_TEST_CASE(testNoPathOptionsIsNotOk) {
+ const ml::sandbox::SChildIpcValidationResult result{
+ ml::sandbox::validateChildIpcLaunchSpec("/tmp", {"--foo=bar"})};
+
+ BOOST_TEST_REQUIRE(result.s_Ok == false);
+ BOOST_TEST_REQUIRE(result.s_Spec.s_ChildId.empty());
+}
+
+BOOST_AUTO_TEST_CASE(testRejectsRelativePath) {
+ CTempChildIpcFixture fixture{"child-2"};
+ const ml::sandbox::SChildIpcValidationResult result{ml::sandbox::validateChildIpcLaunchSpec(
+ fixture.canonicalTrustedBase(), {"--input=relative/input.fifo"})};
+
+ BOOST_TEST_REQUIRE(result.s_Ok == false);
+ BOOST_REQUIRE_EQUAL(result.s_Rejected.size(), 1);
+ BOOST_REQUIRE(result.s_Rejected[0].s_Reason ==
+ ml::sandbox::EChildIpcPathRejection::E_NotAbsolute);
+}
+
+BOOST_AUTO_TEST_CASE(testRejectsRootLevelPath) {
+ CTempChildIpcFixture fixture{"child-3"};
+ const ml::sandbox::SChildIpcValidationResult result{ml::sandbox::validateChildIpcLaunchSpec(
+ fixture.canonicalTrustedBase(), {"--input=/input.fifo"})};
+
+ BOOST_TEST_REQUIRE(result.s_Ok == false);
+ BOOST_REQUIRE_EQUAL(result.s_Rejected.size(), 1);
+ BOOST_REQUIRE(result.s_Rejected[0].s_Reason ==
+ ml::sandbox::EChildIpcPathRejection::E_RootLevelPath);
+}
+
+BOOST_AUTO_TEST_CASE(testRejectsDotDotEscape) {
+ CTempChildIpcFixture fixture{"child-4"};
+ const std::string escapingPath{fixture.childRoot() + "/../../../etc/passwd"};
+ const ml::sandbox::SChildIpcValidationResult result{ml::sandbox::validateChildIpcLaunchSpec(
+ fixture.canonicalTrustedBase(), {"--input=" + escapingPath})};
+
+ BOOST_TEST_REQUIRE(result.s_Ok == false);
+ BOOST_REQUIRE_EQUAL(result.s_Rejected.size(), 1);
+ BOOST_REQUIRE(result.s_Rejected[0].s_Reason ==
+ ml::sandbox::EChildIpcPathRejection::E_ContainsDotDot);
+}
+
+BOOST_AUTO_TEST_CASE(testRejectsPathOutsideTrustedBase) {
+ CTempChildIpcFixture fixture{"child-5"};
+ const ml::sandbox::SChildIpcValidationResult result{ml::sandbox::validateChildIpcLaunchSpec(
+ fixture.canonicalTrustedBase(), {"--input=/var/tmp/not-under-tmpdir/input.fifo"})};
+
+ BOOST_TEST_REQUIRE(result.s_Ok == false);
+ BOOST_REQUIRE_EQUAL(result.s_Rejected.size(), 1);
+ BOOST_REQUIRE(result.s_Rejected[0].s_Reason ==
+ ml::sandbox::EChildIpcPathRejection::E_CanonicalizationFailed ||
+ result.s_Rejected[0].s_Reason ==
+ ml::sandbox::EChildIpcPathRejection::E_OutsideTrustedBase);
+}
+
+BOOST_AUTO_TEST_CASE(testRejectsWrongDepthDirectChildOfTrustedBase) {
+ CTempChildIpcFixture fixture{"child-6"};
+ // Direct child of $TMPDIR (missing the ml-child-ipc intermediate
+ // directory) must fail, not silently be accepted as "close enough".
+ const std::string tooShallow{fixture.canonicalTrustedBase() + "/input.fifo"};
+
+ const ml::sandbox::SChildIpcValidationResult result{ml::sandbox::validateChildIpcLaunchSpec(
+ fixture.canonicalTrustedBase(), {"--input=" + tooShallow})};
+
+ BOOST_TEST_REQUIRE(result.s_Ok == false);
+ BOOST_REQUIRE_EQUAL(result.s_Rejected.size(), 1);
+ BOOST_REQUIRE(result.s_Rejected[0].s_Reason == ml::sandbox::EChildIpcPathRejection::E_RootLevelPath ||
+ result.s_Rejected[0].s_Reason ==
+ ml::sandbox::EChildIpcPathRejection::E_WrongDepth);
+}
+
+BOOST_AUTO_TEST_CASE(testRejectsTooDeepNestingUnderChildId) {
+ CTempChildIpcFixture fixture{"child-7"};
+ const std::string nestedDir{fixture.childRoot() + "/nested"};
+ BOOST_TEST_REQUIRE(::mkdir(nestedDir.c_str(), 0700) == 0);
+
+ const ml::sandbox::SChildIpcValidationResult result{ml::sandbox::validateChildIpcLaunchSpec(
+ fixture.canonicalTrustedBase(), {"--input=" + nestedDir + "/input.fifo"})};
+
+ BOOST_TEST_REQUIRE(result.s_Ok == false);
+ BOOST_REQUIRE_EQUAL(result.s_Rejected.size(), 1);
+ BOOST_REQUIRE(result.s_Rejected[0].s_Reason ==
+ ml::sandbox::EChildIpcPathRejection::E_WrongDepth);
+
+ ::rmdir(nestedDir.c_str());
+}
+
+BOOST_AUTO_TEST_CASE(testRejectsDuplicateLiteralArgument) {
+ CTempChildIpcFixture fixture{"child-8"};
+ const std::string arg{"--input=" + fixture.childRoot() + "/input.fifo"};
+
+ const ml::sandbox::SChildIpcValidationResult result{ml::sandbox::validateChildIpcLaunchSpec(
+ fixture.canonicalTrustedBase(), {arg, arg})};
+
+ BOOST_TEST_REQUIRE(result.s_Ok == false);
+ BOOST_REQUIRE_EQUAL(result.s_Rejected.size(), 1);
+ BOOST_REQUIRE(result.s_Rejected[0].s_Reason ==
+ ml::sandbox::EChildIpcPathRejection::E_Duplicate);
+}
+
+BOOST_AUTO_TEST_CASE(testRejectsMutableSymlinkAlias) {
+ CTempChildIpcFixture fixture{"child-9"};
+ const std::string aliasPath{fixture.canonicalTrustedBase() + "/ml-child-ipc/child-9-alias"};
+ BOOST_TEST_REQUIRE(::symlink(fixture.childRoot().c_str(), aliasPath.c_str()) == 0);
+
+ const ml::sandbox::SChildIpcValidationResult result{ml::sandbox::validateChildIpcLaunchSpec(
+ fixture.canonicalTrustedBase(), {"--input=" + aliasPath + "/input.fifo"})};
+
+ BOOST_TEST_REQUIRE(result.s_Ok == false);
+ BOOST_REQUIRE_EQUAL(result.s_Rejected.size(), 1);
+ BOOST_REQUIRE(result.s_Rejected[0].s_Reason ==
+ ml::sandbox::EChildIpcPathRejection::E_MutableSymlinkOrAlias);
+
+ ::unlink(aliasPath.c_str());
+}
+
+BOOST_AUTO_TEST_CASE(testRejectsChildIdMismatchAcrossOptions) {
+ // Both children must sit under the *same* trusted base for this to
+ // actually exercise E_ChildIdMismatch - two independent
+ // CTempChildIpcFixture instances each mkdtemp their own unrelated base,
+ // so a second-fixture path would hit E_OutsideTrustedBase/E_WrongDepth
+ // first and never reach the child-id comparison at all.
+ CTempChildIpcFixture fixtureA{"child-10a"};
+ const std::string siblingChildRoot{fixtureA.canonicalTrustedBase() + "/ml-child-ipc/child-10b"};
+ BOOST_TEST_REQUIRE(::mkdir(siblingChildRoot.c_str(), 0700) == 0);
+
+ const ml::sandbox::SChildIpcValidationResult result{ml::sandbox::validateChildIpcLaunchSpec(
+ fixtureA.canonicalTrustedBase(),
+ {"--input=" + fixtureA.childRoot() + "/input.fifo",
+ "--output=" + siblingChildRoot + "/output.fifo"})};
+
+ BOOST_TEST_REQUIRE(result.s_Ok == false);
+ BOOST_REQUIRE_EQUAL(result.s_Rejected.size(), 1);
+ BOOST_REQUIRE(result.s_Rejected[0].s_Reason ==
+ ml::sandbox::EChildIpcPathRejection::E_ChildIdMismatch);
+
+ ::rmdir(siblingChildRoot.c_str());
+}
+
+BOOST_AUTO_TEST_CASE(testIgnoresScalarOptionsAsCandidatePaths) {
+ CTempChildIpcFixture fixture{"child-11"};
+ const ml::sandbox::SChildIpcValidationResult result{ml::sandbox::validateChildIpcLaunchSpec(
+ fixture.canonicalTrustedBase(), {"--input=" + fixture.childRoot() + "/input.fifo",
+ "--modelId=../../../etc/passwd", "--inputIsPipe"})};
+
+ BOOST_TEST_REQUIRE(result.s_Ok);
+ BOOST_TEST_REQUIRE(result.s_Rejected.empty());
+}
+
+BOOST_AUTO_TEST_CASE(testRejectsEmptyValueForRecognizedPathOptionEvenAmongValidOnes) {
+ CTempChildIpcFixture fixture{"child-12"};
+ // "--input=" (empty value) must be rejected, not silently skipped as if
+ // the option were absent - even though "--output=..." for the same
+ // child is otherwise valid. A prior version of the parser treated an
+ // empty value identically to a missing "=" and never reached the
+ // value.empty() rejection branch below it.
+ const ml::sandbox::SChildIpcValidationResult result{ml::sandbox::validateChildIpcLaunchSpec(
+ fixture.canonicalTrustedBase(),
+ {"--input=", "--output=" + fixture.childRoot() + "/output.fifo"})};
+
+ BOOST_TEST_REQUIRE(result.s_Ok == false);
+ BOOST_REQUIRE_EQUAL(result.s_Rejected.size(), 1);
+ BOOST_REQUIRE_EQUAL(result.s_Rejected[0].s_Arg, "--input=");
+ BOOST_REQUIRE(result.s_Rejected[0].s_Reason ==
+ ml::sandbox::EChildIpcPathRejection::E_NotAbsolute);
+}
+
+BOOST_AUTO_TEST_SUITE_END()
diff --git a/lib/sandbox/unittest/payloads/ml_sandbox_probe.cc b/lib/sandbox/unittest/payloads/ml_sandbox_probe.cc
new file mode 100644
index 0000000000..e418e2d8e6
--- /dev/null
+++ b/lib/sandbox/unittest/payloads/ml_sandbox_probe.cc
@@ -0,0 +1,198 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License
+ * 2.0 and the following additional limitation. Functionality enabled by the
+ * files subject to the Elastic License 2.0 may only be used in production when
+ * invoked by an Elasticsearch process with a license key installed that permits
+ * use of machine learning features. You may not use this file except in
+ * compliance with the Elastic License 2.0 and the foregoing additional
+ * limitation.
+ */
+
+// Purpose-built allowlisted payload for the typed filesystem/network launch
+// policy's mechanism probe. Runs *inside* the sandbox under the policy built
+// by buildPytorchInferenceFilesystemPolicy and prints
+// one "mechanism=... outcome=..." line per check to stdout, which the
+// controller-side test (CPytorchInferenceSandboxPolicyMechanismTest_Linux)
+// asserts on directly - a wrong-but-still-startable policy would otherwise
+// look identical to a correct one if the test only checked the exit code.
+// Deliberately dependency-free, like sandbox_smoke_payload.cc: no ml-cpp
+// library dependencies, no policy of its own.
+
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+
+namespace {
+
+//! File descriptor for the results file this probe writes into the mapped
+//! per-child IPC directory. The host-side test reads that file directly
+//! from the *host* path after the sandbox exits - the IPC directory is
+//! genuinely shared, so this doubles as the "allowed IPC access" proof and
+//! as this probe's only result channel (no stdout capture plumbing exists
+//! yet; that lands once a real process spawner owns pipe plumbing for
+//! sandboxed children).
+int g_ResultsFd = -1;
+
+void report(const char* mechanism, const char* outcome, const std::string& detail = "") {
+ std::printf("ml_sandbox_probe: mechanism=%s outcome=%s detail=%s\n",
+ mechanism, outcome, detail.c_str());
+ std::fflush(stdout);
+ if (g_ResultsFd >= 0) {
+ std::string line{std::string("mechanism=") + mechanism +
+ " outcome=" + outcome + " detail=" + detail + "\n"};
+ ::write(g_ResultsFd, line.c_str(), line.size());
+ }
+}
+
+} // namespace
+
+int main(int argc, char** argv) {
+ if (argc < 2) {
+ std::fprintf(stderr, "usage: ml_sandbox_probe \n");
+ return EXIT_FAILURE;
+ }
+ const std::string ipcDir{argv[1]};
+
+ g_ResultsFd = ::open((ipcDir + "/results.txt").c_str(),
+ O_CREAT | O_WRONLY | O_TRUNC, 0600);
+
+ std::printf("ml_sandbox_probe: reached\n");
+ std::fflush(stdout);
+ if (g_ResultsFd >= 0) {
+ const std::string reachedLine{"reached=true\n"};
+ ::write(g_ResultsFd, reachedLine.c_str(), reachedLine.size());
+ }
+
+ // Allowed IPC access (positive control): write then read back a file
+ // inside the mapped per-child IPC directory.
+ const std::string ipcFile{ipcDir + "/probe.txt"};
+ int writeFd = ::open(ipcFile.c_str(), O_CREAT | O_WRONLY, 0600);
+ if (writeFd >= 0) {
+ ::write(writeFd, "probe", 5);
+ ::close(writeFd);
+ int readFd = ::open(ipcFile.c_str(), O_RDONLY);
+ char buf[8]{};
+ const bool readBack = readFd >= 0 && ::read(readFd, buf, sizeof(buf)) == 5 &&
+ std::strncmp(buf, "probe", 5) == 0;
+ if (readFd >= 0) {
+ ::close(readFd);
+ }
+ report("ipc_readwrite", readBack ? "allowed" : "denied");
+ } else {
+ report("ipc_readwrite", "denied", std::strerror(errno));
+ }
+
+ // Denied host read (negative control): /etc/shadow must not be
+ // readable even though narrow, individually justified /etc files are
+ // allowlisted (allowlistedEtcFiles()).
+ int shadowFd = ::open("/etc/shadow", O_RDONLY);
+ if (shadowFd < 0) {
+ report("host_read_etc_shadow", "denied", std::strerror(errno));
+ } else {
+ ::close(shadowFd);
+ report("host_read_etc_shadow", "allowed");
+ }
+
+ // Private tmpfs: writability alone doesn't prove /tmp is a private
+ // tmpfs rather than a host bind - a regressed policy that AddDirectory's
+ // the real host /tmp would still pass a plain write check on any
+ // world-writable host. statfs()'s f_type is the actual mechanism
+ // distinguishing tmpfs from a bind-mounted host directory.
+ struct statfs tmpStatfs {};
+ const bool isTmpfs = ::statfs("/tmp", &tmpStatfs) == 0 && tmpStatfs.f_type == TMPFS_MAGIC;
+ const std::string privateTmpFile{"/tmp/ml_sandbox_probe_private_tmp_test"};
+ int tmpFd = ::open(privateTmpFile.c_str(), O_CREAT | O_WRONLY, 0600);
+ if (tmpFd >= 0) {
+ ::close(tmpFd);
+ ::unlink(privateTmpFile.c_str());
+ report("private_tmpfs_write", isTmpfs ? "allowed" : "denied",
+ isTmpfs ? "" : "writable but not tmpfs-backed");
+ } else {
+ report("private_tmpfs_write", "denied", std::strerror(errno));
+ }
+
+ // Mount enumeration conformance: /etc must list only the
+ // allowlisted files, never a full directory bind.
+ DIR* etcDir = ::opendir("/etc");
+ if (etcDir != nullptr) {
+ int entryCount = 0;
+ while (::readdir(etcDir) != nullptr) {
+ ++entryCount;
+ }
+ ::closedir(etcDir);
+ report("etc_enumeration", "counted", std::to_string(entryCount));
+ } else {
+ report("etc_enumeration", "denied", std::strerror(errno));
+ }
+
+ // Private PID namespace: this process should be (close to) the
+ // sandbox's own init, not a real-looking host PID.
+ report("pid_namespace", (::getpid() <= 2) ? "namespaced" : "not_namespaced",
+ std::to_string(::getpid()));
+
+ // External egress denial (negative control): an outbound connect to
+ // a guaranteed non-routable test address (TEST-NET-1, RFC 5737) must
+ // fail - Sandbox2's network namespace has no route out. Using a
+ // non-routable address instead of a real host keeps this check
+ // hermetic and independent of network availability in CI.
+ int egressSocket = ::socket(AF_INET, SOCK_STREAM, 0);
+ if (egressSocket >= 0) {
+ sockaddr_in addr{};
+ addr.sin_family = AF_INET;
+ addr.sin_port = htons(80);
+ ::inet_pton(AF_INET, "192.0.2.1", &addr.sin_addr);
+ const int rc = ::connect(egressSocket, reinterpret_cast(&addr),
+ sizeof(addr));
+ const int connectErrno = errno;
+ // A namespace with no route out fails synchronously with
+ // ENETUNREACH/EHOSTUNREACH before any packet leaves the sandbox.
+ // ECONNREFUSED would mean a packet actually reached something that
+ // sent back RST - a routing leak, not isolation - so only the
+ // no-route errnos count as "denied"; anything else (including
+ // success) is reported "allowed" to keep that distinction visible.
+ const bool denied = rc != 0 && (connectErrno == ENETUNREACH ||
+ connectErrno == EHOSTUNREACH);
+ report("external_egress", denied ? "denied" : "allowed", std::strerror(connectErrno));
+ ::close(egressSocket);
+ } else {
+ report("external_egress", "denied", std::strerror(errno));
+ }
+
+ // Local operation success (positive control): loopback must remain
+ // reachable at the network-namespace level. Connection-refused (nobody
+ // listening on this port) still counts as "reachable" - only a
+ // namespace-level error (e.g. ENETUNREACH) means loopback itself broke.
+ int loopbackSocket = ::socket(AF_INET, SOCK_STREAM, 0);
+ if (loopbackSocket >= 0) {
+ sockaddr_in addr{};
+ addr.sin_family = AF_INET;
+ addr.sin_port = htons(1);
+ addr.sin_addr.s_addr = htonl(INADDR_LOOPBACK);
+ const int rc = ::connect(loopbackSocket,
+ reinterpret_cast(&addr), sizeof(addr));
+ const bool loopbackReachable = rc == 0 || errno == ECONNREFUSED;
+ report("loopback_reachable", loopbackReachable ? "ok" : "broken",
+ std::strerror(errno));
+ ::close(loopbackSocket);
+ } else {
+ report("loopback_reachable", "broken", std::strerror(errno));
+ }
+
+ std::printf("ml_sandbox_probe: done\n");
+ std::fflush(stdout);
+ if (g_ResultsFd >= 0) {
+ ::close(g_ResultsFd);
+ }
+ return EXIT_SUCCESS;
+}
From 96c4954d41a13c7365c34483125388b86e9835fa Mon Sep 17 00:00:00 2001
From: Valeriy Khakhutskyy <1292899+valeriy42@users.noreply.github.com>
Date: Wed, 23 Sep 2026 10:36:36 +0200
Subject: [PATCH 04/10] [ML] Linear child ownership and fault-injected
lifecycle for Sandbox2 (#3187)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
## Summary
Rebuilds `CSandboxedProcessSpawner` around an explicit lifecycle state machine (`Prepared -> Launched -> IdentityCaptured -> Registered -> Monitoring -> TerminationRequested -> CleanupRequired -> Reaped/Failed`), stacked on #3184.
- Non-throwing kill-and-reap guard, armed immediately after launch and PID capture, holding its own owning reference to the sandbox handle so cleanup is order-independent during unwinding.
- Four injectable seams (pidfd acquisition, registry allocation, monitor-thread launch, sandbox completion) for deterministic fault injection.
- Explicit pidfd outcome classification: a kernel lacking pidfd support (`ENOSYS`) is the *only* case that falls back to signalling the sandboxee through its owned monitor handle (SIGKILL, identity-safe, no numeric-PID lookup); every other pidfd error is treated as a resource/identity failure and fails registration outright. Numeric `kill(pid, ...)` does not appear anywhere in this file.
- A CAS-controlled one-shot latch 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.
## Notes
- `E_Launched`/`E_CleanupRequired` lifecycle states are declared but intentionally left unassigned (no natural single point without broader restructuring) — not silently dropped.
- The SIGKILL-only assumption for the owned-monitor termination path (no graceful-SIGTERM variant is buildable against the pinned Sandbox2 dependency version) was cross-checked against the vendored monitor source during review.
(cherry picked from commit 12c3ebfa4bcfdb913ffc6535f919160708cdea7b)
---
include/sandbox/CSandboxedProcessSpawner.h | 290 +++++
lib/sandbox/CMakeLists.txt | 1 +
lib/sandbox/CSandboxedProcessSpawner_Linux.cc | 824 ++++++++++++
lib/sandbox/unittest/CMakeLists.txt | 30 +
...dboxedProcessSpawnerLifecycleTest_Linux.cc | 1129 +++++++++++++++++
.../payloads/lifecycle_signal_payload.cc | 72 ++
6 files changed, 2346 insertions(+)
create mode 100644 include/sandbox/CSandboxedProcessSpawner.h
create mode 100644 lib/sandbox/CSandboxedProcessSpawner_Linux.cc
create mode 100644 lib/sandbox/unittest/CSandboxedProcessSpawnerLifecycleTest_Linux.cc
create mode 100644 lib/sandbox/unittest/payloads/lifecycle_signal_payload.cc
diff --git a/include/sandbox/CSandboxedProcessSpawner.h b/include/sandbox/CSandboxedProcessSpawner.h
new file mode 100644
index 0000000000..a84aa28f2d
--- /dev/null
+++ b/include/sandbox/CSandboxedProcessSpawner.h
@@ -0,0 +1,290 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License
+ * 2.0 and the following additional limitation. Functionality enabled by the
+ * files subject to the Elastic License 2.0 may only be used in production when
+ * invoked by an Elasticsearch process with a license key installed that permits
+ * use of machine learning features. You may not use this file except in
+ * compliance with the Elastic License 2.0 and the foregoing additional
+ * limitation.
+ */
+#ifndef INCLUDED_ml_sandbox_CSandboxedProcessSpawner_h
+#define INCLUDED_ml_sandbox_CSandboxedProcessSpawner_h
+
+#include
+
+#include
+#include
+#include
+#include