diff --git a/include/sandbox/CPytorchInferenceSandboxPolicy.h b/include/sandbox/CPytorchInferenceSandboxPolicy.h new file mode 100644 index 000000000..00564b547 --- /dev/null +++ b/include/sandbox/CPytorchInferenceSandboxPolicy.h @@ -0,0 +1,144 @@ +/* + * 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 +#endif + +namespace ml { +namespace sandbox { + +//! Reasons a path-bearing launch argument fails typed validation against the +//! pinned child-root contract (docs/projects/mlcpp-sandbox2-pr2873/design.md +//! Sandbox2 clean rebuild plan, PR C, gate V16). Every value here must fail +//! *before* a policy is constructed; none of them widen a mount to recover. +enum class EChildIpcPathRejection { + E_UnrecognizedOption, //!< option name is not input/output/restore/logPipe. + 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, //!< realpath() could not resolve the parent directory. + 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 iff no recognized path option was present in the command line. + 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 + +//! What buildPytorchInferenceFilesystemPolicy does with one of the seven +//! historically bulk-mounted fixed directories +//! (/lib /lib64 /usr/lib /usr/lib64 /etc /proc /sys). See design.md +//! §Filesystem and IPC policy: whole /etc and a host /proc/sys bind are +//! 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; +}; + +//! The minimization decision PR C applies to each of the seven historically +//! bulk-mounted fixed directories, with its justification. /etc is Skip +//! (see allowlistedEtcFiles() for the narrower replacement); /proc and /sys +//! are the Sandbox2-namespaced procfs/sysfs, never a host bind (see +//! namespacedProcfsConformant() for the runtime assertion that this actually +//! held for a given launch). /lib, /lib64, /usr/lib, /usr/lib64 remain whole +//! read-only directories: the dynamic loader resolves libtorch/glibc shared +//! objects from them at runtime from an unbounded, platform-dependent set, +//! so per-file allowlisting would duplicate the loader's own search logic. +const std::vector& fixedMountDecisions(); + +//! Individual /etc files pytorch_inference/libtorch are demonstrated to +//! need, replacing a whole-/etc bind. Extend only with a named consumer. +const std::vector& allowlistedEtcFiles(); + +//! Builds the filesystem and network-shape portion of the pytorch_inference +//! Sandbox2 policy: minimized fixed mounts (fixedMountDecisions, +//! allowlistedEtcFiles), 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::pytorch_inference::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. spec must already be s_Ok from validateChildIpcLaunchSpec; this +//! function does not re-validate it. +sandbox2::PolicyBuilder buildPytorchInferenceFilesystemPolicy(const std::string& binDir, + const std::string& libDir, + const SChildIpcLaunchSpec& spec, + 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 8b2b1fcae..1c5205843 100644 --- a/lib/sandbox/CMakeLists.txt +++ b/lib/sandbox/CMakeLists.txt @@ -9,21 +9,23 @@ # limitation. # -# MlSandbox is a dormant dependency foundation (PR A of the Sandbox2 clean -# rebuild plan, see docs/projects/mlcpp-sandbox2-pr2873 in the -# elastic-workspace harness). 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 later PRs of that plan. +# MlSandbox links Sandbox2/Abseil and builds a runnable Sandbox2 forkserver +# on Linux (PR A), and now the typed filesystem/network launch policy (PR C +# of the Sandbox2 clean rebuild plan, see docs/projects/mlcpp-sandbox2-pr2873 +# in the elastic-workspace harness). No controller or pytorch_inference +# routing depends on it yet - the process spawner and controller wiring +# land in later PRs of that plan. 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 000000000..4b873ee0c --- /dev/null +++ b/lib/sandbox/CPytorchInferenceSandboxPolicy.cc @@ -0,0 +1,322 @@ +/* + * 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 + +#include +#include + +#ifdef SANDBOX2_AVAILABLE +#include +#endif + +#ifdef __linux__ +#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 (design.md §Typed argument extraction). +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 +//! (design.md §Required layout), so canonicalizing the *parent* directory +//! of the leaf is always meaningful. +bool canonicalize(const std::string& dir, std::string& canonicalOut) { + char resolved[PATH_MAX]; + if (::realpath(dir.c_str(), resolved) == nullptr) { + return false; + } + canonicalOut.assign(resolved); + return true; +} + +} // 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 || eqPos + 1 >= arg.size()) { + continue; + } + + std::string optionName{arg.substr(0, eqPos)}; + while (optionName.empty() == false && optionName[0] == '-') { + optionName.erase(0, 1); + } + + if (isPathOptionName(optionName) == false) { + continue; + } + + const std::string value{arg.substr(eqPos + 1)}; + + 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 + +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 as an open item for PR C's Linux verification pass, not + // resolved here. + static const std::vector FILES{ + "/etc/nsswitch.conf", + "/etc/resolv.conf", + "/etc/hosts", + "/etc/localtime", + "/etc/ld.so.cache", + }; + return FILES; +} + +sandbox2::PolicyBuilder buildPytorchInferenceFilesystemPolicy(const std::string& binDir, + const std::string& libDir, + const SChildIpcLaunchSpec& spec, + std::size_t tmpfsSizeBytes) { + 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/CPytorchInferenceSyscallAllowlist.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 (design.md "Locked design decisions": "One machine-readable + // declaration generates the applied legacy BPF allowlist and Sandbox2 + // explicit grants"). + for (int syscallNr : seccomp::pytorch_inference::legacyBpfAllowedSyscalls()) { + 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: + 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()) { + policyBuilder.AddFile(etcFile, /*is_ro=*/true); + } + + for (const std::string& devFile : {"/dev/null", "/dev/urandom", "/dev/random"}) { + policyBuilder.AddFile(devFile, /*is_ro=*/devFile != std::string{"/dev/null"}); + } + + // 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. spec must already be s_Ok (validateChildIpcLaunchSpec), so + // s_ChildIpcRoot is exactly $TMPDIR/ml-child-ipc/ - never + // ml-child-ipc itself, never a sibling child's directory. + policyBuilder.AddDirectoryAt(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 82b68e665..76d542402 100644 --- a/lib/sandbox/unittest/CMakeLists.txt +++ b/lib/sandbox/unittest/CMakeLists.txt @@ -14,12 +14,14 @@ project("ML Sandbox unit tests") set(SRCS Main.cc CMlSandboxAvailabilityTest.cc + CPytorchInferenceSandboxPolicyTest.cc ) set(ML_LINK_LIBRARIES ${Boost_LIBRARIES_WITH_UNIT_TEST} MlCore MlSandbox + MlSeccomp MlTest ) @@ -31,6 +33,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 +53,17 @@ if(TARGET sandbox2::sandbox2 AND CMAKE_SYSTEM_NAME STREQUAL "Linux") POSITION_INDEPENDENT_CODE TRUE RUNTIME_OUTPUT_DIRECTORY ${CMAKE_CURRENT_BINARY_DIR}/payloads ) + + # PR C's purpose-built allowlisted mechanism-probe payload (design.md + # gates V4/V7/V16). 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 +74,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 000000000..e0f5ffe01 --- /dev/null +++ b/lib/sandbox/unittest/CPytorchInferenceSandboxPolicyMechanismTest_Linux.cc @@ -0,0 +1,139 @@ +/* + * 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 PR C of +// docs/projects/mlcpp-sandbox2-pr2873 (design.md gates V4, V7, and the +// policy half of V5). Builds a real filesystem/network 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. +// +// NOT YET RUN: this test has not been executed on a real Linux host in +// this session (macOS host has no Sandbox2/Linux toolchain). It has been +// reviewed against the Sandbox2 PolicyBuilder API as used by the +// PR-A-verified CSandboxForkserverSmokeTest_Linux, but needs a devbox or +// Buildkite pass before its gates (V4/V7/V16) can be marked verified - see +// elastic-workspace-3b59.4 session notes. + +#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 {}; +} + +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(); +} + +} // namespace + +BOOST_AUTO_TEST_SUITE(CPytorchInferenceSandboxPolicyMechanismTest_Linux) + +BOOST_AUTO_TEST_CASE(testMinimizedPolicyEnforcesEveryMechanism) { + char tmpDirTemplate[] = "/tmp/ml_sandbox_probe_test_XXXXXX"; + char* tmpDir = ::mkdtemp(tmpDirTemplate); + BOOST_TEST_REQUIRE(tmpDir != nullptr); + const std::string trustedTmpDir{tmpDir}; + + BOOST_TEST_REQUIRE(::mkdir((trustedTmpDir + "/ml-child-ipc").c_str(), 0700) == 0); + const std::string childRoot{trustedTmpDir + "/ml-child-ipc/mechanism-probe-child"}; + BOOST_TEST_REQUIRE(::mkdir(childRoot.c_str(), 0700) == 0); + + const std::vector args{"--input=" + childRoot + "/input.fifo", + "--output=" + childRoot + "/output.fifo", + "--logPipe=" + childRoot + "/log.fifo"}; + const ml::sandbox::SChildIpcValidationResult validated{ + ml::sandbox::validateChildIpcLaunchSpec(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)); + + sandbox2::PolicyBuilder policyBuilder{ml::sandbox::buildPytorchInferenceFilesystemPolicy( + "/usr/bin", "/usr/lib", validated.s_Spec, /*tmpfsSizeBytes=*/16 * 1024 * 1024)}; + policyBuilder.AddLibrariesForBinary(payloadPath); + 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 V4 "allowed + // IPC access" proof, not a separate assertion: if the mount/policy were + // wrong, this file would never appear. + const std::string resultsContent{readFileOrEmpty(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"); + BOOST_REQUIRE_EQUAL(outcomeFor(resultsContent, "loopback_reachable"), "ok"); + + ::unlink((childRoot + "/probe.txt").c_str()); + ::unlink((childRoot + "/results.txt").c_str()); + ::rmdir(childRoot.c_str()); + ::rmdir((trustedTmpDir + "/ml-child-ipc").c_str()); + ::rmdir(trustedTmpDir.c_str()); +} + +BOOST_AUTO_TEST_SUITE_END() diff --git a/lib/sandbox/unittest/CPytorchInferenceSandboxPolicyTest.cc b/lib/sandbox/unittest/CPytorchInferenceSandboxPolicyTest.cc new file mode 100644 index 000000000..93da9939f --- /dev/null +++ b/lib/sandbox/unittest/CPytorchInferenceSandboxPolicyTest.cc @@ -0,0 +1,237 @@ +/* + * 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 (design.md Sandbox2 clean rebuild plan, PR C, gate V16). This +// suite is deliberately platform-portable - it only needs realpath()/mkdir() +// /symlink(), not Sandbox2 itself - so it runs on every ml-cpp CI platform, +// not just Linux. + +#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" - + // this was the exact wording bug the adversarial review (M1) caught in + // an earlier draft of this contract. + const std::string tooShallow{fixture.canonicalTrustedBase() + "/input.fifo"}; + BOOST_TEST_REQUIRE(::mkdir((fixture.canonicalTrustedBase() + "/direct-child-dir").c_str(), 0700) == 0); + + 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); + + ::rmdir((fixture.canonicalTrustedBase() + "/direct-child-dir").c_str()); +} + +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) { + CTempChildIpcFixture fixtureA{"child-10a"}; + CTempChildIpcFixture fixtureB{"child-10b"}; + + const ml::sandbox::SChildIpcValidationResult result{ml::sandbox::validateChildIpcLaunchSpec( + fixtureA.canonicalTrustedBase(), + {"--input=" + fixtureA.childRoot() + "/input.fifo", + "--output=" + fixtureB.childRoot() + "/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); +} + +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_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 000000000..d6f52762e --- /dev/null +++ b/lib/sandbox/unittest/payloads/ml_sandbox_probe.cc @@ -0,0 +1,177 @@ +/* + * 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 PR C's mechanism probe (design.md +// Sandbox2 clean rebuild plan, gates V4/V7/V16). 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 + +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 V4 "allowed IPC access" proof +//! and as this probe's only result channel (no stdout capture plumbing +//! exists yet; that lands with CSandboxedProcessSpawner in PR D). +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 (V4 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 (V4 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 (V4): the mapped /tmp must be writable but is a private + // tmpfs, never the host's shared /tmp - this re-proves it is not shared + // by checking it is actually writable from inside the sandbox. + 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", "allowed"); + } else { + report("private_tmpfs_write", "denied", std::strerror(errno)); + } + + // Mount enumeration (V16 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 (V7 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)); + report("external_egress", rc == 0 ? "allowed" : "denied", std::strerror(errno)); + ::close(egressSocket); + } else { + report("external_egress", "denied", std::strerror(errno)); + } + + // Local operation success (V7 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; +}