diff --git a/.github/workflows/linux-exec-boundary.yml b/.github/workflows/linux-exec-boundary.yml new file mode 100644 index 0000000..c1ab66b --- /dev/null +++ b/.github/workflows/linux-exec-boundary.yml @@ -0,0 +1,77 @@ +name: Linux execution boundary + +on: + pull_request: + branches: [main] + +permissions: + contents: read + +jobs: + native: + name: Execution boundary (${{ matrix.compiler }}) + runs-on: ubuntu-24.04 + timeout-minutes: 10 + strategy: + fail-fast: false + matrix: + compiler: [g++, clang++] + defaults: + run: + working-directory: projects/linux-exec-boundary + shell: bash + steps: + - uses: actions/checkout@v7 + with: + ref: ${{ github.event.pull_request.head.sha }} + persist-credentials: false + + - name: Verify nonprivileged runner and existing toolchain + env: + CXX: ${{ matrix.compiler }} + run: | + test "$(id -u)" -ne 0 + command -v "$CXX" + "$CXX" --version + cmake --version + ctest --version + python3 --version + uname -r + git rev-parse HEAD + + - name: Configure and build approved local fixtures + env: + CXX: ${{ matrix.compiler }} + run: | + cmake -S . -B build -DCMAKE_CXX_COMPILER="$CXX" -DCMAKE_BUILD_TYPE=Debug + cmake --build build --parallel 2 + python3 tools/check_style.py + + - name: Probe capabilities without host configuration changes + run: timeout 10s build/capability_probe > build/runner-capabilities.json + + # Reviewed fixed fixtures only: watchdog/resource/alarm bounds, exact owned + # child targeting, no detached fixture, no arbitrary executable input. + # Existing CTest per-test timeouts are 8s and 50s. Sanitizers remain a + # separately qualified local run, not an assumed hosted-runner capability. + - name: Execute bounded native lifecycle and qualification tests + run: ctest --test-dir build --output-on-failure + + - name: Record actual capability and conditional outcomes + if: always() + run: | + python3 - <<'PY' + import json + from pathlib import Path + for name in ("runner-capabilities.json", "evidence/capabilities.json", + "evidence/results.json", "evidence/negative-controls.json"): + path = Path("build") / name + if path.is_file(): + print(name) + print(json.dumps(json.loads(path.read_text()), indent=2, sort_keys=True)) + else: + print(name + ": NOT_PRODUCED; inspect earlier step failure") + print("CTest success does not mean every optional scenario executed.") + print("E09 native remains NOT_RUN; no arbitrary-tree containment claim.") + print("Hosted sanitizer qualification: NOT_RUN; local ASan/UBSan evidence is separate.") + PY diff --git a/projects/linux-exec-boundary/CAPABILITY-MATRIX.json b/projects/linux-exec-boundary/CAPABILITY-MATRIX.json new file mode 100644 index 0000000..3c7f132 --- /dev/null +++ b/projects/linux-exec-boundary/CAPABILITY-MATRIX.json @@ -0,0 +1,115 @@ +{ + "schema": "exec-boundary.capability-matrix.v1", + "baseline_commit": "2c14763864b3d26bc7a67774e22f9ba78f9329c6", + "environment": { + "kernel": "6.18.54.1-microsoft-standard-WSL2", + "arch": "x86_64", + "non_root": true, + "native_fs": "ext4", + "gcc": "g++ (Ubuntu 13.3.0-6ubuntu2~24.04.1) 13.3.0", + "clang": "Ubuntu clang version 18.1.3 (1ubuntu1)", + "cmake": "cmake version 3.28.3", + "ctest": "ctest version 3.28.3", + "python": "3.12.3", + "own_policy_status": { + "CapEff": "0000000000000000", + "NoNewPrivs": "0", + "Seccomp": "2", + "Seccomp_filters": "1" + } + }, + "source_of_authority": "unprivileged native probes and actual fixture operations", + "features": { + "pidfd_open_and_signal": { + "status": "SUPPORTED_AND_TESTED", + "evidence": "owned child; signal 0; no numeric retarget" + }, + "waitid_P_PIDFD": { + "status": "SUPPORTED_AND_TESTED", + "evidence": "exact child exit and native EINTR/retry" + }, + "no_new_privs": { + "status": "SUPPORTED_AND_TESTED", + "evidence": "child set/get plus post-exec witness" + }, + "landlock": { + "status": "SUPPORTED_AND_TESTED", + "abi": 7, + "enforced_access_classes": [ + "READ_FILE", + "READ_DIR", + "WRITE_FILE", + "MAKE_REG", + "REMOVE_FILE", + "TRUNCATE" + ], + "evidence": "successful setup before exec; six allowed/denied operation classes after exec" + }, + "seccomp": { + "status": "SUPPORTED_AND_TESTED", + "evidence": "getppid allowed in control; EPERM under installed filter across exec", + "inherited_mode_is_not_request_installation": true + }, + "rlimits": { + "status": "SUPPORTED_AND_TESTED", + "controls": [ + "CPU", + "AS", + "FSIZE", + "NOFILE", + "CORE" + ], + "evidence": "set/get and post-exec values; SIGXCPU, ENOMEM, SIGXFSZ, EMFILE and disabled cores" + }, + "close_range": { + "status": "SUPPORTED_AND_TESTED", + "evidence": "unexpected inherited FD 57 removed; injected FD 9 detected" + }, + "subreaper": { + "status": "SUPPORTED_AND_TESTED", + "evidence": "cooperating descendant adopted/reaped after leader exit" + }, + "dedicated_session_process_group": { + "status": "SUPPORTED_AND_TESTED", + "whole_tree_guarantee": false + }, + "cgroup_v2_presence": { + "status": "SUPPORTED_AND_TESTED", + "present": true + }, + "cgroup_delegation": { + "status": "UNAVAILABLE", + "errno": 13, + "evidence": "own current subgroup mkdir denied; current cgroup/procs not writable" + }, + "cgroup_membership_and_kill": { + "status": "NOT_PROBED", + "reason": "no writable delegation; no root/host changes" + }, + "cgroup_backend": { + "status": "UNAVAILABLE", + "strict_requests": "refused before fork" + }, + "atomic_clone3_pidfd": { + "status": "NOT_PROBED", + "reason": "gated fork plus exclusive unreaped-child ownership chosen" + }, + "detached_descendant_native": { + "status": "NOT_PROBED", + "execution": "NOT_RUN", + "reason": "verified secondary containment absent" + }, + "clang_format": { + "status": "UNAVAILABLE", + "fallback": "checked LF/final-newline/tab/NUL/trailing-whitespace conventions" + }, + "clang_tidy": { + "status": "UNAVAILABLE", + "fallback": "Clang built-in static analyzer executed" + }, + "privileged_provisioning": { + "status": "DENIED_BY_POLICY", + "reason": "no sudo or host-wide settings" + } + } +} diff --git a/projects/linux-exec-boundary/CMakeLists.txt b/projects/linux-exec-boundary/CMakeLists.txt new file mode 100644 index 0000000..7df8a19 --- /dev/null +++ b/projects/linux-exec-boundary/CMakeLists.txt @@ -0,0 +1,46 @@ +cmake_minimum_required(VERSION 3.20) +project(linux_exec_boundary VERSION 0.1.0 LANGUAGES CXX) +if(NOT CMAKE_SYSTEM_NAME STREQUAL "Linux") + message(FATAL_ERROR "Native Linux is required; do not substitute Windows process semantics") +endif() +set(CMAKE_CXX_STANDARD 20) +set(CMAKE_CXX_STANDARD_REQUIRED ON) +set(CMAKE_CXX_EXTENSIONS OFF) +option(BOUNDARY_SANITIZERS "Instrument the supervisor with ASan/UBSan" OFF) +add_library(boundary_core src/supervisor.cpp src/manifest.cpp) +target_include_directories(boundary_core PUBLIC include) +add_executable(boundary_fixture fixtures/fixture.cpp) +target_include_directories(boundary_fixture PRIVATE include) +add_executable(exec_boundary src/main.cpp) +target_link_libraries(exec_boundary PRIVATE boundary_core) +target_compile_definitions(exec_boundary PRIVATE APPROVED_FIXTURE_PATH="$") +add_dependencies(exec_boundary boundary_fixture) +add_executable(capability_probe tools/capability_probe.cpp) +foreach(target boundary_core boundary_fixture exec_boundary capability_probe) + target_compile_options(${target} PRIVATE -Wall -Wextra -Wpedantic -Werror) +endforeach() +if(BOUNDARY_SANITIZERS) + target_compile_options(boundary_core PRIVATE -fsanitize=address,undefined -fno-omit-frame-pointer) + target_compile_options(exec_boundary PRIVATE -fsanitize=address,undefined -fno-omit-frame-pointer) + target_link_options(exec_boundary PRIVATE -fsanitize=address,undefined) +endif() +add_executable(lifecycle_test tests/lifecycle.cpp) +target_link_libraries(lifecycle_test PRIVATE boundary_core) +target_compile_definitions(lifecycle_test PRIVATE APPROVED_FIXTURE_PATH="$") +target_compile_options(lifecycle_test PRIVATE -Wall -Wextra -Wpedantic -Werror) +add_dependencies(lifecycle_test boundary_fixture) +if(BOUNDARY_SANITIZERS) + target_link_options(lifecycle_test PRIVATE -fsanitize=address,undefined) +endif() +enable_testing() +find_package(Python3 3.11 REQUIRED COMPONENTS Interpreter) +set(QUALIFY_EXTRA) +if(BOUNDARY_SANITIZERS) + list(APPEND QUALIFY_EXTRA --sanitized) +endif() +add_test(NAME lifecycle COMMAND lifecycle_test) +set_tests_properties(lifecycle PROPERTIES TIMEOUT 8) +add_test(NAME qualification COMMAND ${Python3_EXECUTABLE} ${CMAKE_CURRENT_SOURCE_DIR}/tests/qualify.py + --supervisor $ --probe $ + --output ${CMAKE_CURRENT_BINARY_DIR}/evidence ${QUALIFY_EXTRA}) +set_tests_properties(qualification PROPERTIES TIMEOUT 50) diff --git a/projects/linux-exec-boundary/EXECUTION-CONTRACT.md b/projects/linux-exec-boundary/EXECUTION-CONTRACT.md new file mode 100644 index 0000000..854d650 --- /dev/null +++ b/projects/linux-exec-boundary/EXECUTION-CONTRACT.md @@ -0,0 +1,108 @@ +# Execution contract + +## Problem and invariant + +A successful fork does not prove exec succeeded, a numeric PID is not a lasting +target, and a successful group kill does not establish whole-tree containment. +Reports separate attempted setup, acknowledged controls and observed outcome. +A strict unsupported profile is refused before launching a fixture. + +This is a single-request, dedicated-supervisor-process API. The caller must not +run concurrent requests, create unrelated children concurrently, run another +reaper or install SIGCHLD ignore/SA_NOCLDWAIT. Existing direct children and an +already active subreaper cause refusal. The CLI owns the complete process and +uses fixed fixture arguments; the C++ header is not a general embedding SDK. + +## ExecutionRequest + +- request_id and run_id: required 1-64 character alphanumeric, hyphen or + underscore labels. They are run labels, not durable OS identities. +- approved_executable: CMake's local fixture, opened O_PATH/CLOEXEC before fork + and executed through that descriptor using execveat. +- arguments: exactly boundary_fixture and one allowlisted synthetic scenario. +- environment: exactly LANG=C and LC_ALL=C; stdin is /dev/null. +- working_directory: a newly owned mkdtemp directory under caller TMPDIR. + Only this invocation's directory is removed. +- timeout_ms: 1-3000, measured from execute entry with steady_clock. +- output_limit: 0-16384 retained bytes per stream. Total observed bytes remain + distinct from retained bytes. +- limits: CPU soft/hard 1/2 seconds, AS 32-256 MiB, FSIZE at most 1 MiB, + NOFILE 16-64, CORE zero. CLI defaults: AS 128 MiB, FSIZE 4096 (1024 for the + file-size scenario), NOFILE 32. +- profile: direct, landlock or seccomp. strict-tree and unknown profiles refuse + execution. cancel_after_ms supplies a bounded synthetic cancellation + schedule; SIGTERM/SIGINT provide external cancellation. +- injected_fault: a transparent, allowlisted lab control recorded in results. + +## Design decisions + +The child closes unexpected descriptors with close_range. Standard I/O, the +CLOEXEC executable/error descriptors, the launch gate and an intentional +fixture-witness descriptor are the only setup descriptors. The fixture closes +its witness descriptor before the scenario and reports unexpected descriptors. + +Fork does not atomically return a pidfd. The child waits on the gate while the +parent opens its pidfd and retains exclusive unreaped-child ownership. A child +that exits early remains a zombie until this parent reaps it, preventing PID +reuse under the stated SIGCHLD/reaper preconditions. Acquisition failure aborts +launch and recovery targets only the still-owned unreaped direct child. There +is no silent numeric-target execution fallback. Atomic clone3/CLONE_PIDFD is +not claimed or required. + +The child creates its own session. The parent confirms PGID=SID=child PID before +opening the gate, and validates both values again before group signals. Other +existing-session processes cannot join this dedicated session. The leader +remains unreaped during group cleanup, pinning its identity. A descendant can +still create another session; this is not whole-tree containment. + +Child setup sets limits, no_new_privs and requested restrictions, then +acknowledges success. A separate post-exec fixture witness reports PID/PGID/SID, +observed no_new_privs/seccomp mode, resource values and descriptor census. +Neither an ack nor EOF alone proves exec. The witness is trusted synthetic +instrumentation, not authentication against a malicious replacement binary. + +## Result and classification + +Schema: exec-boundary.execution.v1. Request/run identity, stage/errno, +request-local features, inherited/observed policy, raw waitid status, stop +decisions, bounded capture, cleanup evidence and unsupported controls remain +separate. + +| State | CLI exit | Condition | +| --- | ---: | --- | +| COMPLETED | 0 | Exec witnessed, normal zero exit, no stop decision | +| EXITED_NONZERO | 1 | Exec witnessed, normal nonzero exit | +| SIGNALED | 2 | Exec witnessed, kernel signal termination observed | +| TIMED_OUT | 3 | Deadline stop decision preceded observed termination | +| CANCELLED | 4 | Cancellation stop decision preceded observed termination | +| LAUNCH_FAILED | 5 | Spawn/exec/setup failed or no valid exec witness | +| POLICY_UNAVAILABLE | 6 | Requested control refused before fork | +| CONTAINMENT_UNCONFIRMED | 7 | Cleanup or inherited-FD boundary unconfirmed | +| SUPERVISOR_ERROR | 8 | Supervisor precondition/accounting/artifact failure | + +Unconfirmed containment takes precedence over nominal completion. A timeout or +cancellation decision is retained even if later setup/exit evidence arrives; +an exit/stop race may report that decision with exit zero. This does not assert +that a signal caused the exit. Raw child exit/signal is not the CLI status. + +Inherited seccomp mode 2 does not establish request-local installation. +features.seccomp means this request successfully installed the filter; +inherited and post-exec modes are separate. Landlock has no general policy +introspection API: successful setup and controlled post-exec allowed/denied +operations support the narrow claim. + +Cleanup waits are bounded observations, not hard real-time guarantees for +uninterruptible kernel tasks. Missing reaping within the window is +CONTAINMENT_UNCONFIRMED. Parent SIGTERM requests cancellation. SIGKILL, machine +failure and hostile supervisor modification are outside the guarantee. + +## Compatibility and validation + +No saved-evidence schema, source label, fixture, golden, adapter or root workflow +changes. The independent checker consumes manifests and watchdog observations; +it never calls this classifier to derive expected answers. It rejects false +termination, capability, cleanup, resource, identity and truncation claims. + +Matrices retain conditional results and NOT_RUN. Repeated schedules are race +controls, not independent semantic coverage. See REPRODUCTION.md and the +independent audit for actual execution evidence. diff --git a/projects/linux-exec-boundary/EXECUTION-MANIFEST.json b/projects/linux-exec-boundary/EXECUTION-MANIFEST.json new file mode 100644 index 0000000..0051bfb --- /dev/null +++ b/projects/linux-exec-boundary/EXECUTION-MANIFEST.json @@ -0,0 +1,119 @@ +{ + "schema": "exec-boundary.execution.v1", + "request": { + "request_id": "request-normal", + "run_id": "run-normal", + "approved_executable": "$BUILD/boundary_fixture", + "working_directory": "owned-scratch", + "environment": [ + "LANG=C", + "LC_ALL=C" + ], + "argv": [ + "boundary_fixture", + "normal" + ], + "scenario": "normal", + "profile": "direct", + "injected_fault": "none", + "timeout_ms": 300, + "cancel_after_ms": -1, + "output_limit": 256, + "limits": { + "cpu_soft": 1, + "cpu_hard": 2, + "address_space": 134217728, + "file_size": 4096, + "open_files": 32, + "core_size": 0 + } + }, + "setup": { + "spawned": true, + "confirmed": true, + "exec_observed": true, + "stage": "fixture_observed_after_exec", + "errno": 0 + }, + "features": { + "pidfd": true, + "waitid_pidfd": true, + "group_confirmed": true, + "no_new_privs": true, + "limits_setup": true, + "landlock": false, + "landlock_abi": 7, + "landlock_rights": [], + "seccomp": false, + "cgroup": false + }, + "outcome": { + "state": "COMPLETED", + "exit_code": 0, + "signal": null, + "wait_code": 1, + "wait_status": 0, + "timed_out": false, + "cancelled": false, + "parent_sigterm": false, + "elapsed_ms": 32 + }, + "termination": { + "term_attempted": true, + "kill_attempted": true, + "term_errno": 0, + "kill_errno": 0 + }, + "cleanup": { + "direct_child_reaped": true, + "adopted_reaped": 0, + "complete": true, + "pipes_closed": true, + "artifacts_removed": true, + "owned_fd_delta": 0, + "group_cleanup_attempted": true, + "group_identity_pinned": true, + "whole_tree_confirmed": false + }, + "observed": { + "child_pid": 545, + "pgid": 545, + "sid": 545, + "nnp": 1, + "seccomp": 2, + "inherited_seccomp": 2, + "inherited_nnp": 0, + "unexpected_fds": 0, + "cpu_soft": 1, + "cpu_hard": 2, + "address_space": 134217728, + "file_size": 4096, + "open_files": 32, + "core_size": 0, + "interrupted_calls": 0, + "cleanup_interruptions": 0, + "signal_notifications": 0, + "cleanup_signal_notifications": 0 + }, + "output": { + "stdout": { + "captured_bytes": 7, + "total_bytes": 7, + "truncated": false, + "eof_observed": true, + "hex": "6e6f726d616c0a" + }, + "stderr": { + "captured_bytes": 0, + "total_bytes": 0, + "truncated": false, + "eof_observed": true, + "hex": "" + } + }, + "enforcement_profile": "direct-child-and-best-effort-process-group", + "unsupported": [ + "strict-whole-tree-cgroup", + "general-purpose-sandbox" + ] +} diff --git a/projects/linux-exec-boundary/FAILURE-MATRIX.json b/projects/linux-exec-boundary/FAILURE-MATRIX.json new file mode 100644 index 0000000..c81b34b --- /dev/null +++ b/projects/linux-exec-boundary/FAILURE-MATRIX.json @@ -0,0 +1,589 @@ +{ + "schema": "exec-boundary.failure-matrix.v1", + "capability_limit": "no delegated cgroup / no safe escaped-descendant execution", + "cases": [ + { + "id": "E01", + "title": "Child exits before pidfd observation", + "expected_independent_contract": "observe exact termination or failed pre-exec launch; reap child", + "method": "native+injected-native", + "status": "PASS", + "observations": { + "E01-immediate": { + "adopted_reaped": 0, + "cleanup_interruptions": 0, + "cleanup_signal_notifications": 0, + "elapsed_ms": 32, + "interrupted_calls": 0, + "oracle": "accepted", + "signal_notifications": 0, + "state": "COMPLETED", + "status": "PASS" + }, + "E01-before-pidfd": { + "adopted_reaped": 0, + "cleanup_interruptions": 0, + "cleanup_signal_notifications": 0, + "elapsed_ms": 30, + "interrupted_calls": 0, + "oracle": "accepted", + "signal_notifications": 0, + "state": "LAUNCH_FAILED", + "status": "PASS" + } + }, + "bounds": "watchdog wall 5s, CPU hard 4s, file/FD/core caps; fixture alarm 3s (descendant 2s)", + "input_and_environment": "fixed argv/scenario in tests/qualify.py; request/observer manifests retained privately", + "first_invariant_to_check": "truthful setup/outcome, exact owned targets, bounded capture and observed reaping", + "red_green_evidence": "negative/failure is injected or scheduled; handled outcome accepted by independent oracle", + "cleanup_obligations": "watchdog reports zero adopted survivors/unexpected reaps; source-owned scratch empty; FD census stable" + }, + { + "id": "E02", + "title": "Cancellation/exit race", + "expected_independent_contract": "COMPLETED or CANCELLED with unchanged raw wait status", + "method": "native bounded schedules", + "status": "PASS", + "observations": { + "E02-cancel-0": { + "adopted_reaped": 0, + "cleanup_interruptions": 0, + "cleanup_signal_notifications": 0, + "elapsed_ms": 31, + "interrupted_calls": 0, + "oracle": "accepted", + "signal_notifications": 0, + "state": "CANCELLED", + "status": "PASS" + }, + "E02-cancel-1": { + "adopted_reaped": 0, + "cleanup_interruptions": 0, + "cleanup_signal_notifications": 0, + "elapsed_ms": 35, + "interrupted_calls": 0, + "oracle": "accepted", + "signal_notifications": 0, + "state": "CANCELLED", + "status": "PASS" + }, + "E02-cancel-2": { + "adopted_reaped": 0, + "cleanup_interruptions": 0, + "cleanup_signal_notifications": 0, + "elapsed_ms": 50, + "interrupted_calls": 0, + "oracle": "accepted", + "signal_notifications": 0, + "state": "CANCELLED", + "status": "PASS" + }, + "E02-cancel-3": { + "adopted_reaped": 0, + "cleanup_interruptions": 0, + "cleanup_signal_notifications": 0, + "elapsed_ms": 52, + "interrupted_calls": 0, + "oracle": "accepted", + "signal_notifications": 0, + "state": "COMPLETED", + "status": "PASS" + } + }, + "bounds": "watchdog wall 5s, CPU hard 4s, file/FD/core caps; fixture alarm 3s (descendant 2s)", + "input_and_environment": "fixed argv/scenario in tests/qualify.py; request/observer manifests retained privately", + "first_invariant_to_check": "truthful setup/outcome, exact owned targets, bounded capture and observed reaping", + "red_green_evidence": "negative/failure is injected or scheduled; handled outcome accepted by independent oracle", + "cleanup_obligations": "watchdog reports zero adopted survivors/unexpected reaps; source-owned scratch empty; FD census stable" + }, + { + "id": "E03", + "title": "Timeout/completion race", + "expected_independent_contract": "COMPLETED or TIMED_OUT; retain first stop decision", + "method": "native bounded schedules", + "status": "PASS", + "observations": { + "E03-timeout-0": { + "adopted_reaped": 0, + "cleanup_interruptions": 0, + "cleanup_signal_notifications": 0, + "elapsed_ms": 31, + "interrupted_calls": 0, + "oracle": "accepted", + "signal_notifications": 0, + "state": "TIMED_OUT", + "status": "PASS" + }, + "E03-timeout-1": { + "adopted_reaped": 0, + "cleanup_interruptions": 0, + "cleanup_signal_notifications": 0, + "elapsed_ms": 50, + "interrupted_calls": 0, + "oracle": "accepted", + "signal_notifications": 0, + "state": "TIMED_OUT", + "status": "PASS" + }, + "E03-timeout-2": { + "adopted_reaped": 0, + "cleanup_interruptions": 0, + "cleanup_signal_notifications": 0, + "elapsed_ms": 52, + "interrupted_calls": 0, + "oracle": "accepted", + "signal_notifications": 0, + "state": "COMPLETED", + "status": "PASS" + } + }, + "bounds": "watchdog wall 5s, CPU hard 4s, file/FD/core caps; fixture alarm 3s (descendant 2s)", + "input_and_environment": "fixed argv/scenario in tests/qualify.py; request/observer manifests retained privately", + "first_invariant_to_check": "truthful setup/outcome, exact owned targets, bounded capture and observed reaping", + "red_green_evidence": "negative/failure is injected or scheduled; handled outcome accepted by independent oracle", + "cleanup_obligations": "watchdog reports zero adopted survivors/unexpected reaps; source-owned scratch empty; FD census stable" + }, + { + "id": "E04", + "title": "Exec fails after spawn", + "expected_independent_contract": "LAUNCH_FAILED; setup distinct from exec witness", + "method": "injected native invalid execveat", + "status": "PASS", + "observations": { + "E04-exec": { + "adopted_reaped": 0, + "cleanup_interruptions": 0, + "cleanup_signal_notifications": 0, + "elapsed_ms": 31, + "interrupted_calls": 0, + "oracle": "accepted", + "signal_notifications": 0, + "state": "LAUNCH_FAILED", + "status": "PASS" + } + }, + "bounds": "watchdog wall 5s, CPU hard 4s, file/FD/core caps; fixture alarm 3s (descendant 2s)", + "input_and_environment": "fixed argv/scenario in tests/qualify.py; request/observer manifests retained privately", + "first_invariant_to_check": "truthful setup/outcome, exact owned targets, bounded capture and observed reaping", + "red_green_evidence": "negative/failure is injected or scheduled; handled outcome accepted by independent oracle", + "cleanup_obligations": "watchdog reports zero adopted survivors/unexpected reaps; source-owned scratch empty; FD census stable" + }, + { + "id": "E05", + "title": "stdout closes, stderr retained", + "expected_independent_contract": "COMPLETED; retain complete stderr, empty stdout", + "method": "native", + "status": "PASS", + "observations": { + "E05-stdout-closed": { + "adopted_reaped": 0, + "cleanup_interruptions": 0, + "cleanup_signal_notifications": 0, + "elapsed_ms": 47, + "interrupted_calls": 0, + "oracle": "accepted", + "signal_notifications": 0, + "state": "COMPLETED", + "status": "PASS" + } + }, + "bounds": "watchdog wall 5s, CPU hard 4s, file/FD/core caps; fixture alarm 3s (descendant 2s)", + "input_and_environment": "fixed argv/scenario in tests/qualify.py; request/observer manifests retained privately", + "first_invariant_to_check": "truthful setup/outcome, exact owned targets, bounded capture and observed reaping", + "red_green_evidence": "negative/failure is injected or scheduled; handled outcome accepted by independent oracle", + "cleanup_obligations": "watchdog reports zero adopted survivors/unexpected reaps; source-owned scratch empty; FD census stable" + }, + { + "id": "E06", + "title": "Output exceeds capture limit", + "expected_independent_contract": "COMPLETED; 256 retained / 262144 observed bytes per stream", + "method": "native finite flood", + "status": "PASS", + "observations": { + "E06-output": { + "adopted_reaped": 0, + "cleanup_interruptions": 0, + "cleanup_signal_notifications": 0, + "elapsed_ms": 32, + "interrupted_calls": 0, + "oracle": "accepted", + "signal_notifications": 0, + "state": "COMPLETED", + "status": "PASS" + } + }, + "bounds": "watchdog wall 5s, CPU hard 4s, file/FD/core caps; fixture alarm 3s (descendant 2s)", + "input_and_environment": "fixed argv/scenario in tests/qualify.py; request/observer manifests retained privately", + "first_invariant_to_check": "truthful setup/outcome, exact owned targets, bounded capture and observed reaping", + "red_green_evidence": "negative/failure is injected or scheduled; handled outcome accepted by independent oracle", + "cleanup_obligations": "watchdog reports zero adopted survivors/unexpected reaps; source-owned scratch empty; FD census stable" + }, + { + "id": "E07", + "title": "Cooperating descendant", + "expected_independent_contract": "TIMED_OUT; one descendant reaped; no survivor", + "method": "native", + "status": "PASS", + "observations": { + "E07-tree": { + "adopted_reaped": 1, + "cleanup_interruptions": 0, + "cleanup_signal_notifications": 0, + "elapsed_ms": 112, + "interrupted_calls": 0, + "oracle": "accepted", + "signal_notifications": 0, + "state": "TIMED_OUT", + "status": "PASS" + } + }, + "bounds": "watchdog wall 5s, CPU hard 4s, file/FD/core caps; fixture alarm 3s (descendant 2s)", + "input_and_environment": "fixed argv/scenario in tests/qualify.py; request/observer manifests retained privately", + "first_invariant_to_check": "truthful setup/outcome, exact owned targets, bounded capture and observed reaping", + "red_green_evidence": "negative/failure is injected or scheduled; handled outcome accepted by independent oracle", + "cleanup_obligations": "watchdog reports zero adopted survivors/unexpected reaps; source-owned scratch empty; FD census stable" + }, + { + "id": "E08", + "title": "Descendant outlives leader", + "expected_independent_contract": "COMPLETED leader; adopted descendant reaped; group cleanup limited", + "method": "native", + "status": "PASS", + "observations": { + "E08-orphan": { + "adopted_reaped": 1, + "cleanup_interruptions": 0, + "cleanup_signal_notifications": 0, + "elapsed_ms": 32, + "interrupted_calls": 0, + "oracle": "accepted", + "signal_notifications": 0, + "state": "COMPLETED", + "status": "PASS" + } + }, + "bounds": "watchdog wall 5s, CPU hard 4s, file/FD/core caps; fixture alarm 3s (descendant 2s)", + "input_and_environment": "fixed argv/scenario in tests/qualify.py; request/observer manifests retained privately", + "first_invariant_to_check": "truthful setup/outcome, exact owned targets, bounded capture and observed reaping", + "red_green_evidence": "negative/failure is injected or scheduled; handled outcome accepted by independent oracle", + "cleanup_obligations": "watchdog reports zero adopted survivors/unexpected reaps; source-owned scratch empty; FD census stable" + }, + { + "id": "E09", + "title": "Descendant changes session/group", + "expected_independent_contract": "native NOT_RUN; no secondary containment; strict refusal retained", + "method": "static/mock only", + "status": "NOT_RUN", + "observations": { + "E09-detached": { + "reason": "no verified delegated secondary containment; no detached fixture supplied", + "static_control": "strict-tree refuses before fork; process-group limit documented", + "status": "NOT_RUN" + } + }, + "bounds": "watchdog wall 5s, CPU hard 4s, file/FD/core caps; fixture alarm 3s (descendant 2s)", + "input_and_environment": "fixed argv/scenario in tests/qualify.py; request/observer manifests retained privately", + "first_invariant_to_check": "truthful setup/outcome, exact owned targets, bounded capture and observed reaping", + "red_green_evidence": "negative/failure is injected or scheduled; handled outcome accepted by independent oracle", + "cleanup_obligations": "Native detached case NOT_RUN; no detached process created. Strict refusal control has separate observed cleanup." + }, + { + "id": "E10", + "title": "Resource failure", + "expected_independent_contract": "SIGXCPU / ENOMEM / SIGXFSZ / EMFILE / SIGABRT with CORE=0; no artifact leak", + "method": "native bounded", + "status": "PASS", + "observations": { + "E10-cpu": { + "adopted_reaped": 0, + "cleanup_interruptions": 0, + "cleanup_signal_notifications": 0, + "elapsed_ms": 1033, + "interrupted_calls": 0, + "oracle": "accepted", + "signal_notifications": 0, + "state": "SIGNALED", + "status": "PASS" + }, + "E10-memory": { + "adopted_reaped": 0, + "cleanup_interruptions": 0, + "cleanup_signal_notifications": 0, + "elapsed_ms": 32, + "interrupted_calls": 0, + "oracle": "accepted", + "signal_notifications": 0, + "state": "COMPLETED", + "status": "PASS" + }, + "E10-fsize": { + "adopted_reaped": 0, + "cleanup_interruptions": 0, + "cleanup_signal_notifications": 0, + "elapsed_ms": 36, + "interrupted_calls": 0, + "oracle": "accepted", + "signal_notifications": 0, + "state": "SIGNALED", + "status": "PASS" + }, + "E10-nofile": { + "adopted_reaped": 0, + "cleanup_interruptions": 0, + "cleanup_signal_notifications": 0, + "elapsed_ms": 32, + "interrupted_calls": 0, + "oracle": "accepted", + "signal_notifications": 0, + "state": "COMPLETED", + "status": "PASS" + }, + "core-disabled": { + "adopted_reaped": 0, + "cleanup_interruptions": 0, + "cleanup_signal_notifications": 0, + "elapsed_ms": 36, + "interrupted_calls": 0, + "oracle": "accepted", + "signal_notifications": 0, + "state": "SIGNALED", + "status": "PASS" + } + }, + "bounds": "watchdog wall 5s, CPU hard 4s, file/FD/core caps; fixture alarm 3s (descendant 2s)", + "input_and_environment": "fixed argv/scenario in tests/qualify.py; request/observer manifests retained privately", + "first_invariant_to_check": "truthful setup/outcome, exact owned targets, bounded capture and observed reaping", + "red_green_evidence": "negative/failure is injected or scheduled; handled outcome accepted by independent oracle", + "cleanup_obligations": "watchdog reports zero adopted survivors/unexpected reaps; source-owned scratch empty; FD census stable" + }, + { + "id": "E11", + "title": "Landlock unavailable/denied", + "expected_independent_contract": "POLICY_UNAVAILABLE before fork; actual kernel availability remains separate", + "method": "availability fault injection", + "status": "PASS", + "observations": { + "E11-unavailable": { + "adopted_reaped": 0, + "cleanup_interruptions": 0, + "cleanup_signal_notifications": 0, + "elapsed_ms": 0, + "interrupted_calls": 0, + "oracle": "accepted", + "signal_notifications": 0, + "state": "POLICY_UNAVAILABLE", + "status": "PASS" + } + }, + "bounds": "watchdog wall 5s, CPU hard 4s, file/FD/core caps; fixture alarm 3s (descendant 2s)", + "input_and_environment": "fixed argv/scenario in tests/qualify.py; request/observer manifests retained privately", + "first_invariant_to_check": "truthful setup/outcome, exact owned targets, bounded capture and observed reaping", + "red_green_evidence": "negative/failure is injected or scheduled; handled outcome accepted by independent oracle", + "cleanup_obligations": "watchdog reports zero adopted survivors/unexpected reaps; source-owned scratch empty; FD census stable" + }, + { + "id": "E12", + "title": "seccomp setup failure", + "expected_independent_contract": "invalid BPF rejected natively; LAUNCH_FAILED before fixture exec", + "method": "native setup-failure injection", + "status": "PASS", + "observations": { + "E12-seccomp-failure": { + "adopted_reaped": 0, + "cleanup_interruptions": 0, + "cleanup_signal_notifications": 0, + "elapsed_ms": 31, + "interrupted_calls": 0, + "oracle": "accepted", + "signal_notifications": 0, + "state": "LAUNCH_FAILED", + "status": "PASS" + } + }, + "bounds": "watchdog wall 5s, CPU hard 4s, file/FD/core caps; fixture alarm 3s (descendant 2s)", + "input_and_environment": "fixed argv/scenario in tests/qualify.py; request/observer manifests retained privately", + "first_invariant_to_check": "truthful setup/outcome, exact owned targets, bounded capture and observed reaping", + "red_green_evidence": "negative/failure is injected or scheduled; handled outcome accepted by independent oracle", + "cleanup_obligations": "watchdog reports zero adopted survivors/unexpected reaps; source-owned scratch empty; FD census stable" + }, + { + "id": "E13", + "title": "Strict unsupported control", + "expected_independent_contract": "POLICY_UNAVAILABLE; no spawned process", + "method": "native admission refusal", + "status": "PASS", + "observations": { + "E13-strict": { + "adopted_reaped": 0, + "cleanup_interruptions": 0, + "cleanup_signal_notifications": 0, + "elapsed_ms": 0, + "interrupted_calls": 0, + "oracle": "accepted", + "signal_notifications": 0, + "state": "POLICY_UNAVAILABLE", + "status": "PASS" + } + }, + "bounds": "watchdog wall 5s, CPU hard 4s, file/FD/core caps; fixture alarm 3s (descendant 2s)", + "input_and_environment": "fixed argv/scenario in tests/qualify.py; request/observer manifests retained privately", + "first_invariant_to_check": "truthful setup/outcome, exact owned targets, bounded capture and observed reaping", + "red_green_evidence": "negative/failure is injected or scheduled; handled outcome accepted by independent oracle", + "cleanup_obligations": "watchdog reports zero adopted survivors/unexpected reaps; source-owned scratch empty; FD census stable" + }, + { + "id": "E14", + "title": "Cleanup interrupted", + "expected_independent_contract": "TIMED_OUT; signals/EINTR during cleanup handled; no survivor", + "method": "native notifications and native blocking-wait EINTR control", + "status": "PASS", + "observations": { + "E14-interrupted": { + "adopted_reaped": 0, + "cleanup_interruptions": 15, + "cleanup_signal_notifications": 15, + "elapsed_ms": 63, + "interrupted_calls": 30, + "oracle": "accepted", + "signal_notifications": 31, + "state": "TIMED_OUT", + "status": "PASS" + } + }, + "bounds": "watchdog wall 5s, CPU hard 4s, file/FD/core caps; fixture alarm 3s (descendant 2s)", + "input_and_environment": "fixed argv/scenario in tests/qualify.py; request/observer manifests retained privately", + "first_invariant_to_check": "truthful setup/outcome, exact owned targets, bounded capture and observed reaping", + "red_green_evidence": "negative/failure is injected or scheduled; handled outcome accepted by independent oracle", + "cleanup_obligations": "watchdog reports zero adopted survivors/unexpected reaps; source-owned scratch empty; FD census stable" + }, + { + "id": "E15", + "title": "Unexpected inherited descriptor", + "expected_independent_contract": "CONTAINMENT_UNCONFIRMED; do not report normal boundary success", + "method": "injected native FD inheritance", + "status": "PASS", + "observations": { + "E15-inherit": { + "adopted_reaped": 0, + "cleanup_interruptions": 0, + "cleanup_signal_notifications": 0, + "elapsed_ms": 32, + "interrupted_calls": 0, + "oracle": "accepted", + "signal_notifications": 0, + "state": "CONTAINMENT_UNCONFIRMED", + "status": "PASS" + } + }, + "bounds": "watchdog wall 5s, CPU hard 4s, file/FD/core caps; fixture alarm 3s (descendant 2s)", + "input_and_environment": "fixed argv/scenario in tests/qualify.py; request/observer manifests retained privately", + "first_invariant_to_check": "truthful setup/outcome, exact owned targets, bounded capture and observed reaping", + "red_green_evidence": "negative/failure is injected or scheduled; handled outcome accepted by independent oracle", + "cleanup_obligations": "watchdog reports zero adopted survivors/unexpected reaps; source-owned scratch empty; FD census stable" + }, + { + "id": "E16", + "title": "Stale process identity", + "expected_independent_contract": "reaped own-child pidfd returns ESRCH; no numeric retarget", + "method": "native owned-child probe", + "status": "PASS", + "observations": { + "E16-stale": { + "evidence": "own reaped child pidfd returns ESRCH; no numeric retarget", + "status": "PASS" + } + }, + "bounds": "watchdog wall 5s, CPU hard 4s, file/FD/core caps; fixture alarm 3s (descendant 2s)", + "input_and_environment": "fixed argv/scenario in tests/qualify.py; request/observer manifests retained privately", + "first_invariant_to_check": "truthful setup/outcome, exact owned targets, bounded capture and observed reaping", + "red_green_evidence": "negative/failure is injected or scheduled; handled outcome accepted by independent oracle", + "cleanup_obligations": "watchdog reports zero adopted survivors/unexpected reaps; source-owned scratch empty; FD census stable" + }, + { + "id": "E17", + "title": "False enforcement report", + "expected_independent_contract": "independent oracle rejects corrupted feature claims", + "method": "oracle negative controls", + "status": "PASS", + "observations": { + "E17-false-enforcement": { + "evidence": "oracle rejects cgroup/seccomp/Landlock flag corruptions", + "status": "PASS" + } + }, + "bounds": "watchdog wall 5s, CPU hard 4s, file/FD/core caps; fixture alarm 3s (descendant 2s)", + "input_and_environment": "fixed argv/scenario in tests/qualify.py; request/observer manifests retained privately", + "first_invariant_to_check": "truthful setup/outcome, exact owned targets, bounded capture and observed reaping", + "red_green_evidence": "Independent reviews reproduced false pre-spawn and failed-setup enforcement claims. Regressions now reject these alongside the original controls; 21 total corruption controls.", + "cleanup_obligations": "watchdog reports zero adopted survivors/unexpected reaps; source-owned scratch empty; FD census stable" + }, + { + "id": "E18", + "title": "Report/exit contradiction", + "expected_independent_contract": "independent oracle rejects reason/code contradictions", + "method": "oracle negative controls", + "status": "PASS", + "observations": { + "E18-report-status": { + "evidence": "oracle rejects termination/exit corruptions", + "status": "PASS" + } + }, + "bounds": "watchdog wall 5s, CPU hard 4s, file/FD/core caps; fixture alarm 3s (descendant 2s)", + "input_and_environment": "fixed argv/scenario in tests/qualify.py; request/observer manifests retained privately", + "first_invariant_to_check": "truthful setup/outcome, exact owned targets, bounded capture and observed reaping", + "red_green_evidence": "negative/failure is injected or scheduled; handled outcome accepted by independent oracle", + "cleanup_obligations": "watchdog reports zero adopted survivors/unexpected reaps; source-owned scratch empty; FD census stable" + }, + { + "id": "E19", + "title": "Parent SIGTERM", + "expected_independent_contract": "CANCELLED; direct child and owned descendants reaped", + "method": "native exact-pidfd signal", + "status": "PASS", + "observations": { + "E19-parent-term": { + "adopted_reaped": 0, + "cleanup_interruptions": 0, + "cleanup_signal_notifications": 0, + "elapsed_ms": 46, + "interrupted_calls": 1, + "oracle": "accepted", + "signal_notifications": 0, + "state": "CANCELLED", + "status": "PASS" + } + }, + "bounds": "watchdog wall 5s, CPU hard 4s, file/FD/core caps; fixture alarm 3s (descendant 2s)", + "input_and_environment": "fixed argv/scenario in tests/qualify.py; request/observer manifests retained privately", + "first_invariant_to_check": "truthful setup/outcome, exact owned targets, bounded capture and observed reaping", + "red_green_evidence": "negative/failure is injected or scheduled; handled outcome accepted by independent oracle", + "cleanup_obligations": "watchdog reports zero adopted survivors/unexpected reaps; source-owned scratch empty; FD census stable" + }, + { + "id": "E20", + "title": "Failed run temporary artifacts", + "expected_independent_contract": "EXITED_NONZERO 42; owned scratch empty after cleanup", + "method": "native", + "status": "PASS", + "observations": { + "E20-artifact": { + "adopted_reaped": 0, + "cleanup_interruptions": 0, + "cleanup_signal_notifications": 0, + "elapsed_ms": 32, + "interrupted_calls": 0, + "oracle": "accepted", + "signal_notifications": 0, + "state": "EXITED_NONZERO", + "status": "PASS" + } + }, + "bounds": "watchdog wall 5s, CPU hard 4s, file/FD/core caps; fixture alarm 3s (descendant 2s)", + "input_and_environment": "fixed argv/scenario in tests/qualify.py; request/observer manifests retained privately", + "first_invariant_to_check": "truthful setup/outcome, exact owned targets, bounded capture and observed reaping", + "red_green_evidence": "negative/failure is injected or scheduled; handled outcome accepted by independent oracle", + "cleanup_obligations": "watchdog reports zero adopted survivors/unexpected reaps; source-owned scratch empty; FD census stable" + } + ] +} diff --git a/projects/linux-exec-boundary/FAILURE-REPORT.md b/projects/linux-exec-boundary/FAILURE-REPORT.md new file mode 100644 index 0000000..aef4a3b --- /dev/null +++ b/projects/linux-exec-boundary/FAILURE-REPORT.md @@ -0,0 +1,42 @@ +# Failure observations and decisions + +The first native independent check rejected a direct-profile report because +the fixture inherited seccomp mode 2. Mode 2 alone could not identify this +request's filter. The contract and oracle now record inherited mode separately +from request-local installation. An unfiltered getppid control and the filtered +EPERM result distinguish the controlled experiment. + +A Landlock setup succeeded but execveat failed with EACCES. The selected +executable also needed READ_FILE admission. The correction grants that right +to its already-open descriptor, not a new pathname lookup. The final native +fixture executes and verifies all six allowed/denied access classes. + +A one-millisecond deadline could expire during spawn/session setup, followed +by cancellation of the launch gate. Classifying the consequent setup error +as the primary launch failure discarded the earlier timeout decision. The +classifier now retains the stop decision and records the actual wait status +separately. Fixed early/boundary/late schedules cover this race. + +Closed pipes originally remained in poll, permitting HUP-ready busy loops. +EOF descriptors are now removed from the poll set. Native cleanup control +records actual EINTR and signal notifications during the grace period. + +These are development RED/GREEN observations, not vulnerabilities asserted +against the four existing labs. Initial failed receipts and final native +manifest/observer/oracle records remain in the private qualification package. +GCC, Clang and ASan/UBSan each reached green on the bounded scenarios. + +The cgroup delegation experiment stopped at EACCES. No host configuration was +changed. An escaped descendant was not executed to fill a matrix cell: +E09 remains NOT_RUN and strict whole-tree admission is refused before fork. + +Independent review exposed false enforcement claims accepted by the oracle on +pre-spawn refusals and failed child setup. The checker now requires a spawned +child for request-local controls and setup confirmation for child-installed +controls. Native qualification rejects 21 corruptions across normal, refused +and failed-setup records; original observations remain accepted. + +Review also found that a basic Landlock probe on ABI 1/2 did not establish +the full profile requiring ABI 3. Qualification now gates that profile on ABI +3 or later; synthetic boundary checks cover ABI 1, 2, 3 and 7 and unavailable +support. Older-ABI behavior was checked by synthetic gating, not a second kernel. diff --git a/projects/linux-exec-boundary/INDEPENDENT-AUDIT.md b/projects/linux-exec-boundary/INDEPENDENT-AUDIT.md new file mode 100644 index 0000000..9547c0d --- /dev/null +++ b/projects/linux-exec-boundary/INDEPENDENT-AUDIT.md @@ -0,0 +1,17 @@ +# Independent audit + +Decision: CAPABILITY_LIMITED. Independent source and native reviews accepted the final candidate with no unresolved material finding. + +Frozen candidate: `37841e5daff18b50f69f99bd102d8502c8cd7acf2ad8737c281a5bab5592d40b`. Base: `2c14763864b3d26bc7a67774e22f9ba78f9329c6`. SOURCE-IDENTITY.json records the 21 reviewed files; this audit summary and the identity file itself are excluded to avoid a circular digest. + +Source review independently verified all files, replayed 36 valid manifest/observer pairs, rejected 12 false-enforcement mutations, retained the valid setup-before-exec-failure control and checked five ABI/status selection controls. + +Native review independently copied and built the frozen executable/test source using GCC on non-root WSL2/ext4. CTest passed 2/2 in 3.03 seconds; 39 result entries passed, E09 remained NOT_RUN and all 21 negative controls rejected. Entry counts include schedules and are not distinct semantic coverage counts. The final evidence-only refresh preserved all executed source bytes. + +Earlier reviews found false enforcement acceptance on refused/failed setup and an incorrect ABI1/2 full-profile test gate. RED receipts and original review reports remain retained. Both findings were repaired and independently rechecked. + +Builder GCC, Clang and ASan/UBSan CTest each passed 2/2 after repair. Six C++ translation units passed Clang static analysis without diagnostics; the dependency-free formatting gate passed. clang-format and clang-tidy were unavailable. The ASan fixture is intentionally uninstrumented to preserve its bounded address-space experiment. + +Limits: no cgroup delegation (EACCES), no native detached-descendant experiment, no arbitrary-tree guarantee. Strict whole-tree execution refuses before fork. Landlock ABI7 was exercised; ABI1/2 selection was checked synthetically. Trusted fixtures and exclusive supervisor child ownership are prerequisites; no general-purpose sandbox or binary authentication is claimed. + +The self-contained private handoff retains review-source/REPORT-revision3.md, review-native/REPORT-revision3.md, their independent scripts, command receipts, mutants and execution evidence. All prior review failures remain preserved. diff --git a/projects/linux-exec-boundary/NEGATIVE-CONTROLS.json b/projects/linux-exec-boundary/NEGATIVE-CONTROLS.json new file mode 100644 index 0000000..2b34c68 --- /dev/null +++ b/projects/linux-exec-boundary/NEGATIVE-CONTROLS.json @@ -0,0 +1,148 @@ +{ + "schema": "exec-boundary.negative-controls.v1", + "valid_control": "Native normal, pre-spawn refusal and failed-setup originals accepted; 21 independent corruptions rejected.", + "corruptions": { + "capability_flag": { + "errors": [ + "unsupported cgroup claim" + ], + "rejected": true + }, + "cleanup_status": { + "errors": [ + "direct child not reaped" + ], + "rejected": true + }, + "exit_code": { + "errors": [ + "observed exit status", + "completed classification" + ], + "rejected": true + }, + "false_landlock": { + "errors": [ + "Landlock profile/ABI", + "Landlock rights", + "silent Landlock downgrade" + ], + "rejected": true + }, + "false_seccomp": { + "errors": [ + "silent seccomp downgrade" + ], + "rejected": true + }, + "resource_state": { + "errors": [ + "resource limit differs: open_files" + ], + "rejected": true + }, + "run_identity": { + "errors": [ + "run identity" + ], + "rejected": true + }, + "termination_reason": { + "errors": [ + "independent expected outcome", + "CLI/report contradiction", + "signaled classification" + ], + "rejected": true + }, + "truncation_metadata": { + "errors": [ + "output truncation metadata" + ], + "rejected": true + }, + "unconfirmed_landlock": { + "errors": [ + "unconfirmed setup control: landlock", + "Landlock profile/ABI", + "Landlock rights" + ], + "rejected": true + }, + "unconfirmed_limits_setup": { + "errors": [ + "unconfirmed setup control: limits_setup" + ], + "rejected": true + }, + "unconfirmed_no_new_privs": { + "errors": [ + "unconfirmed setup control: no_new_privs" + ], + "rejected": true + }, + "unconfirmed_seccomp": { + "errors": [ + "unconfirmed setup control: seccomp" + ], + "rejected": true + }, + "unspawned_cgroup": { + "errors": [ + "unsupported cgroup claim", + "unspawned control claimed: cgroup" + ], + "rejected": true + }, + "unspawned_group_confirmed": { + "errors": [ + "unspawned control claimed: group_confirmed" + ], + "rejected": true + }, + "unspawned_landlock": { + "errors": [ + "unconfirmed setup control: landlock", + "unspawned control claimed: landlock", + "Landlock profile/ABI", + "Landlock rights" + ], + "rejected": true + }, + "unspawned_limits_setup": { + "errors": [ + "unconfirmed setup control: limits_setup", + "unspawned control claimed: limits_setup" + ], + "rejected": true + }, + "unspawned_no_new_privs": { + "errors": [ + "unconfirmed setup control: no_new_privs", + "unspawned control claimed: no_new_privs" + ], + "rejected": true + }, + "unspawned_pidfd": { + "errors": [ + "unspawned control claimed: pidfd" + ], + "rejected": true + }, + "unspawned_seccomp": { + "errors": [ + "unconfirmed setup control: seccomp", + "unspawned control claimed: seccomp" + ], + "rejected": true + }, + "unspawned_waitid_pidfd": { + "errors": [ + "unspawned control claimed: waitid_pidfd" + ], + "rejected": true + } + }, + "all_rejected": true, + "scope": "checker consistency, not independent proof of kernel sandboxing" +} diff --git a/projects/linux-exec-boundary/README.md b/projects/linux-exec-boundary/README.md new file mode 100644 index 0000000..fdb8ffd --- /dev/null +++ b/projects/linux-exec-boundary/README.md @@ -0,0 +1,61 @@ +# Linux execution boundary lab + +A C++20 Linux supervisor for bounded, locally compiled synthetic fixtures. It +examines process identity, launch failure, deadlines, cancellation, resource +limits and truthful enforcement reports. This is an empirical lab, not a +production sandbox or a container runtime. + +The standalone CLI only launches its CMake-built sibling fixture. There is no +arbitrary executable or shell-command option. Use a non-root Linux user and a +native filesystem. Do not run the build under sudo. + +## Build and reproduce + +~~~sh +cmake -S . -B build -DCMAKE_BUILD_TYPE=Debug +cmake --build build -j2 +ctest --test-dir build --output-on-failure + +build/capability_probe +build/exec_boundary --scenario normal --run-id example-run > execution.json +python3 tools/render_report.py execution.json execution.md +~~~ + +Python 3.11+ supplies the dependency-free watchdog and independent manifest +checker. No downloaded executable, third-party C++ library, cloud account or +root CI change is needed. [REPRODUCTION.md](REPRODUCTION.md) includes Clang, +sanitizer and evidence commands. + +## Boundaries and results + +- Forked children wait on a parent gate until their pidfd and dedicated + session/process group are established. An open executable descriptor pins the + inode for execveat; it does not authenticate executable contents. +- A post-exec fixture witness is required for a successful launch claim. Pipe + EOF alone is insufficient. +- Deadlines use steady_clock. Output retention is bounded separately for stdout + and stderr; excess bytes are counted and discarded while deadline checks + continue. This is a capture limit, not a producer-output quota. +- The unreaped leader pins process-group identity during cleanup. Direct + children and adopted cooperating descendants are reaped. A process group does + not contain descendants that create another session. +- Landlock and a narrow seccomp getppid denial experiment are installed before + exec, or execution fails. They do not form a general-purpose sandbox. +- Strict whole-tree requests return POLICY_UNAVAILABLE before fork. The tested + WSL2 environment has no writable cgroup delegation. The native detached- + descendant case is NOT_RUN without verified secondary containment. + +CLI exit codes 0-8 map to COMPLETED, EXITED_NONZERO, SIGNALED, TIMED_OUT, +CANCELLED, LAUNCH_FAILED, POLICY_UNAVAILABLE, CONTAINMENT_UNCONFIRMED and +SUPERVISOR_ERROR. The child exit code/signal is reported separately. + +The checked-in JSON files describe the qualification environment, not another +kernel. Re-run the capability probe. Dynamic PIDs, elapsed times and race +winners are not deterministic identities. + +See [EXECUTION-CONTRACT.md](EXECUTION-CONTRACT.md), +[THREAT-MODEL.md](THREAT-MODEL.md), [CAPABILITY-MATRIX.json](CAPABILITY-MATRIX.json), +[FAILURE-MATRIX.json](FAILURE-MATRIX.json) and +[INDEPENDENT-AUDIT.md](INDEPENDENT-AUDIT.md). + +The four saved-evidence Python mini-labs remain independent and unchanged. diff --git a/projects/linux-exec-boundary/REPRODUCTION.md b/projects/linux-exec-boundary/REPRODUCTION.md new file mode 100644 index 0000000..8600907 --- /dev/null +++ b/projects/linux-exec-boundary/REPRODUCTION.md @@ -0,0 +1,65 @@ +# Native reproduction + +Use a non-root Linux/WSL2 user with GCC or Clang, CMake, CTest and Python 3.11+. +Build/test on native ext4, rather than a Windows-backed mount. No packages, +downloaded fixtures, sudo, cloud service or root CI change is required. + +~~~sh +cmake -S . -B build-gcc -DCMAKE_CXX_COMPILER=g++ -DCMAKE_BUILD_TYPE=Debug +cmake --build build-gcc -j2 +ctest --test-dir build-gcc --output-on-failure + +cmake -S . -B build-clang -DCMAKE_CXX_COMPILER=clang++ -DCMAKE_BUILD_TYPE=Debug +cmake --build build-clang -j2 +ctest --test-dir build-clang --output-on-failure + +cmake -S . -B build-asan -DCMAKE_CXX_COMPILER=clang++ -DBOUNDARY_SANITIZERS=ON +cmake --build build-asan -j2 +ctest --test-dir build-asan --output-on-failure + +python3 tools/check_style.py +~~~ + +The fixture is deliberately not ASan-instrumented so its small virtual-address +limit remains meaningful. Supervisor/core code is instrumented with ASan/UBSan. +There are no external pytest dependencies in this lab. + +CTest runs an in-process repeated-lifecycle/FD/reaping/parent-policy check and +a separate bounded watchdog. The latter creates a fresh evidence directory +(PID suffix on repetition), probes real capability support, runs fixed +scenarios/schedules, checks an independent oracle and rejects corrupted +manifests. Unsupported tests remain UNSUPPORTED/NOT_RUN, never native passes. + +~~~sh +build-gcc/capability_probe > capabilities.json +build-gcc/exec_boundary --scenario normal --run-id manual-run > execution.json +python3 tools/render_report.py execution.json execution.md + +python3 tests/qualify.py --supervisor "$PWD/build-gcc/exec_boundary" \ + --probe "$PWD/build-gcc/capability_probe" --output "$PWD/build-gcc/another-evidence" + +python3 tests/oracle.py build-gcc/evidence/normal/stdout.json \ + build-gcc/evidence/normal/observer.json build-gcc/evidence/capabilities.json +~~~ + +Use the actual evidence directory printed by the watchdog if a previous one +already exists. The manual CLI is for finite approved scenarios; run CPU, +resource-failure and tree experiments through the watchdog. It supplies a +second wall/resource bound and exclusive descendant census. Never add or run +an escaped-descendant case without verified secondary containment. + +Clang's built-in static analyzer was run on each C++ translation unit with +C++20, the include directory and CMake-built fixture definition. clang-tidy +and clang-format were unavailable; neither was installed or reported passing. +The dependency-free style gate checks LF, final newline, tabs/NUL and trailing +whitespace, supplemented by source review and warnings-as-errors. + +Qualification environment: WSL2 x86_64, kernel +6.18.54.1-microsoft-standard-WSL2, native ext4, GCC 13.3.0, Clang 18.1.3, +CMake/CTest 3.28.3, Python 3.12.3. Landlock ABI 7 was tested; cgroup delegation +is unavailable (EACCES). These observations do not cover all WSL2 kernels. + +The four older Python labs were separately tested using the existing Windows +runtime: 143 passed and 12 native-condition skips. Tracked source, schema, +fixtures, goldens and root workflow remain unchanged. Those skips are not new +native qualification evidence for the old projects. diff --git a/projects/linux-exec-boundary/SOURCE-IDENTITY.json b/projects/linux-exec-boundary/SOURCE-IDENTITY.json new file mode 100644 index 0000000..916db2d --- /dev/null +++ b/projects/linux-exec-boundary/SOURCE-IDENTITY.json @@ -0,0 +1,28 @@ +{ + "base": "2c14763864b3d26bc7a67774e22f9ba78f9329c6", + "candidate_sha256": "37841e5daff18b50f69f99bd102d8502c8cd7acf2ad8737c281a5bab5592d40b", + "scope": "All candidate files except this identity file and independent audit report; no bytecode included.", + "files": { + "CAPABILITY-MATRIX.json": "0af3c472cd6c9ce46ce6395a6eb1d70b78a7547727f86ae377933e58c74ca417", + "CMakeLists.txt": "ed6d3c3a6b1f40d1b43d22991acec38ea1797a0c94a48b1193b00df52304edd4", + "EXECUTION-CONTRACT.md": "f437ba554a3f2f5868b280ee06ff9a64509f22539bdffd89007cec7fa1996e66", + "EXECUTION-MANIFEST.json": "b54d98a0b2e949f4879d4b65d96e05572e484a82ea54765fd4d1669d836cdccb", + "FAILURE-MATRIX.json": "0901f004aea3882f601ddc735790337284cf500d7967994dcd31cc2ac7d09ddb", + "FAILURE-REPORT.md": "a8e5ce2c944ffd088ad04de4035c1fb42206b666bf65c149b92c0ef15354996a", + "fixtures/fixture.cpp": "787be45b277a94312aed9e4362f7bc4f0cd5156b513eec6bcbd3a6e8e9216452", + "include/boundary.hpp": "764c8292a76f6fad4ca566fe78ab8160be87189c2154f65136bc4716877aaecd", + "NEGATIVE-CONTROLS.json": "dac0248cb69ae37a069b13bc60cf984249d9912f5b0a86e509ee787f587a380c", + "README.md": "2e40d055bc824b9af13a877adb11eccc539a0e1b1c0d8b7436965ee9a135abd1", + "REPRODUCTION.md": "1b5dbdca97f5a4702cabe21a82f7eec452337d2f39f76a3f724e93c75a3fcc6b", + "src/main.cpp": "247e3ff9f5aae1c559d0ef84cee31f288445fd364d85f322dfd59a967ff8bd2f", + "src/manifest.cpp": "ae8aae516ba2e1aa63b87cd8ef64ef689c08bf094c56a2b1e63c87c4cbe9e0a0", + "src/supervisor.cpp": "7a4b351f14f89ecc301e48b2934f779b87c164b81fefadb2ff9160bb06947f9f", + "tests/lifecycle.cpp": "c69b4e79e2f22f8664796b8f931e29fad5d7d6f4ef1b2ef0906e3feb5f65f09c", + "tests/oracle.py": "1a9a7fa56ab5c60b7ee02f597db8deadbe822162a92237e15de3ae593d64b128", + "tests/qualify.py": "672515be6c5077562cc3dc731bc97a0959aa44a64541c2fa24d423d63d9ded1e", + "THREAT-MODEL.md": "bf97b1edecb981aac7c32a7af7f97b36f8188100a0700d2efae0909e90097c69", + "tools/capability_probe.cpp": "0056443bb31e3bd27a1c33bb3026a6e8009de26a172ae2ff1d84df92c440ba71", + "tools/check_style.py": "0ef88dd0510eae0053a6c005b44c84944fdd2c691a174f8b24e6778abe370404", + "tools/render_report.py": "3954e146cc067264a30ab8ef21f2a665aa2e32297431a486cd2199e89c3ed2c6" + } +} diff --git a/projects/linux-exec-boundary/THREAT-MODEL.md b/projects/linux-exec-boundary/THREAT-MODEL.md new file mode 100644 index 0000000..db488da --- /dev/null +++ b/projects/linux-exec-boundary/THREAT-MODEL.md @@ -0,0 +1,88 @@ +# Threat and failure model + +## Scope + +The lab controls a locally built, trusted synthetic executable with bounded +adversarial exit, signal, output, allocation and process-tree scenarios. The +supervisor and Linux kernel are trusted. No arbitrary downloaded binary, shell +command, credentials, external target or unrelated process is an input. +Non-root execution is mandatory. + +The assets are exact child ownership, correct signal targeting, bounded parent +resources, stable report structure and truthful evidence. This is empirical +qualification in one environment, not a production sandbox claim. + +## Boundaries + +1. Request admission fixes executable/argv/environment and bounded parameters. +2. Open executable descriptor and gated fork separate creation from exec. +3. Pidfd waiting and unreaped leader identity prevent accidental retargeting. +4. A dedicated session scopes cooperating-descendant group signals. +5. Child-local limits/no_new_privs and optional policy precede fixture exec. +6. Nonblocking bounded capture avoids pipe deadlock and unbounded retention. +7. Subreaping and an independent watchdog check direct/adopted cleanup. +8. Independent manifest checks reject claims stronger than the observations. + +The exclusive supervisor checks for existing children without consuming status. +Concurrent spawning/reaping by another thread violates the API precondition. +No unrelated child may be reaped or terminated to make a report look clean. + +## Optional mechanisms + +Landlock handles READ_FILE, READ_DIR, WRITE_FILE, MAKE_REG, REMOVE_FILE and +TRUNCATE (ABI >=3). The exact executable and loader/libraries receive read-only +access; one owned allowed subtree permits those six classes. Another owned +subtree supplies denied-operation controls. Unhandled rights are unrestricted. +Pre-opened descriptors, filesystem metadata and non-filesystem objects have +their own kernel semantics. This is not filesystem-wide confidentiality. + +The seccomp demonstration validates architecture and denies only getppid with +EPERM; getpid remains allowed across exec. It does not restrict every dangerous +syscall or establish network isolation. + +Limits are per process, not aggregate tree CPU/memory quotas. AS limits virtual +address space, not RSS; FSIZE limits regular files, not pipes. The capture cap +limits retained evidence, not generated output. RLIMIT_NPROC is deliberately +not used because it can affect unrelated same-UID activity. + +Current cgroup v2 delegation is not writable. No cgroup root setting, service, +namespace, sudo action or host policy changes. There is no active whole-tree +backend. strict-tree refuses before fork. Group signals cannot establish +containment of a descendant that leaves the session/group. The native +detached-descendant case is NOT_RUN; no such fixture is supplied without a +verified secondary containment envelope. + +## External fixture safety + +The watchdog signals only its own supervisor pidfd and its own adopted children. +It applies CPU, file-size, descriptor and core limits, a wall timeout, and AS +bounds for ordinary builds. ASan requires large shadow virtual mappings; that +parent AS bound is omitted in the sanitizer run. CPU/wall/file/FD bounds and +ASan's 128 MiB RSS watchdog remain. The non-instrumented fixture still receives +and reports its child AS limit. + +Fixtures arm a three-second alarm; their single cooperating descendant arms a +two-second alarm. They fork at most once, generate a fixed finite output flood +and touch only owned paths. The memory control attempts one untouched fixed +256 MiB mapping. No UID-wide process-limit or uncontrolled stress experiment. + +## Rejected alternatives + +| Alternative | Decision evidence | +| --- | --- | +| Use an unheld numeric PID after reaping | PID may be reused; retain pidfd and leader ownership | +| Call group kill whole-tree isolation | Descendants can leave; always deny the stronger claim | +| Downgrade a strict unsupported profile | Misrepresents enforcement; refuse before fork | +| Infer exec from pipe EOF | Pre-exec death also closes it; require post-exec witness | +| Infer installed seccomp from mode 2 | Environment already inherits mode 2; distinguish inheritance | +| Retain every output byte | Noisy child can exhaust memory; cap retention and count dropped bytes | +| Provision cgroups or use sudo | Outside scope; retain unavailable delegation | +| Execute detached descendants for coverage | No verified secondary containment; retain NOT_RUN | + +API references: Linux +[pidfd man-pages](https://www.kernel.org/pub/linux/docs/man-pages/book/man-pages-6.16.pdf), +[Landlock](https://docs.kernel.org/userspace-api/landlock.html) and +[seccomp filters](https://www.kernel.org/doc/html/v5.9/userspace-api/seccomp_filter.html). +Runtime probes, rather than the newest upstream documentation, determine the +active ABI. Holding an inode does not authenticate or prevent all modifications +to its contents. diff --git a/projects/linux-exec-boundary/fixtures/fixture.cpp b/projects/linux-exec-boundary/fixtures/fixture.cpp new file mode 100644 index 0000000..ab54ede --- /dev/null +++ b/projects/linux-exec-boundary/fixtures/fixture.cpp @@ -0,0 +1,193 @@ +#include "boundary.hpp" +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace { +bool text(int descriptor, const char* value) { + std::size_t remaining = std::strlen(value); + while (remaining != 0) { + const ssize_t count = write(descriptor, value, remaining); + if (count < 0 && errno == EINTR) continue; + if (count <= 0) return false; + value += count; + remaining -= static_cast(count); + } + return true; +} +void nap(unsigned milliseconds) { + timespec delay{static_cast(milliseconds / 1000), + static_cast((milliseconds % 1000) * 1000000)}; + while (nanosleep(&delay, &delay) != 0 && errno == EINTR) {} +} +std::uint64_t value(int resource, bool hard = false) { + rlimit limit{}; + if (getrlimit(resource, &limit) != 0) _exit(125); + return hard ? limit.rlim_max : limit.rlim_cur; +} +} + +int main(int argc, char** argv) { + if (argc != 2 || geteuid() == 0) return 124; + alarm(3); + struct stat control{}; + if (fstat(6, &control) != 0 || !S_ISFIFO(control.st_mode)) return 124; + boundary::Wire wire; + wire.kind = 3; + wire.pid = getpid(); + wire.pgid = getpgrp(); + wire.sid = getsid(0); + wire.nnp = prctl(PR_GET_NO_NEW_PRIVS, 0, 0, 0, 0); + wire.seccomp = prctl(PR_GET_SECCOMP, 0, 0, 0, 0); + wire.cpu_soft = value(RLIMIT_CPU); + wire.cpu_hard = value(RLIMIT_CPU, true); + wire.address_space = value(RLIMIT_AS); + wire.file_size = value(RLIMIT_FSIZE); + wire.open_files = value(RLIMIT_NOFILE); + wire.core_size = value(RLIMIT_CORE); + for (int descriptor = 3; descriptor < 64; ++descriptor) { + if (descriptor != 6 && fcntl(descriptor, F_GETFD) >= 0) ++wire.unexpected_fds; + } + if (write(6, &wire, sizeof(wire)) != static_cast(sizeof(wire))) return 125; + close(6); + const std::string mode(argv[1]); + if (mode == "normal" || mode == "immediate") return text(1, "normal\n") ? 0 : 120; + if (mode == "nonzero") return 42; + if (mode == "signal") { raise(SIGUSR2); return 121; } + if (mode == "abnormal") abort(); + if (mode == "sleep" || mode == "short") { nap(mode == "short" ? 20 : 1500); return 0; } + if (mode == "ignore-term") { signal(SIGTERM, SIG_IGN); nap(1500); return 0; } + if (mode == "partial") return text(1, "abc") && text(2, "error") ? 0 : 120; + if (mode == "close-stdout") { + close(1); + nap(15); + return text(2, "stderr-retained\n") ? 0 : 120; + } + if (mode == "broken-pipe") { + int descriptors[2]; + if (pipe(descriptors) != 0) return 122; + close(descriptors[0]); + const char item = 'x'; + write(descriptors[1], &item, 1); + return 123; + } + if (mode == "flood") { + std::array block{}; + block.fill('x'); + for (int index = 0; index < 64; ++index) { + for (int descriptor : {1, 2}) { + std::size_t offset = 0; + while (offset < block.size()) { + const ssize_t count = write(descriptor, block.data() + offset, block.size() - offset); + if (count < 0 && errno == EINTR) continue; + if (count <= 0) return 120; + offset += static_cast(count); + } + } + } + return 0; + } + if (mode == "cpu") { + volatile unsigned long counter = 0; + while (true) counter = counter + 1; + } + if (mode == "memory") { + errno = 0; + void* allocation = mmap(nullptr, 256 * 1024 * 1024, PROT_READ | PROT_WRITE, + MAP_PRIVATE | MAP_ANONYMOUS, -1, 0); + if (allocation == MAP_FAILED && errno == ENOMEM) + return text(1, "allocation-denied-ENOMEM\n") ? 0 : 120; + if (allocation != MAP_FAILED) munmap(allocation, 256 * 1024 * 1024); + return 122; + } + if (mode == "fsize") { + const int file = open("size-test", O_CREAT | O_EXCL | O_WRONLY | O_CLOEXEC, 0600); + if (file < 0) return 122; + std::array bytes{}; + for (int index = 0; index < 4; ++index) write(file, bytes.data(), bytes.size()); + close(file); + return 123; + } + if (mode == "nofile") { + int count = 0; + for (int index = 0; index < 64; ++index) { + const int file = open("/dev/null", O_RDONLY | O_CLOEXEC); + if (file < 0) return errno == EMFILE && count > 0 && text(1, "open-denied-EMFILE\n") ? 0 : 123; + ++count; + } + return 123; + } + if (mode == "artifact-failure") { + const int file = open("temporary-artifact", O_CREAT | O_EXCL | O_WRONLY | O_CLOEXEC, 0600); + if (file < 0) return 122; + text(file, "synthetic only"); + close(file); + return 42; + } + if (mode == "tree" || mode == "orphan") { + const pid_t descendant = fork(); + if (descendant < 0) return 122; + if (descendant == 0) { alarm(2); nap(1200); _exit(0); } + text(1, "cooperating-descendant-started\n"); + if (mode == "orphan") return 0; + nap(1200); + int status = 0; + while (waitpid(descendant, &status, 0) < 0 && errno == EINTR) {} + return 0; + } + if (mode == "seccomp") { + errno = 0; + const long parent = syscall(SYS_getppid); + if (parent == -1 && errno == EPERM && getpid() > 0) + return text(1, "getppid-denied-EPERM;getpid-allowed\n") ? 0 : 120; + if (parent > 0) return text(1, "getppid-allowed\n") ? 0 : 120; + return 123; + } + if (mode == "landlock") { + const int allowed = open("allowed/input", O_RDONLY | O_CLOEXEC); + if (allowed < 0) return 122; + close(allowed); + const int created = open("allowed/new", O_CREAT | O_EXCL | O_WRONLY | O_CLOEXEC, 0600); + if (created < 0) return 122; + text(created, "allowed"); + close(created); + errno = 0; + const int denied_read = open("denied/input", O_RDONLY | O_CLOEXEC); + const bool read_rejected = denied_read < 0 && errno == EACCES; + if (denied_read >= 0) close(denied_read); + errno = 0; + const int denied_write = open("denied/input", O_WRONLY | O_CLOEXEC); + const bool write_rejected = denied_write < 0 && errno == EACCES; + if (denied_write >= 0) close(denied_write); + const int allowed_dir = open("allowed", O_RDONLY | O_DIRECTORY | O_CLOEXEC); + if (allowed_dir < 0) return 122; + close(allowed_dir); + errno = 0; + const int denied_dir = open("denied", O_RDONLY | O_DIRECTORY | O_CLOEXEC); + const bool dir_rejected = denied_dir < 0 && errno == EACCES; + if (denied_dir >= 0) close(denied_dir); + errno = 0; + const int denied_new = open("denied/new", O_CREAT | O_EXCL | O_WRONLY | O_CLOEXEC, 0600); + const bool create_rejected = denied_new < 0 && errno == EACCES; + if (denied_new >= 0) close(denied_new); + if (unlink("allowed/new") != 0 || truncate("allowed/input", 0) != 0) return 122; + errno = 0; + const bool remove_rejected = unlink("denied/input") < 0 && errno == EACCES; + errno = 0; + const bool truncate_rejected = truncate("denied/input", 0) < 0 && errno == EACCES; + return read_rejected && write_rejected && dir_rejected && create_rejected && + remove_rejected && truncate_rejected && + text(1, "read-write-dir-create-remove-truncate:allowed-and-denied\n") ? 0 : 123; + } + return 124; +} diff --git a/projects/linux-exec-boundary/include/boundary.hpp b/projects/linux-exec-boundary/include/boundary.hpp new file mode 100644 index 0000000..771c9a3 --- /dev/null +++ b/projects/linux-exec-boundary/include/boundary.hpp @@ -0,0 +1,80 @@ +#pragma once +#include +#include +#include +#include +#include + +namespace boundary { +inline constexpr std::uint32_t wire_magic = 0x424e4431; +inline constexpr unsigned group_bit = 1, limits_bit = 2, nnp_bit = 4, + landlock_bit = 8, seccomp_bit = 16; +struct Wire { + std::uint32_t magic = wire_magic; + std::uint32_t kind = 0; + std::uint32_t flags = 0; + int error = 0; + int pid = 0; + int pgid = 0, sid = 0; + int unexpected_fds = 0; + int nnp = 0; + int seccomp = 0; + std::uint64_t cpu_soft = 0, cpu_hard = 0, address_space = 0, + file_size = 0, open_files = 0, core_size = 0; +}; +struct ExecutionRequest { + std::string request_id; + std::string run_id; + std::filesystem::path approved_executable; + std::filesystem::path working_directory; + std::vector arguments; + std::vector environment{"LANG=C", "LC_ALL=C"}; + std::string scenario = "normal"; + std::string profile = "direct"; + std::string fault = "none"; + int timeout_ms = 300; + int cancel_after_ms = -1; + std::size_t output_limit = 256; + std::uint64_t cpu_soft = 1, cpu_hard = 2; + std::uint64_t address_space = 128 * 1024 * 1024; + std::uint64_t file_size = 4096, open_files = 32, core_size = 0; +}; +struct Capture { + std::string bytes; + std::uint64_t total = 0; + bool eof = false; +}; +struct ControlSignals { + volatile sig_atomic_t cancellation = 0; + volatile sig_atomic_t notifications = 0; + volatile sig_atomic_t cleanup_active = 0; + volatile sig_atomic_t cleanup_notifications = 0; +}; +struct ExecutionResult { + std::string state = "SUPERVISOR_ERROR"; + std::string stage = "validation"; + int error = 0; + bool spawned = false, pidfd = false, pidfd_wait = false; + bool setup_confirmed = false, exec_observed = false; + unsigned setup_flags = 0; + int landlock_abi = 0; + int inherited_seccomp = 0, inherited_nnp = 0; + bool group_confirmed = false, group_cleanup = false, group_pinned = false; + bool direct_reaped = false, cleanup_complete = false, pipes_closed = false; + bool artifacts_removed = false, containment_unconfirmed = false; + int adopted_reaped = 0, owned_fd_delta = 0, interrupted_calls = 0; + int cleanup_interruptions = 0; + int notifications = 0, cleanup_notifications = 0; + int child_pid = 0, wait_code = 0, wait_status = 0; + int exit_code = -1, term_signal = 0; + bool timed_out = false, cancelled = false, parent_sigterm = false; + bool term_attempted = false, kill_attempted = false; + int term_errno = 0, kill_errno = 0; + std::uint64_t elapsed_ms = 0; + Wire observed{}; + Capture out, err; +}; +ExecutionResult execute(const ExecutionRequest&, ControlSignals* signals); +std::string manifest(const ExecutionRequest&, const ExecutionResult&); +int cli_exit(const std::string& state); +} diff --git a/projects/linux-exec-boundary/src/main.cpp b/projects/linux-exec-boundary/src/main.cpp new file mode 100644 index 0000000..2cb56be --- /dev/null +++ b/projects/linux-exec-boundary/src/main.cpp @@ -0,0 +1,80 @@ +#include "boundary.hpp" +#include +#include +#include +#include +#include +#include +#include + +namespace { +boundary::ControlSignals signals; +void on_signal(int signal) { + if (signal == SIGUSR1) { + signals.notifications = signals.notifications + 1; + if (signals.cleanup_active) signals.cleanup_notifications = signals.cleanup_notifications + 1; + } else signals.cancellation = signal; +} +bool integer(std::string_view text, int& value) { + const auto parsed = std::from_chars(text.data(), text.data() + text.size(), value); + return parsed.ec == std::errc{} && parsed.ptr == text.data() + text.size(); +} +bool member(const std::string& item, std::initializer_list values) { + return std::any_of(values.begin(), values.end(), [&](const char* value) { return item == value; }); +} +} +int main(int argc, char** argv) { + boundary::ExecutionRequest request; + request.request_id = "synthetic-request"; + request.run_id = "synthetic-run"; + request.approved_executable = APPROVED_FIXTURE_PATH; + for (int index = 1; index < argc; index += 2) { + if (index + 1 == argc) { std::cerr << "expected option/value pairs\n"; return 8; } + const std::string key(argv[index]), value(argv[index + 1]); + if (key == "--scenario") request.scenario = value; + else if (key == "--profile") request.profile = value; + else if (key == "--fault") request.fault = value; + else if (key == "--request-id") request.request_id = value; + else if (key == "--run-id") request.run_id = value; + else if (key == "--timeout-ms") { if (!integer(value, request.timeout_ms)) return 8; } + else if (key == "--cancel-after-ms") { if (!integer(value, request.cancel_after_ms)) return 8; } + else if (key == "--output-limit") { + int limit = 0; + if (!integer(value, limit) || limit < 0) return 8; + request.output_limit = static_cast(limit); + } else { std::cerr << "unknown option\n"; return 8; } + } + if (!member(request.scenario, {"normal", "immediate", "nonzero", "signal", "abnormal", "sleep", "short", + "ignore-term", "partial", "close-stdout", "broken-pipe", "flood", "cpu", "memory", "fsize", + "nofile", "artifact-failure", "tree", "orphan", "seccomp", "landlock"}) || + !member(request.fault, {"none", "exec", "pidfd", "exit_before_pidfd", "landlock_unavailable", + "seccomp_setup", "inherit_fd"})) { + std::cerr << "unapproved synthetic scenario or fault\n"; + return 8; + } + if (request.scenario == "fsize") request.file_size = 1024; + request.arguments = {"boundary_fixture", request.scenario}; + struct sigaction action{}; + action.sa_handler = on_signal; + sigemptyset(&action.sa_mask); + for (int signal : {SIGTERM, SIGINT, SIGUSR1}) if (sigaction(signal, &action, nullptr) != 0) return 8; + struct sigaction ignore{}; + ignore.sa_handler = SIG_IGN; + sigemptyset(&ignore.sa_mask); + if (sigaction(SIGPIPE, &ignore, nullptr) != 0) return 8; + const auto temporary_root = std::filesystem::temp_directory_path(); + std::string temporary = (temporary_root / "exec-boundary-run-XXXXXX").string(); + if (mkdtemp(temporary.data()) == nullptr) return 8; + request.working_directory = temporary; + std::error_code error; + std::filesystem::create_directory(request.working_directory / "allowed", error); + std::filesystem::create_directory(request.working_directory / "denied", error); + std::ofstream(request.working_directory / "allowed/input") << "synthetic-allowed"; + std::ofstream(request.working_directory / "denied/input") << "synthetic-denied"; + auto result = boundary::execute(request, &signals); + std::filesystem::remove_all(request.working_directory, error); + result.artifacts_removed = !error && !std::filesystem::exists(request.working_directory); + if (!result.artifacts_removed) result.state = "SUPERVISOR_ERROR"; + std::cout << boundary::manifest(request, result) << '\n'; + return boundary::cli_exit(result.state); +} diff --git a/projects/linux-exec-boundary/src/manifest.cpp b/projects/linux-exec-boundary/src/manifest.cpp new file mode 100644 index 0000000..0f820d6 --- /dev/null +++ b/projects/linux-exec-boundary/src/manifest.cpp @@ -0,0 +1,90 @@ +#include "boundary.hpp" +#include + +namespace boundary { +namespace { +std::string quoted(const std::string& text) { + std::string output = "\""; + constexpr char hex[] = "0123456789abcdef"; + for (unsigned char character : text) { + if (character == '"' || character == '\\') { output += '\\'; output += static_cast(character); } + else if (character < 32) { output += "\\u00"; output += hex[character >> 4]; output += hex[character & 15]; } + else output += static_cast(character); + } + return output + '"'; +} +std::string hex(const std::string& bytes) { + constexpr char alphabet[] = "0123456789abcdef"; + std::string result; + for (unsigned char value : bytes) { result += alphabet[value >> 4]; result += alphabet[value & 15]; } + return result; +} +std::string capture_json(const Capture& capture) { + std::ostringstream stream; + stream << "{\"captured_bytes\":" << capture.bytes.size() << ",\"total_bytes\":" << capture.total + << ",\"truncated\":" << (capture.total > capture.bytes.size() ? "true" : "false") + << ",\"eof_observed\":" << (capture.eof ? "true" : "false") + << ",\"hex\":" << quoted(hex(capture.bytes)) << '}'; + return stream.str(); +} +} +int cli_exit(const std::string& state) { + const std::string states[] = {"COMPLETED", "EXITED_NONZERO", "SIGNALED", "TIMED_OUT", "CANCELLED", + "LAUNCH_FAILED", "POLICY_UNAVAILABLE", "CONTAINMENT_UNCONFIRMED", "SUPERVISOR_ERROR"}; + for (int index = 0; index < 9; ++index) if (states[index] == state) return index; + return 8; +} +std::string manifest(const ExecutionRequest& request, const ExecutionResult& result) { + const bool landlock = (result.setup_flags & landlock_bit) != 0; + const bool seccomp = (result.setup_flags & seccomp_bit) != 0; + std::ostringstream stream; + stream << std::boolalpha; + stream << "{\"schema\":\"exec-boundary.execution.v1\",\"request\":{\"request_id\":" << quoted(request.request_id) + << ",\"run_id\":" << quoted(request.run_id) << ",\"approved_executable\":\"$BUILD/boundary_fixture\"" + << ",\"working_directory\":\"owned-scratch\",\"environment\":[\"LANG=C\",\"LC_ALL=C\"]" + << ",\"argv\":[\"boundary_fixture\"," << quoted(request.scenario) << ']' + << ",\"scenario\":" << quoted(request.scenario) << ",\"profile\":" << quoted(request.profile) + << ",\"injected_fault\":" << quoted(request.fault) << ",\"timeout_ms\":" << request.timeout_ms + << ",\"cancel_after_ms\":" << request.cancel_after_ms << ",\"output_limit\":" << request.output_limit + << ",\"limits\":{\"cpu_soft\":" << request.cpu_soft << ",\"cpu_hard\":" << request.cpu_hard + << ",\"address_space\":" << request.address_space << ",\"file_size\":" << request.file_size + << ",\"open_files\":" << request.open_files << ",\"core_size\":" << request.core_size << "}}," + << "\"setup\":{\"spawned\":" << result.spawned << ",\"confirmed\":" << result.setup_confirmed + << ",\"exec_observed\":" << result.exec_observed << ",\"stage\":" << quoted(result.stage) + << ",\"errno\":" << result.error << "},\"features\":{\"pidfd\":" << result.pidfd + << ",\"waitid_pidfd\":" << result.pidfd_wait << ",\"group_confirmed\":" << result.group_confirmed + << ",\"no_new_privs\":" << ((result.setup_flags & nnp_bit) != 0) + << ",\"limits_setup\":" << ((result.setup_flags & limits_bit) != 0) + << ",\"landlock\":" << landlock << ",\"landlock_abi\":" << result.landlock_abi + << ",\"landlock_rights\":["; + if (landlock) stream << "\"READ_FILE\",\"READ_DIR\",\"WRITE_FILE\",\"MAKE_REG\",\"REMOVE_FILE\",\"TRUNCATE\""; + stream << "],\"seccomp\":" << seccomp << ",\"cgroup\":false},\"outcome\":{\"state\":" << quoted(result.state) + << ",\"exit_code\":" << (result.exit_code < 0 ? "null" : std::to_string(result.exit_code)) + << ",\"signal\":" << (result.term_signal == 0 ? "null" : std::to_string(result.term_signal)) + << ",\"wait_code\":" << result.wait_code << ",\"wait_status\":" << result.wait_status + << ",\"timed_out\":" << result.timed_out << ",\"cancelled\":" << result.cancelled + << ",\"parent_sigterm\":" << result.parent_sigterm << ",\"elapsed_ms\":" << result.elapsed_ms + << "},\"termination\":{\"term_attempted\":" << result.term_attempted + << ",\"kill_attempted\":" << result.kill_attempted << ",\"term_errno\":" << result.term_errno + << ",\"kill_errno\":" << result.kill_errno << "},\"cleanup\":{\"direct_child_reaped\":" << result.direct_reaped + << ",\"adopted_reaped\":" << result.adopted_reaped << ",\"complete\":" << result.cleanup_complete + << ",\"pipes_closed\":" << result.pipes_closed << ",\"artifacts_removed\":" << result.artifacts_removed + << ",\"owned_fd_delta\":" << result.owned_fd_delta << ",\"group_cleanup_attempted\":" << result.group_cleanup + << ",\"group_identity_pinned\":" << result.group_pinned << ",\"whole_tree_confirmed\":false}," + << "\"observed\":{\"child_pid\":" << result.child_pid << ",\"pgid\":" << result.observed.pgid + << ",\"sid\":" << result.observed.sid << ",\"nnp\":" << result.observed.nnp + << ",\"seccomp\":" << result.observed.seccomp << ",\"inherited_seccomp\":" << result.inherited_seccomp + << ",\"inherited_nnp\":" << result.inherited_nnp << ",\"unexpected_fds\":" << result.observed.unexpected_fds + << ",\"cpu_soft\":" << result.observed.cpu_soft << ",\"cpu_hard\":" << result.observed.cpu_hard + << ",\"address_space\":" << result.observed.address_space << ",\"file_size\":" << result.observed.file_size + << ",\"open_files\":" << result.observed.open_files << ",\"core_size\":" << result.observed.core_size + << ",\"interrupted_calls\":" << result.interrupted_calls + << ",\"cleanup_interruptions\":" << result.cleanup_interruptions + << ",\"signal_notifications\":" << result.notifications + << ",\"cleanup_signal_notifications\":" << result.cleanup_notifications << "},\"output\":{\"stdout\":" + << capture_json(result.out) << ",\"stderr\":" << capture_json(result.err) + << "},\"enforcement_profile\":\"direct-child-and-best-effort-process-group\"," + << "\"unsupported\":[\"strict-whole-tree-cgroup\",\"general-purpose-sandbox\"]}"; + return stream.str(); +} +} diff --git a/projects/linux-exec-boundary/src/supervisor.cpp b/projects/linux-exec-boundary/src/supervisor.cpp new file mode 100644 index 0000000..92275ed --- /dev/null +++ b/projects/linux-exec-boundary/src/supervisor.cpp @@ -0,0 +1,500 @@ +#include "boundary.hpp" +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace boundary { +namespace { +using Clock = std::chrono::steady_clock; +using Milliseconds = std::chrono::milliseconds; +class Fd { + public: + explicit Fd(int value = -1) : value_(value) {} + ~Fd() { reset(); } + Fd(const Fd&) = delete; + Fd& operator=(const Fd&) = delete; + int get() const { return value_; } + void reset(int value = -1) { + if (value_ >= 0) close(value_); + value_ = value; + } + private: + int value_; +}; +struct Pipe { + Fd read, write; + bool open() { + int descriptors[2]; + if (pipe2(descriptors, O_CLOEXEC) != 0) return false; + read.reset(descriptors[0]); + write.reset(descriptors[1]); + return true; + } +}; +int fd_count() { + DIR* directory = opendir("/proc/self/fd"); + if (directory == nullptr) return -1; + int count = -1; // Exclude this temporary directory descriptor. + while (const dirent* item = readdir(directory)) { + if (item->d_name[0] != '.') ++count; + } + closedir(directory); + return count; +} +int high_copy(int descriptor) { return fcntl(descriptor, F_DUPFD_CLOEXEC, 10); } +bool nonblocking(int descriptor) { + const int flags = fcntl(descriptor, F_GETFL); + return flags >= 0 && fcntl(descriptor, F_SETFL, flags | O_NONBLOCK) == 0; +} +void send_wire(int descriptor, const Wire& wire) { + ssize_t written; + do { written = write(descriptor, &wire, sizeof(wire)); } + while (written < 0 && errno == EINTR); + if (written != static_cast(sizeof(wire))) _exit(126); +} +[[noreturn]] void child_error(int error) { + Wire wire; + wire.kind = 2; + wire.error = error; + send_wire(4, wire); + _exit(127); +} +bool limit(int resource, std::uint64_t soft, std::uint64_t hard) { + const rlimit value{static_cast(soft), static_cast(hard)}; + return setrlimit(resource, &value) == 0; +} +bool landlock_rule(int ruleset, const char* path, std::uint64_t rights) { + const int descriptor = open(path, O_PATH | O_CLOEXEC); + if (descriptor < 0) return errno == ENOENT; + landlock_path_beneath_attr rule{}; + rule.allowed_access = rights; + rule.parent_fd = descriptor; + const bool ok = syscall(SYS_landlock_add_rule, ruleset, LANDLOCK_RULE_PATH_BENEATH, + &rule, 0) == 0; + const int error = errno; + close(descriptor); + errno = error; + return ok; +} +bool restrict_filesystem() { + constexpr std::uint64_t read = LANDLOCK_ACCESS_FS_READ_FILE | LANDLOCK_ACCESS_FS_READ_DIR; + constexpr std::uint64_t rights = read | LANDLOCK_ACCESS_FS_WRITE_FILE | + LANDLOCK_ACCESS_FS_MAKE_REG | LANDLOCK_ACCESS_FS_REMOVE_FILE | LANDLOCK_ACCESS_FS_TRUNCATE; + landlock_ruleset_attr attributes{}; + attributes.handled_access_fs = rights; + const int rules = static_cast(syscall(SYS_landlock_create_ruleset, + &attributes, sizeof(attributes), 0)); + if (rules < 0) return false; + bool ok = true; + landlock_path_beneath_attr executable{}; + executable.allowed_access = LANDLOCK_ACCESS_FS_READ_FILE; + executable.parent_fd = 3; // The exact already-open executable, not a later path lookup. + ok = syscall(SYS_landlock_add_rule, rules, LANDLOCK_RULE_PATH_BENEATH, &executable, 0) == 0; + for (const char* path : {"/usr", "/lib", "/lib64"}) + ok = ok && landlock_rule(rules, path, read); + ok = ok && landlock_rule(rules, "/etc/ld.so.cache", LANDLOCK_ACCESS_FS_READ_FILE); + ok = ok && landlock_rule(rules, "allowed", rights); + if (ok) ok = syscall(SYS_landlock_restrict_self, rules, 0) == 0; + const int error = errno; + close(rules); + errno = error; + return ok; +} +bool restrict_syscall() { +#if defined(__x86_64__) + constexpr unsigned architecture = AUDIT_ARCH_X86_64; +#elif defined(__aarch64__) + constexpr unsigned architecture = AUDIT_ARCH_AARCH64; +#else + errno = ENOTSUP; + return false; +#endif +#if defined(__x86_64__) || defined(__aarch64__) + sock_filter instructions[] = { + BPF_STMT(BPF_LD | BPF_W | BPF_ABS, 4), + BPF_JUMP(BPF_JMP | BPF_JEQ | BPF_K, architecture, 1, 0), + BPF_STMT(BPF_RET | BPF_K, SECCOMP_RET_KILL_PROCESS), + BPF_STMT(BPF_LD | BPF_W | BPF_ABS, 0), + BPF_JUMP(BPF_JMP | BPF_JEQ | BPF_K, SYS_getppid, 0, 1), + BPF_STMT(BPF_RET | BPF_K, SECCOMP_RET_ERRNO | EPERM), + BPF_STMT(BPF_RET | BPF_K, SECCOMP_RET_ALLOW)}; + sock_fprog program{7, instructions}; + return prctl(PR_SET_SECCOMP, SECCOMP_MODE_FILTER, &program) == 0; +#endif +} +void capture(int descriptor, Capture& output, std::size_t maximum) { + std::array bytes{}; + // A finite read budget prevents a busy writer from starving deadline checks. + for (int pass = 0; pass < 16; ++pass) { + const ssize_t count = read(descriptor, bytes.data(), bytes.size()); + if (count > 0) { + const auto size = static_cast(count); + output.total += size; + output.bytes.append(bytes.data(), std::min(size, maximum - output.bytes.size())); + } else if (count == 0) { output.eof = true; return; } + else if (errno == EINTR) continue; + else if (errno == EAGAIN || errno == EWOULDBLOCK) return; + else { output.eof = true; return; } + } +} +bool valid_id(const std::string& value) { + return !value.empty() && value.size() <= 64 && + std::all_of(value.begin(), value.end(), [](unsigned char item) { + return (item >= 'a' && item <= 'z') || (item >= 'A' && item <= 'Z') || + (item >= '0' && item <= '9') || item == '-' || item == '_'; + }); +} +bool valid(const ExecutionRequest& request) { + return valid_id(request.request_id) && valid_id(request.run_id) && + request.timeout_ms >= 1 && request.timeout_ms <= 3000 && + request.cancel_after_ms >= -1 && request.cancel_after_ms <= 3000 && + request.output_limit <= 16384 && request.open_files >= 16 && request.open_files <= 64 && + request.core_size == 0 && request.cpu_soft == 1 && request.cpu_hard == 2 && + request.address_space >= 32 * 1024 * 1024 && request.address_space <= 256 * 1024 * 1024 && + request.file_size <= 1024 * 1024 && request.arguments.size() == 2 && + request.arguments[0] == "boundary_fixture" && request.arguments[1] == request.scenario && + request.environment == std::vector{"LANG=C", "LC_ALL=C"}; +} +} + +ExecutionResult execute(const ExecutionRequest& request, ControlSignals* signals) { + ExecutionResult result; + result.inherited_seccomp = prctl(PR_GET_SECCOMP, 0, 0, 0, 0); + result.inherited_nnp = prctl(PR_GET_NO_NEW_PRIVS, 0, 0, 0, 0); + const int initial_fds = fd_count(); + const auto started = Clock::now(); + const auto deadline = started + Milliseconds(request.timeout_ms); + auto finish = [&] { + if (signals != nullptr) { + result.notifications = signals->notifications; + result.cleanup_notifications = signals->cleanup_notifications; + signals->cleanup_active = 0; + } + result.elapsed_ms = static_cast( + std::chrono::duration_cast(Clock::now() - started).count()); + const int final_fds = fd_count(); + result.owned_fd_delta = initial_fds >= 0 && final_fds >= 0 ? final_fds - initial_fds : -1; + if (result.owned_fd_delta != 0) { + result.state = "SUPERVISOR_ERROR"; + result.stage = "descriptor_accounting"; + } + return result; + }; + if (!valid(request) || geteuid() == 0) { result.error = EINVAL; return finish(); } + struct sigaction child_action{}; + siginfo_t existing_child{}; + errno = 0; + const bool no_existing_children = + waitid(P_ALL, 0, &existing_child, WEXITED | WNOHANG | WNOWAIT) < 0 && errno == ECHILD; + if (sigaction(SIGCHLD, nullptr, &child_action) != 0 || child_action.sa_handler == SIG_IGN || + (child_action.sa_flags & SA_NOCLDWAIT) != 0 || !no_existing_children) { + result.stage = "exclusive_child_ownership_required"; + result.error = EBUSY; + return finish(); + } + if (request.profile == "strict-tree" || request.fault == "landlock_unavailable") { + result.state = "POLICY_UNAVAILABLE"; + result.stage = request.profile == "strict-tree" ? "no_delegated_cgroup_backend" : "landlock_unavailable_injected"; + result.cleanup_complete = true; + result.pipes_closed = true; + return finish(); + } + if (request.profile != "direct" && request.profile != "landlock" && request.profile != "seccomp") { + result.state = "POLICY_UNAVAILABLE"; + result.stage = "unsupported_profile"; + result.cleanup_complete = true; + result.pipes_closed = true; + return finish(); + } + result.landlock_abi = static_cast(syscall(SYS_landlock_create_ruleset, nullptr, 0, + LANDLOCK_CREATE_RULESET_VERSION)); + if (result.landlock_abi < 0) result.landlock_abi = 0; + if (request.profile == "landlock" && result.landlock_abi < 3) { + result.state = "POLICY_UNAVAILABLE"; + result.stage = "landlock_abi_requires_3"; + result.cleanup_complete = true; + result.pipes_closed = true; + return finish(); + } + int old_subreaper = 0; + if (prctl(PR_GET_CHILD_SUBREAPER, &old_subreaper) != 0 || old_subreaper != 0 || + prctl(PR_SET_CHILD_SUBREAPER, 1) != 0) { + result.stage = "exclusive_subreaper_required"; + result.error = errno; + return finish(); + } + { + Pipe output, errors, status, gate; + Fd executable(open(request.approved_executable.c_str(), O_PATH | O_CLOEXEC)); + Fd null_input(open("/dev/null", O_RDONLY | O_CLOEXEC)); + if (executable.get() < 0 || null_input.get() < 0 || !output.open() || !errors.open() || + !status.open() || !gate.open()) { + result.error = errno; + result.stage = "parent_descriptors"; + } else { + Fd exec_copy(high_copy(executable.get())), error_copy(high_copy(status.write.get())), + gate_copy(high_copy(gate.read.get())), stdout_copy(high_copy(output.write.get())), + stderr_copy(high_copy(errors.write.get())), stdin_copy(high_copy(null_input.get())); + const bool copies = exec_copy.get() >= 0 && error_copy.get() >= 0 && gate_copy.get() >= 0 && + stdout_copy.get() >= 0 && stderr_copy.get() >= 0 && stdin_copy.get() >= 0; + if (!copies || !nonblocking(output.read.get()) || !nonblocking(errors.read.get()) || + !nonblocking(status.read.get())) { + result.error = errno; + result.stage = "parent_pipe_setup"; + } else { + std::array argv{const_cast(request.arguments[0].c_str()), + const_cast(request.arguments[1].c_str()), nullptr}; + std::array environment{const_cast(request.environment[0].c_str()), + const_cast(request.environment[1].c_str()), nullptr}; + const bool fail_early = request.fault == "exit_before_pidfd"; + const bool fail_seccomp = request.fault == "seccomp_setup"; + const bool leak_fd = request.fault == "inherit_fd"; + const bool fail_exec = request.fault == "exec"; + const pid_t child = fork(); + if (child == 0) { + if (fail_early) _exit(0); + if (dup3(exec_copy.get(), 3, O_CLOEXEC) < 0 || + dup3(error_copy.get(), 4, O_CLOEXEC) < 0 || + dup3(gate_copy.get(), 5, O_CLOEXEC) < 0 || + dup2(error_copy.get(), 6) < 0 || dup2(stdout_copy.get(), 1) < 0 || + dup2(stderr_copy.get(), 2) < 0 || dup2(stdin_copy.get(), 0) < 0) _exit(126); + if (leak_fd && dup2(stdin_copy.get(), 9) < 0) child_error(errno); + if (syscall(SYS_close_range, leak_fd ? 10U : 7U, UINT_MAX, 0) != 0) child_error(errno); + if (leak_fd) { close(7); close(8); } + signal(SIGPIPE, SIG_DFL); + signal(SIGTERM, SIG_DFL); + signal(SIGUSR1, SIG_DFL); + if (setsid() != getpid()) child_error(errno); + char allowed = 0; + ssize_t count; + do { count = read(5, &allowed, 1); } while (count < 0 && errno == EINTR); + close(5); + if (count != 1 || allowed != 'G') child_error(ECANCELED); + Wire confirmed; + confirmed.kind = 1; + if (chdir(request.working_directory.c_str()) != 0) child_error(errno); + confirmed.flags |= group_bit; + if (!limit(RLIMIT_CPU, request.cpu_soft, request.cpu_hard) || + !limit(RLIMIT_AS, request.address_space, request.address_space) || + !limit(RLIMIT_FSIZE, request.file_size, request.file_size) || + !limit(RLIMIT_NOFILE, request.open_files, request.open_files) || + !limit(RLIMIT_CORE, 0, 0)) child_error(errno); + confirmed.flags |= limits_bit; + if (prctl(PR_SET_NO_NEW_PRIVS, 1, 0, 0, 0) != 0) child_error(errno); + confirmed.flags |= nnp_bit; + if (request.profile == "landlock") { + if (!restrict_filesystem()) child_error(errno); + confirmed.flags |= landlock_bit; + } + if (fail_seccomp) { + sock_fprog invalid{0, nullptr}; + const int applied = prctl(PR_SET_SECCOMP, SECCOMP_MODE_FILTER, &invalid); + child_error(applied < 0 ? errno : EPROTO); + } + if (request.profile == "seccomp") { + if (!restrict_syscall()) child_error(errno); + confirmed.flags |= seccomp_bit; + } + send_wire(4, confirmed); + if (fail_exec) syscall(SYS_execveat, -1, "", argv.data(), environment.data(), AT_EMPTY_PATH); + else syscall(SYS_execveat, 3, "", argv.data(), environment.data(), AT_EMPTY_PATH); + child_error(errno); + } + if (child < 0) { result.stage = "fork"; result.error = errno; } + else { + result.spawned = true; + result.child_pid = child; + result.stage = "spawned"; + output.write.reset(); errors.write.reset(); status.write.reset(); gate.read.reset(); + exec_copy.reset(); error_copy.reset(); gate_copy.reset(); stdout_copy.reset(); + stderr_copy.reset(); stdin_copy.reset(); executable.reset(); null_input.reset(); + if (fail_early) { + siginfo_t info{}; + int code; + do { code = waitid(P_PID, static_cast(child), &info, WEXITED | WNOWAIT); } + while (code < 0 && errno == EINTR); + } + Fd pidfd(request.fault == "pidfd" ? -1 : static_cast(syscall(SYS_pidfd_open, child, 0))); + if (pidfd.get() < 0) { + result.state = "LAUNCH_FAILED"; + result.stage = "pidfd_acquisition"; + result.error = request.fault == "pidfd" ? EMFILE : errno; + // Still our unreaped direct child: its numeric PID cannot have been reused. + if (kill(child, SIGKILL) == 0 || errno == ESRCH) result.kill_attempted = true; + int status_value = 0; + const auto recovery_deadline = Clock::now() + Milliseconds(300); + while (Clock::now() < recovery_deadline) { + const pid_t waited = waitpid(child, &status_value, WNOHANG); + if (waited == child) { result.direct_reaped = true; break; } + if (waited < 0 && errno != EINTR) break; + poll(nullptr, 0, 2); + } + result.cleanup_complete = result.direct_reaped; + if (!result.cleanup_complete) result.state = "CONTAINMENT_UNCONFIRMED"; + } else { + result.pidfd = true; + const auto group_deadline = std::min(deadline, Clock::now() + Milliseconds(100)); + while (!fail_early && Clock::now() < group_deadline) { + if (getpgid(child) == child && getsid(child) == child) { + result.group_confirmed = true; + break; + } + pollfd exited{pidfd.get(), POLLIN, 0}; + if (poll(&exited, 1, 1) > 0) break; + } + if (!fail_early && !result.group_confirmed && Clock::now() >= deadline) + result.timed_out = true; + if (result.group_confirmed && !fail_early) { + const char go = 'G'; + if (write(gate.write.get(), &go, 1) != 1) result.error = errno; + } + gate.write.reset(); + bool leader_done = false, status_eof = false, stop = false; + bool status_error = false; + std::array buffer{}; + std::size_t buffered = 0; + auto stop_time = Clock::time_point::max(); + siginfo_t final_info{}; + while (true) { + capture(output.read.get(), result.out, request.output_limit); + capture(errors.read.get(), result.err, request.output_limit); + for (int pass = 0; pass < 4 && !status_eof; ++pass) { + const ssize_t count = read(status.read.get(), buffer.data() + buffered, buffer.size() - buffered); + if (count > 0) { + buffered += static_cast(count); + if (buffered == sizeof(Wire)) { + Wire wire; + std::memcpy(&wire, buffer.data(), sizeof(wire)); + buffered = 0; + if (wire.magic != wire_magic) status_error = true; + else if (wire.kind == 1) { result.setup_confirmed = true; result.setup_flags = wire.flags; } + else if (wire.kind == 2) { result.error = wire.error; status_error = true; } + else if (wire.kind == 3 && wire.pid == child) { + result.exec_observed = true; + result.observed = wire; + if (wire.unexpected_fds != 0) result.containment_unconfirmed = true; + } else status_error = true; + } + } else if (count == 0) { status_eof = true; if (buffered != 0) status_error = true; } + else if (errno == EINTR) { ++result.interrupted_calls; } + else if (errno == EAGAIN || errno == EWOULDBLOCK) break; + else { status_eof = true; status_error = true; } + } + siginfo_t info{}; + const int waited = waitid(P_PIDFD, static_cast(pidfd.get()), &info, WEXITED | WNOHANG | WNOWAIT); + if (waited == 0) result.pidfd_wait = true; + if (waited == 0 && info.si_pid == child) { leader_done = true; final_info = info; } + else if (waited < 0 && errno == EINTR) ++result.interrupted_calls; + else if (waited < 0) { status_error = true; result.error = errno; } + const auto now = Clock::now(); + if (!stop && (leader_done || now >= deadline || + (signals != nullptr && signals->cancellation != 0) || + (request.cancel_after_ms >= 0 && now - started >= Milliseconds(request.cancel_after_ms)) || + status_error || result.containment_unconfirmed || !result.group_confirmed)) { + stop = true; + stop_time = now; + if (signals != nullptr) signals->cleanup_active = 1; + if (!leader_done && now >= deadline) result.timed_out = true; + else if (!leader_done && ((signals != nullptr && signals->cancellation != 0) || + (request.cancel_after_ms >= 0 && now - started >= Milliseconds(request.cancel_after_ms)))) result.cancelled = true; + result.parent_sigterm = signals != nullptr && signals->cancellation == SIGTERM; + const bool bound_group = result.group_confirmed && child > 1 && + child != getpgrp() && getpgid(child) == child && getsid(child) == child; + if (bound_group) { + result.group_pinned = true; + result.group_cleanup = true; + result.term_attempted = true; + if (kill(-child, SIGTERM) != 0 && errno != ESRCH) result.term_errno = errno; + } else { + if (result.group_confirmed) result.containment_unconfirmed = true; + result.term_attempted = true; + if (syscall(SYS_pidfd_send_signal, pidfd.get(), SIGTERM, nullptr, 0) != 0 && errno != ESRCH) result.term_errno = errno; + } + } + if (stop && !result.kill_attempted && now - stop_time >= Milliseconds(30)) { + result.kill_attempted = true; + if (result.group_pinned) { + if (kill(-child, SIGKILL) != 0 && errno != ESRCH) result.kill_errno = errno; + } else if (syscall(SYS_pidfd_send_signal, pidfd.get(), SIGKILL, nullptr, 0) != 0 && errno != ESRCH) result.kill_errno = errno; + } + if (stop && leader_done && result.kill_attempted && result.out.eof && result.err.eof && status_eof) break; + if (stop && now - stop_time >= Milliseconds(300)) { + result.containment_unconfirmed = true; + break; + } + pollfd poll_descriptors[] = { + {result.out.eof ? -1 : output.read.get(), POLLIN, 0}, + {result.err.eof ? -1 : errors.read.get(), POLLIN, 0}, + {status_eof ? -1 : status.read.get(), POLLIN, 0}, + {leader_done ? -1 : pidfd.get(), POLLIN, 0}}; + if (poll(poll_descriptors, 4, 3) < 0 && errno == EINTR) { + ++result.interrupted_calls; + if (stop) ++result.cleanup_interruptions; + } + } + if (!leader_done) { + syscall(SYS_pidfd_send_signal, pidfd.get(), SIGKILL, nullptr, 0); + // WNOHANG below bounds reaping observation even for an uninterruptible task. + } + if (leader_done) { + result.wait_code = final_info.si_code; + result.wait_status = final_info.si_status; + if (final_info.si_code == CLD_EXITED) result.exit_code = final_info.si_status; + else result.term_signal = final_info.si_status; + } + const auto reap_deadline = Clock::now() + Milliseconds(300); + while (Clock::now() < reap_deadline) { + int child_status = 0; + const pid_t reaped = waitpid(-1, &child_status, WNOHANG); + if (reaped == child) result.direct_reaped = true; + else if (reaped > 0) ++result.adopted_reaped; + else if (reaped < 0 && errno == EINTR) { + ++result.interrupted_calls; + ++result.cleanup_interruptions; + continue; + } + else if (reaped < 0 && errno == ECHILD) { result.cleanup_complete = result.direct_reaped; break; } + else poll(nullptr, 0, 2); + } + capture(output.read.get(), result.out, request.output_limit); + capture(errors.read.get(), result.err, request.output_limit); + if (!result.cleanup_complete) result.containment_unconfirmed = true; + if (result.containment_unconfirmed) result.state = "CONTAINMENT_UNCONFIRMED"; + else if (result.timed_out) result.state = "TIMED_OUT"; + else if (result.cancelled) result.state = "CANCELLED"; + else if (status_error) result.state = "LAUNCH_FAILED"; + else if (!result.setup_confirmed || !result.exec_observed) result.state = "LAUNCH_FAILED"; + else if (result.term_signal != 0) result.state = "SIGNALED"; + else if (result.exit_code != 0) result.state = "EXITED_NONZERO"; + else result.state = "COMPLETED"; + result.stage = result.exec_observed ? "fixture_observed_after_exec" : "before_fixture_witness"; + } + } + } + } + } + result.pipes_closed = true; + if (prctl(PR_SET_CHILD_SUBREAPER, old_subreaper) != 0) { + result.state = "SUPERVISOR_ERROR"; + result.stage = "restore_subreaper"; + result.error = errno; + } + return finish(); +} +} diff --git a/projects/linux-exec-boundary/tests/lifecycle.cpp b/projects/linux-exec-boundary/tests/lifecycle.cpp new file mode 100644 index 0000000..064fbcd --- /dev/null +++ b/projects/linux-exec-boundary/tests/lifecycle.cpp @@ -0,0 +1,104 @@ +#include "boundary.hpp" +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace { +int count_fds() { + DIR* directory = opendir("/proc/self/fd"); + if (directory == nullptr) return -1; + int count = -1; + while (const dirent* entry = readdir(directory)) { + if (entry->d_name[0] != '.') ++count; + } + closedir(directory); + return count; +} +void interrupted(int) {} +bool interrupted_wait() { + struct sigaction action{}; + action.sa_handler = interrupted; + sigemptyset(&action.sa_mask); + if (sigaction(SIGALRM, &action, nullptr) != 0) return false; + const pid_t child = fork(); + if (child == 0) { alarm(2); usleep(50000); _exit(0); } + if (child < 0) return false; + const int descriptor = static_cast(syscall(SYS_pidfd_open, child, 0)); + if (descriptor < 0) { int status; waitpid(child, &status, 0); return false; } + itimerval timer{}; + timer.it_value.tv_usec = 5000; + if (setitimer(ITIMER_REAL, &timer, nullptr) != 0) return false; + siginfo_t info{}; + const int first = waitid(P_PIDFD, static_cast(descriptor), &info, WEXITED); + const bool received_eintr = first < 0 && errno == EINTR; + timer = {}; + setitimer(ITIMER_REAL, &timer, nullptr); + int waited = first; + if (received_eintr) { + do { waited = waitid(P_PIDFD, static_cast(descriptor), &info, WEXITED); } + while (waited < 0 && errno == EINTR); + } + close(descriptor); + return received_eintr && waited == 0 && info.si_pid == child && info.si_status == 0; +} +} +int main() { + if (geteuid() == 0) return 2; + signal(SIGPIPE, SIG_IGN); + if (!interrupted_wait()) return 1; + const int before = count_fds(); + const int before_nnp = prctl(PR_GET_NO_NEW_PRIVS, 0, 0, 0, 0); + const int before_seccomp = prctl(PR_GET_SECCOMP, 0, 0, 0, 0); + rlimit before_limits{}; + if (getrlimit(RLIMIT_NOFILE, &before_limits) != 0) return 2; + for (int iteration = 0; iteration < 12; ++iteration) { + boundary::ExecutionRequest request; + request.approved_executable = APPROVED_FIXTURE_PATH; + request.scenario = iteration % 2 == 0 ? "normal" : "partial"; + request.arguments = {"boundary_fixture", request.scenario}; + request.request_id = "repeat"; + request.run_id = "repeat-" + std::to_string(iteration); + std::string scratch = (std::filesystem::temp_directory_path() / "exec-lifecycle-XXXXXX").string(); + if (mkdtemp(scratch.data()) == nullptr) return 2; + request.working_directory = scratch; + const auto result = boundary::execute(request, nullptr); + std::filesystem::remove_all(request.working_directory); + std::filesystem::remove_all(request.working_directory); // Owned artifact cleanup is idempotent. + if (result.state != "COMPLETED" || result.exit_code != 0 || !result.direct_reaped || + !result.cleanup_complete || result.owned_fd_delta != 0 || count_fds() != before) return 1; + int status = 0; + errno = 0; + if (waitpid(-1, &status, WNOHANG) != -1 || errno != ECHILD) return 1; + } + rlimit after_limits{}; + if (getrlimit(RLIMIT_NOFILE, &after_limits) != 0 || + before_limits.rlim_cur != after_limits.rlim_cur || + before_limits.rlim_max != after_limits.rlim_max || + prctl(PR_GET_NO_NEW_PRIVS, 0, 0, 0, 0) != before_nnp || + prctl(PR_GET_SECCOMP, 0, 0, 0, 0) != before_seccomp) return 1; + const pid_t other_child = fork(); + if (other_child == 0) { alarm(2); usleep(10000); _exit(0); } + if (other_child < 0) return 2; + boundary::ExecutionRequest refused; + refused.request_id = "ownership-control"; + refused.run_id = "ownership-control"; + refused.approved_executable = APPROVED_FIXTURE_PATH; + refused.working_directory = std::filesystem::temp_directory_path(); + refused.arguments = {"boundary_fixture", "normal"}; + const auto rejected = boundary::execute(refused, nullptr); + int status = 0; + while (waitpid(other_child, &status, 0) < 0 && errno == EINTR) {} + if (rejected.state != "SUPERVISOR_ERROR" || rejected.spawned || + rejected.stage != "exclusive_child_ownership_required") return 1; + std::cout << "12 repeated runs; stable FD census; ECHILD; parent policy unchanged; " + "native waitid EINTR retried; unrelated-child ownership refused\n"; + return 0; +} diff --git a/projects/linux-exec-boundary/tests/oracle.py b/projects/linux-exec-boundary/tests/oracle.py new file mode 100644 index 0000000..62e98fb --- /dev/null +++ b/projects/linux-exec-boundary/tests/oracle.py @@ -0,0 +1,177 @@ +"""Independent manifest/observer checker. Does not import or invoke a C++ classifier.""" +from __future__ import annotations +import argparse +import copy +import json +from pathlib import Path + +EXIT = {"COMPLETED": 0, "EXITED_NONZERO": 1, "SIGNALED": 2, "TIMED_OUT": 3, + "CANCELLED": 4, "LAUNCH_FAILED": 5, "POLICY_UNAVAILABLE": 6, + "CONTAINMENT_UNCONFIRMED": 7, "SUPERVISOR_ERROR": 8} +LIMITS = ("cpu_soft", "cpu_hard", "address_space", "file_size", "open_files", "core_size") + + +def check(document: dict, observer: dict, capabilities: dict) -> list[str]: + errors = [] + + def require(condition, message): + if not condition: + errors.append(message) + + try: + require(document["schema"] == "exec-boundary.execution.v1", "schema") + require(set(document) == {"schema", "request", "setup", "features", "outcome", + "termination", "cleanup", "observed", "output", + "enforcement_profile", "unsupported"}, "top-level shape") + request, setup = document["request"], document["setup"] + outcome, features = document["outcome"], document["features"] + cleanup, observed = document["cleanup"], document["observed"] + for field in ("no_new_privs", "limits_setup", "landlock", "seccomp"): + if features[field]: + require(setup["confirmed"], "unconfirmed setup control: " + field) + state = outcome["state"] + require(request["run_id"] == observer["run_id"], "run identity") + require(request["request_id"] == observer["request_id"], "request identity") + require(request["scenario"] == observer["scenario"], "scenario identity") + require(request["profile"] == observer["profile"], "requested policy") + require(state in observer["allowed_states"], "independent expected outcome") + require(observer["cli_exit"] == EXIT.get(state), "CLI/report contradiction") + if "expected_child_exit" in observer: + require(outcome["exit_code"] == observer["expected_child_exit"], "expected child exit") + if "expected_child_signal" in observer: + require(outcome["signal"] == observer["expected_child_signal"], "expected child signal") + require(observer["watchdog_fired"] is False, "external watchdog") + require(observer["surviving_adopted"] == 0, "external child census") + require(observer["unexpected_reaped"] == 0, "supervisor failed reaping obligation") + require(cleanup["complete"] and cleanup["pipes_closed"], "cleanup incomplete") + require(cleanup["owned_fd_delta"] == 0, "descriptor leak") + require(cleanup["artifacts_removed"] and observer["scratch_delta"] == [], + "temporary artifacts") + require(cleanup["whole_tree_confirmed"] is False, "unsupported whole-tree claim") + require(features["cgroup"] is False, "unsupported cgroup claim") + if setup["spawned"]: + require(cleanup["direct_child_reaped"], "direct child not reaped") + else: + for field in ("pidfd", "waitid_pidfd", "group_confirmed", "no_new_privs", + "limits_setup", "landlock", "seccomp", "cgroup"): + require(features[field] is False, "unspawned control claimed: " + field) + if cleanup["group_cleanup_attempted"]: + require(cleanup["group_identity_pinned"] and features["group_confirmed"], + "unbound process group targeting") + for field, capability in (("pidfd", "pidfd"), ("waitid_pidfd", "waitid_pidfd"), + ("landlock", "landlock"), ("seccomp", "seccomp")): + if features[field]: + require(capabilities[capability] == "SUPPORTED_AND_TESTED", + "unsupported capability active: " + field) + require(outcome["exit_code"] is None or outcome["signal"] is None, + "exit and signal mutually exclusive") + if outcome["wait_code"] == 1: + require(outcome["exit_code"] == outcome["wait_status"] and + outcome["signal"] is None, "observed exit status") + elif outcome["wait_code"] in (2, 3): + require(outcome["signal"] == outcome["wait_status"] and + outcome["exit_code"] is None, "observed signal status") + if state == "COMPLETED": + require(setup["exec_observed"] and outcome["exit_code"] == 0 and + outcome["signal"] is None and not outcome["timed_out"] and + not outcome["cancelled"], "completed classification") + elif state == "EXITED_NONZERO": + require(setup["exec_observed"] and isinstance(outcome["exit_code"], int) and + outcome["exit_code"] > 0 and outcome["signal"] is None, + "nonzero classification") + elif state == "SIGNALED": + require(setup["exec_observed"] and isinstance(outcome["signal"], int) and + outcome["signal"] > 0 and outcome["exit_code"] is None, + "signaled classification") + elif state == "TIMED_OUT": + require(outcome["timed_out"] and document["termination"]["term_attempted"] and + outcome["elapsed_ms"] >= request["timeout_ms"], "timeout classification") + elif state == "CANCELLED": + require(outcome["cancelled"] and document["termination"]["term_attempted"], + "cancellation classification") + elif state == "POLICY_UNAVAILABLE": + require(not setup["spawned"] and not setup["exec_observed"] and + not setup["confirmed"], "policy refusal launched child") + elif state == "LAUNCH_FAILED": + require(not setup["exec_observed"], "failed launch claims exec witness") + if setup["exec_observed"]: + require(observed["child_pid"] == observed["pgid"] == observed["sid"] and + observed["child_pid"] > 1, "dedicated session/group") + require(setup["confirmed"] and features["pidfd"] and features["waitid_pidfd"], + "exec without setup or exact wait") + require(features["no_new_privs"] and observed["nnp"] == 1, + "no_new_privs enforcement") + require(features["limits_setup"], "resource setup absent") + for field in LIMITS: + require(request["limits"][field] == observed[field], + "resource limit differs: " + field) + require(observed["core_size"] == 0, "core dump limit") + if state != "CONTAINMENT_UNCONFIRMED": + require(observed["unexpected_fds"] == 0, "unexpected inherited descriptor") + expected_mode = 2 if features["seccomp"] else observed["inherited_seccomp"] + require(observed["seccomp"] == expected_mode, + "seccomp installed/inherited/observed mismatch") + if features["landlock"]: + require(request["profile"] == "landlock" and features["landlock_abi"] >= 3, + "Landlock profile/ABI") + require(set(features["landlock_rights"]) == + {"READ_FILE", "READ_DIR", "WRITE_FILE", "MAKE_REG", "REMOVE_FILE", "TRUNCATE"}, + "Landlock rights") + else: + require(features["landlock_rights"] == [], "unenforced Landlock rights") + if setup["exec_observed"] and state in ("COMPLETED", "EXITED_NONZERO", "SIGNALED"): + require(features["landlock"] == (request["profile"] == "landlock"), + "silent Landlock downgrade") + require(features["seccomp"] == (request["profile"] == "seccomp"), + "silent seccomp downgrade") + for channel in ("stdout", "stderr"): + captured = document["output"][channel] + raw = bytes.fromhex(captured["hex"]) + require(len(raw) == captured["captured_bytes"] <= request["output_limit"], + "bounded output " + channel) + require(captured["total_bytes"] >= len(raw), "output byte accounting") + require(captured["truncated"] == (captured["total_bytes"] > len(raw)), + "output truncation metadata") + expected = observer.get("expected_" + channel) + if expected is not None: + expected = expected.encode() + require(raw == expected[:request["output_limit"]], "fixture output " + channel) + require(captured["total_bytes"] == len(expected), "fixture output total") + require(outcome["elapsed_ms"] <= request["timeout_ms"] + 1200, + "bounded cleanup time") + except (KeyError, TypeError, ValueError) as error: + errors.append("malformed contract: " + str(error)) + return errors + + +def corruptions(document): + edits = { + "termination_reason": ("outcome", "state", "SIGNALED"), + "exit_code": ("outcome", "exit_code", 42), + "capability_flag": ("features", "cgroup", True), + "cleanup_status": ("cleanup", "direct_child_reaped", False), + "resource_state": ("observed", "open_files", 999), + "run_identity": ("request", "run_id", "wrong-run"), + "truncation_metadata": ("output", "stdout", None), + "false_seccomp": ("features", "seccomp", True), + "false_landlock": ("features", "landlock", True), + } + for name, (section, field, value) in edits.items(): + changed = copy.deepcopy(document) + if name == "truncation_metadata": + changed[section][field]["truncated"] = not changed[section][field]["truncated"] + else: + changed[section][field] = value + yield name, changed + + +if __name__ == "__main__": + parser = argparse.ArgumentParser() + parser.add_argument("manifest", type=Path) + parser.add_argument("observer", type=Path) + parser.add_argument("capabilities", type=Path) + args = parser.parse_args() + problems = check(*(json.loads(path.read_text()) for path in + (args.manifest, args.observer, args.capabilities))) + print(json.dumps({"accepted": not problems, "errors": problems})) + raise SystemExit(bool(problems)) diff --git a/projects/linux-exec-boundary/tests/qualify.py b/projects/linux-exec-boundary/tests/qualify.py new file mode 100644 index 0000000..b149ca6 --- /dev/null +++ b/projects/linux-exec-boundary/tests/qualify.py @@ -0,0 +1,306 @@ +"""Bounded native scenarios; only this run's own supervisor/fixture children are targeted.""" +from __future__ import annotations +import argparse +import ctypes +import json +import os +from pathlib import Path +import resource +import signal +import subprocess +import time + +from oracle import check, corruptions + + +def landlock_profile_supported(capabilities): + return (capabilities["landlock"] == "SUPPORTED_AND_TESTED" and + capabilities["landlock_abi"] >= 3) + + +def save(path, value): + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(json.dumps(value, indent=2, sort_keys=True) + "\n") + + +def external_limits(sanitized=False): + resource.setrlimit(resource.RLIMIT_CORE, (0, 0)) + resource.setrlimit(resource.RLIMIT_CPU, (3, 4)) + resource.setrlimit(resource.RLIMIT_FSIZE, (2 * 1024 * 1024, 2 * 1024 * 1024)) + resource.setrlimit(resource.RLIMIT_NOFILE, (64, 64)) + if not sanitized: + resource.setrlimit(resource.RLIMIT_AS, (384 * 1024 * 1024, 384 * 1024 * 1024)) + + +def owned_children(): + # Only direct children of this watchdog, never a host-wide process scan. + path = Path(f"/proc/self/task/{os.getpid()}/children") + return [int(value) for value in path.read_text().split()] + + +def signal_exact(descriptor, sig): + try: + signal.pidfd_send_signal(descriptor, sig) + except ProcessLookupError: + pass + + +def census(): + unexpected = 0 + end = time.monotonic() + 0.2 + while time.monotonic() < end: + try: + pid, _ = os.waitpid(-1, os.WNOHANG) + except ChildProcessError: + return unexpected, 0 + if pid: + unexpected += 1 + else: + time.sleep(0.002) + return unexpected, len(owned_children()) + + +def native_case(binary, output, capabilities, label, scenario, allowed, *, + profile="direct", fault="none", timeout=300, cancel=-1, + output_limit=256, send=None, expected_out=None, + expected_err=None, expected_child_exit=None, expected_child_signal=None, + sanitized=False): + case_dir = output / label + scratch = case_dir / "scratch" + scratch.mkdir(parents=True) + request_id, run_id = "request-" + label, "run-" + label + argv = [str(binary), "--scenario", scenario, "--profile", profile, + "--fault", fault, "--request-id", request_id, "--run-id", run_id, + "--timeout-ms", str(timeout), "--cancel-after-ms", str(cancel), + "--output-limit", str(output_limit)] + environment = {"PATH": "/usr/bin:/bin", "LANG": "C", "LC_ALL": "C", + "TMPDIR": str(scratch), + "ASAN_OPTIONS": "allocator_may_return_null=1:detect_leaks=1:" + "quarantine_size_mb=8:hard_rss_limit_mb=128", + "UBSAN_OPTIONS": "halt_on_error=1:print_stacktrace=1"} + before_fds = len(os.listdir("/proc/self/fd")) + started = time.monotonic() + # Give the supervisor an unexpected inherited FD; its child must not inherit it. + sentinel = os.open("/dev/null", os.O_RDONLY) + os.dup2(sentinel, 57, inheritable=True) + os.close(sentinel) + process = subprocess.Popen(argv, stdout=subprocess.PIPE, stderr=subprocess.PIPE, + env=environment, pass_fds=(57,), + preexec_fn=lambda: external_limits(sanitized)) + os.close(57) + descriptor = os.pidfd_open(process.pid) + if send is not None: + ready_deadline = time.monotonic() + 0.4 + while time.monotonic() < ready_deadline and process.poll() is None: + child_path = Path(f"/proc/{process.pid}/task/{process.pid}/children") + try: + if child_path.read_text().strip(): + break + except FileNotFoundError: + break + time.sleep(0.002) + if send == "term": + time.sleep(0.015) + signal_exact(descriptor, signal.SIGTERM) + elif send == "interrupt": + for _ in range(35): + if process.poll() is not None: + break + signal_exact(descriptor, signal.SIGUSR1) + time.sleep(0.002) + fired = False + try: + raw, stderr = process.communicate(timeout=5) + except subprocess.TimeoutExpired: + fired = True + signal_exact(descriptor, signal.SIGKILL) + raw, stderr = process.communicate(timeout=2) + finally: + os.close(descriptor) + unexpected, survivors = census() + # Last-resort recovery targets only unreaped children of this exclusive watchdog. + if survivors: + for pid in owned_children(): + child_fd = os.pidfd_open(pid) + signal_exact(child_fd, signal.SIGKILL) + os.close(child_fd) + for _ in range(100): + try: + pid, _ = os.waitpid(-1, os.WNOHANG) + except ChildProcessError: + break + if not pid: + time.sleep(0.002) + wall = time.monotonic() - started + observer = {"request_id": request_id, "run_id": run_id, "scenario": scenario, + "profile": profile, "allowed_states": allowed, "cli_exit": process.returncode, + "watchdog_fired": fired, "unexpected_reaped": unexpected, + "surviving_adopted": survivors, + "scratch_delta": [p.name for p in scratch.iterdir()], + "wall_seconds": wall, "external_cpu_hard_seconds": 4, + "external_fsize_bytes": 2 * 1024 * 1024, + "external_as_bytes": None if sanitized else 384 * 1024 * 1024, + "asan_rss_watchdog_mb": 128 if sanitized else None} + if expected_out is not None: + observer["expected_stdout"] = expected_out + if expected_err is not None: + observer["expected_stderr"] = expected_err + if expected_child_exit is not None: + observer["expected_child_exit"] = expected_child_exit + if expected_child_signal is not None: + observer["expected_child_signal"] = expected_child_signal + (case_dir / "stdout.json").write_bytes(raw) + (case_dir / "stderr.txt").write_bytes(stderr) + save(case_dir / "observer.json", observer) + assert len(os.listdir("/proc/self/fd")) == before_fds, "watchdog leaked descriptors" + assert not stderr, (label, stderr.decode(errors="replace")[:500]) + document = json.loads(raw) + problems = check(document, observer, capabilities) + save(case_dir / "oracle.json", {"accepted": not problems, "errors": problems}) + assert not problems, (label, problems, document["outcome"]) + assert wall < 5, (label, wall) + return document, observer + + +def main(): + parser = argparse.ArgumentParser() + parser.add_argument("--supervisor", type=Path, required=True) + parser.add_argument("--probe", type=Path, required=True) + parser.add_argument("--output", type=Path, required=True) + parser.add_argument("--sanitized", action="store_true") + args = parser.parse_args() + assert os.geteuid() != 0, "unprivileged execution required" + # A private watchdog subreaper catches any descendant the supervisor failed to reap. + assert ctypes.CDLL(None, use_errno=True).prctl(36, 1, 0, 0, 0) == 0 + output = args.output.resolve() + if output.exists(): + # No deletion: each CTest invocation retains a separate receipt directory. + output = output.with_name(output.name + "-" + str(os.getpid())) + output.mkdir(parents=True) + probe = subprocess.run([str(args.probe)], capture_output=True, timeout=8, + preexec_fn=external_limits) + assert probe.returncode == 0, probe.stderr.decode(errors="replace") + capabilities = json.loads(probe.stdout) + save(output / "capabilities.json", capabilities) + results = {} + documents = {} + + def run(label, scenario, states, **kwargs): + document, observer = native_case(args.supervisor, output, capabilities, + label, scenario, states, + sanitized=args.sanitized, **kwargs) + results[label] = {"status": "PASS", "state": document["outcome"]["state"], + "elapsed_ms": document["outcome"]["elapsed_ms"], + "adopted_reaped": document["cleanup"]["adopted_reaped"], + "interrupted_calls": document["observed"]["interrupted_calls"], + "cleanup_interruptions": document["observed"]["cleanup_interruptions"], + "signal_notifications": document["observed"]["signal_notifications"], + "cleanup_signal_notifications": document["observed"]["cleanup_signal_notifications"], + "oracle": "accepted"} + documents[label] = (document, observer) + return document + + run("normal", "normal", ["COMPLETED"], expected_out="normal\n", expected_err="") + run("nonzero", "nonzero", ["EXITED_NONZERO"], expected_child_exit=42) + run("signal", "signal", ["SIGNALED"], expected_child_signal=signal.SIGUSR2) + run("E01-immediate", "immediate", ["COMPLETED"], expected_out="normal\n") + run("E01-before-pidfd", "normal", ["LAUNCH_FAILED"], fault="exit_before_pidfd") + run("pidfd-failure", "normal", ["LAUNCH_FAILED"], fault="pidfd") + for index, schedule in enumerate((0, 2, 20, 35)): + run("E02-cancel-" + str(index), "short", + ["COMPLETED", "CANCELLED"], cancel=schedule) + for index, deadline in enumerate((1, 20, 40)): + run("E03-timeout-" + str(index), "short", + ["COMPLETED", "TIMED_OUT"], timeout=deadline) + run("E04-exec", "normal", ["LAUNCH_FAILED"], fault="exec") + run("E05-stdout-closed", "close-stdout", ["COMPLETED"], + expected_out="", expected_err="stderr-retained\n") + run("partial", "partial", ["COMPLETED"], expected_out="abc", expected_err="error") + run("broken-pipe", "broken-pipe", ["SIGNALED"], expected_child_signal=signal.SIGPIPE) + run("E06-output", "flood", ["COMPLETED"], timeout=1000, + expected_out="x" * 262144, expected_err="x" * 262144) + run("zero-capture", "normal", ["COMPLETED"], output_limit=0, expected_out="normal\n") + run("E07-tree", "tree", ["TIMED_OUT"], timeout=80, + expected_out="cooperating-descendant-started\n") + assert results["E07-tree"]["adopted_reaped"] == 1 + run("E08-orphan", "orphan", ["COMPLETED"]) + assert results["E08-orphan"]["adopted_reaped"] == 1 + results["E09-detached"] = {"status": "NOT_RUN", + "reason": "no verified delegated secondary containment; no detached fixture supplied", + "static_control": "strict-tree refuses before fork; process-group limit documented"} + run("E10-cpu", "cpu", ["SIGNALED"], timeout=2200, expected_child_signal=signal.SIGXCPU) + run("E10-memory", "memory", ["COMPLETED"], expected_out="allocation-denied-ENOMEM\n") + run("E10-fsize", "fsize", ["SIGNALED"], expected_child_signal=signal.SIGXFSZ) + run("E10-nofile", "nofile", ["COMPLETED"], expected_out="open-denied-EMFILE\n") + run("core-disabled", "abnormal", ["SIGNALED"], expected_child_signal=signal.SIGABRT) + run("E11-unavailable", "normal", ["POLICY_UNAVAILABLE"], fault="landlock_unavailable") + run("E12-seccomp-failure", "normal", ["LAUNCH_FAILED"], profile="seccomp", fault="seccomp_setup") + run("E13-strict", "normal", ["POLICY_UNAVAILABLE"], profile="strict-tree") + run("E14-interrupted", "ignore-term", ["TIMED_OUT"], timeout=30, send="interrupt") + assert results["E14-interrupted"]["signal_notifications"] > 0 + assert results["E14-interrupted"]["cleanup_signal_notifications"] > 0 + run("E15-inherit", "normal", ["CONTAINMENT_UNCONFIRMED"], fault="inherit_fd") + pid = os.fork() + if pid == 0: + signal.alarm(2) + os._exit(0) + pidfd = os.pidfd_open(pid) + os.waitpid(pid, 0) + stale = False + try: + signal.pidfd_send_signal(pidfd, 0) + except ProcessLookupError: + stale = True + os.close(pidfd) + assert stale + results["E16-stale"] = {"status": "PASS", "evidence": "own reaped child pidfd returns ESRCH; no numeric retarget"} + run("E19-parent-term", "sleep", ["CANCELLED"], timeout=500, send="term") + run("E20-artifact", "artifact-failure", ["EXITED_NONZERO"], expected_child_exit=42) + for abi in (1, 2, 3, 7): + assert landlock_profile_supported({"landlock": "SUPPORTED_AND_TESTED", + "landlock_abi": abi}) == (abi >= 3) + assert not landlock_profile_supported({"landlock": "UNAVAILABLE", "landlock_abi": 7}) + if landlock_profile_supported(capabilities): + run("landlock", "landlock", ["COMPLETED"], profile="landlock", + expected_out="read-write-dir-create-remove-truncate:allowed-and-denied\n") + else: + results["landlock"] = {"status": "UNSUPPORTED", "reason": "full profile requires tested Landlock ABI >= 3"} + if capabilities["seccomp"] == "SUPPORTED_AND_TESTED": + run("seccomp", "seccomp", ["COMPLETED"], profile="seccomp", + expected_out="getppid-denied-EPERM;getpid-allowed\n") + run("seccomp-control", "seccomp", ["COMPLETED"], expected_out="getppid-allowed\n") + else: + results["seccomp"] = {"status": "UNSUPPORTED", "reason": "actual capability probe"} + negative = {} + failed, failure_observer = documents["E12-seccomp-failure"] + for field in ("no_new_privs", "limits_setup", "landlock", "seccomp"): + changed = json.loads(json.dumps(failed)) + changed["features"][field] = True + problems = check(changed, failure_observer, capabilities) + negative["unconfirmed_" + field] = {"rejected": bool(problems), "errors": problems} + assert problems, "unconfirmed_" + field + refused, refusal_observer = documents["E13-strict"] + for field in ("pidfd", "waitid_pidfd", "group_confirmed", "no_new_privs", + "limits_setup", "landlock", "seccomp", "cgroup"): + changed = json.loads(json.dumps(refused)) + changed["features"][field] = True + problems = check(changed, refusal_observer, capabilities) + negative["unspawned_" + field] = {"rejected": bool(problems), "errors": problems} + assert problems, "unspawned_" + field + baseline, observer = documents["normal"] + for label, changed in corruptions(baseline): + problems = check(changed, observer, capabilities) + negative[label] = {"rejected": bool(problems), "errors": problems} + assert problems, label + results["E17-false-enforcement"] = {"status": "PASS", "evidence": "oracle rejects cgroup/seccomp/Landlock flag corruptions"} + results["E18-report-status"] = {"status": "PASS", "evidence": "oracle rejects termination/exit corruptions"} + save(output / "negative-controls.json", negative) + save(output / "results.json", results) + print(json.dumps({"passed": sum(row["status"] == "PASS" for row in results.values()), + "not_run": [key for key, row in results.items() if row["status"] != "PASS"], + "negative_controls_rejected": len(negative), + "evidence_directory": str(output)})) + + +if __name__ == "__main__": + main() diff --git a/projects/linux-exec-boundary/tools/capability_probe.cpp b/projects/linux-exec-boundary/tools/capability_probe.cpp new file mode 100644 index 0000000..ee5a4c5 --- /dev/null +++ b/projects/linux-exec-boundary/tools/capability_probe.cpp @@ -0,0 +1,163 @@ +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace { +template bool child_test(Function function) { + const pid_t child = fork(); + if (child == 0) { + alarm(3); + _exit(function() ? 0 : 1); + } + if (child < 0) return false; + int status = 0; + while (waitpid(child, &status, 0) < 0) { + if (errno != EINTR) return false; + } + return WIFEXITED(status) && WEXITSTATUS(status) == 0; +} +const char* status(bool available) { + return available ? "SUPPORTED_AND_TESTED" : "UNAVAILABLE"; +} +} + +int main() { + if (geteuid() == 0) return 2; + utsname kernel{}; + if (uname(&kernel) != 0) return 2; + bool pidfd = false; + bool pidfd_wait = false; + const pid_t child = fork(); + if (child == 0) _exit(0); + if (child < 0) return 2; + const int descriptor = static_cast(syscall(SYS_pidfd_open, child, 0)); + if (descriptor >= 0) { + pidfd = syscall(SYS_pidfd_send_signal, descriptor, 0, nullptr, 0) == 0; + siginfo_t info{}; + int result; + do { + result = waitid(P_PIDFD, static_cast(descriptor), &info, + WEXITED | WNOWAIT); + } while (result < 0 && errno == EINTR); + pidfd_wait = result == 0 && info.si_pid == child && info.si_code == CLD_EXITED; + close(descriptor); + } + int wait_status = 0; + while (waitpid(child, &wait_status, 0) < 0 && errno == EINTR) {} + const bool nnp = child_test([] { + return prctl(PR_SET_NO_NEW_PRIVS, 1, 0, 0, 0) == 0 && + prctl(PR_GET_NO_NEW_PRIVS, 0, 0, 0, 0) == 1; + }); + const bool seccomp = child_test([] { + sock_filter instructions[] = { + BPF_STMT(BPF_LD | BPF_W | BPF_ABS, 0), + BPF_JUMP(BPF_JMP | BPF_JEQ | BPF_K, SYS_getppid, 0, 1), + BPF_STMT(BPF_RET | BPF_K, SECCOMP_RET_ERRNO | EPERM), + BPF_STMT(BPF_RET | BPF_K, SECCOMP_RET_ALLOW)}; + sock_fprog program{4, instructions}; + if (prctl(PR_SET_NO_NEW_PRIVS, 1, 0, 0, 0) != 0 || + prctl(PR_SET_SECCOMP, SECCOMP_MODE_FILTER, &program) != 0) return false; + errno = 0; + return syscall(SYS_getppid) == -1 && errno == EPERM && getpid() > 0; + }); + errno = 0; + const int abi = static_cast(syscall(SYS_landlock_create_ruleset, nullptr, 0, + LANDLOCK_CREATE_RULESET_VERSION)); + const int landlock_errno = abi < 0 ? errno : 0; + char directory[] = "/tmp/exec-boundary-probe-XXXXXX"; + if (mkdtemp(directory) == nullptr) return 2; + const std::filesystem::path scratch(directory); + std::ofstream(scratch / "allowed") << "synthetic"; + std::ofstream(scratch / "denied") << "synthetic"; + const bool landlock = abi > 0 && child_test([&] { + landlock_ruleset_attr attributes{}; + attributes.handled_access_fs = LANDLOCK_ACCESS_FS_READ_FILE; + const int rules = static_cast(syscall(SYS_landlock_create_ruleset, + &attributes, sizeof(attributes), 0)); + if (rules < 0) return false; + const int file = open((scratch / "allowed").c_str(), O_PATH | O_CLOEXEC); + if (file < 0) { close(rules); return false; } + landlock_path_beneath_attr rule{}; + rule.allowed_access = LANDLOCK_ACCESS_FS_READ_FILE; + rule.parent_fd = file; + bool ok = syscall(SYS_landlock_add_rule, rules, LANDLOCK_RULE_PATH_BENEATH, + &rule, 0) == 0 && + prctl(PR_SET_NO_NEW_PRIVS, 1, 0, 0, 0) == 0 && + syscall(SYS_landlock_restrict_self, rules, 0) == 0; + close(file); + close(rules); + if (!ok) return false; + const int allowed = open((scratch / "allowed").c_str(), O_RDONLY | O_CLOEXEC); + if (allowed < 0) return false; + close(allowed); + errno = 0; + const int denied = open((scratch / "denied").c_str(), O_RDONLY | O_CLOEXEC); + const bool rejected = denied < 0 && errno == EACCES; + if (denied >= 0) close(denied); + return rejected; + }); + std::filesystem::remove_all(scratch); + const bool limits = child_test([] { + const int resources[] = {RLIMIT_CPU, RLIMIT_AS, RLIMIT_FSIZE, RLIMIT_NOFILE, RLIMIT_CORE}; + const rlim_t values[] = {2, 256 * 1024 * 1024, 1024 * 1024, 64, 0}; + for (unsigned index = 0; index < 5; ++index) { + const rlimit limit{values[index], values[index]}; + rlimit observed{}; + if (setrlimit(resources[index], &limit) != 0 || + getrlimit(resources[index], &observed) != 0 || + observed.rlim_cur != limit.rlim_cur || observed.rlim_max != limit.rlim_max) + return false; + } + return true; + }); + std::string own_group; + std::ifstream group_stream("/proc/self/cgroup"); + std::string line; + while (std::getline(group_stream, line)) { + if (line.starts_with("0::")) own_group = line.substr(3); + } + const bool cgroup_present = std::filesystem::exists("/sys/fs/cgroup/cgroup.controllers"); + bool writable = false; + int delegation_errno = EACCES; + if (cgroup_present && !own_group.empty() && own_group != "/" && + own_group.find("..") == std::string::npos) { + const auto current = std::filesystem::path("/sys/fs/cgroup") / own_group.substr(1); + const auto test_group = current / ("exec-boundary-probe-" + std::to_string(getpid())); + if (mkdir(test_group.c_str(), 0700) == 0) { + writable = access((test_group / "cgroup.procs").c_str(), W_OK) == 0; + delegation_errno = writable ? 0 : errno; + if (rmdir(test_group.c_str()) != 0) return 2; + } else delegation_errno = errno; + } + std::cout << "{\"schema\":\"exec-boundary.capabilities.v1\",\"kernel\":\"" + << kernel.release << "\",\"architecture\":\"" << kernel.machine + << "\",\"non_root\":true,\"pidfd\":\"" << status(pidfd) + << "\",\"waitid_pidfd\":\"" << status(pidfd_wait) + << "\",\"no_new_privs\":\"" << status(nnp) + << "\",\"seccomp\":\"" << status(seccomp) + << "\",\"landlock\":\"" << status(landlock) + << "\",\"landlock_abi\":" << (abi > 0 ? abi : 0) + << ",\"landlock_probe_errno\":" << landlock_errno + << ",\"resource_limits\":\"" << status(limits) + << "\",\"cgroup_v2_present\":" << (cgroup_present ? "true" : "false") + << ",\"cgroup_delegation\":\"" + << (writable ? "SUPPORTED_NOT_TESTED" : "UNAVAILABLE") + << "\",\"cgroup_delegation_errno\":" << delegation_errno + << ",\"cgroup_membership_and_kill\":\"NOT_PROBED\"}\n"; + return pidfd && pidfd_wait && nnp && limits ? 0 : 1; +} diff --git a/projects/linux-exec-boundary/tools/check_style.py b/projects/linux-exec-boundary/tools/check_style.py new file mode 100644 index 0000000..69f8ff1 --- /dev/null +++ b/projects/linux-exec-boundary/tools/check_style.py @@ -0,0 +1,23 @@ +"""Small dependency-free formatting gate; not a substitute for clang-format.""" +from pathlib import Path +import sys + +root = Path(__file__).resolve().parents[1] +errors = [] +for path in sorted(root.rglob("*")): + if not path.is_file() or any(part.startswith("build") or part in + ("__pycache__", "evidence") for part in path.relative_to(root).parts): + continue + if path.suffix not in (".cpp", ".hpp", ".py", ".md", ".json", ".txt"): + continue + data = path.read_bytes() + name = path.relative_to(root).as_posix() + if b"\r" in data or b"\t" in data or b"\0" in data: + errors.append(name + ": CR, tab or NUL") + if data and not data.endswith(b"\n"): + errors.append(name + ": missing final LF") + for index, line in enumerate(data.splitlines(), 1): + if line.rstrip(b" ") != line: + errors.append(f"{name}:{index}: trailing spaces") +print("\n".join(errors) if errors else "LF, no tabs/NUL, final newline, trailing-whitespace checks passed") +raise SystemExit(bool(errors)) diff --git a/projects/linux-exec-boundary/tools/render_report.py b/projects/linux-exec-boundary/tools/render_report.py new file mode 100644 index 0000000..43bf57b --- /dev/null +++ b/projects/linux-exec-boundary/tools/render_report.py @@ -0,0 +1,46 @@ +"""Render observed manifest fields without inferring stronger containment.""" +import argparse +import json +from pathlib import Path + +parser = argparse.ArgumentParser() +parser.add_argument("manifest", type=Path) +parser.add_argument("report", type=Path) +args = parser.parse_args() +data = json.loads(args.manifest.read_text()) +lines = [ + "# Execution observation", + "", + f"- Request: {data['request']['request_id']}", + f"- Run: {data['request']['run_id']}", + f"- Result: {data['outcome']['state']}", + f"- Child exit: {data['outcome']['exit_code']}; signal: {data['outcome']['signal']}", + f"- Timeout decision: {data['outcome']['timed_out']}", + f"- Cancellation decision: {data['outcome']['cancelled']}", + f"- Post-exec fixture observed: {data['setup']['exec_observed']}", + f"- Direct child reaped: {data['cleanup']['direct_child_reaped']}", + f"- Adopted descendants reaped: {data['cleanup']['adopted_reaped']}", + f"- Owned descriptor delta: {data['cleanup']['owned_fd_delta']}", + f"- Owned artifacts removed: {data['cleanup']['artifacts_removed']}", + f"- Process-group cleanup attempted: {data['cleanup']['group_cleanup_attempted']}", + f"- Whole-tree containment confirmed: {data['cleanup']['whole_tree_confirmed']}", + f"- Unsupported: {', '.join(data['unsupported'])}", + "", + "## Request-local controls confirmed by successful setup", + "", +] +for name in ("pidfd", "waitid_pidfd", "no_new_privs", "limits_setup", "landlock", "seccomp", "cgroup"): + lines.append(f"- {name}: {data['features'][name]}") +lines.extend([ + "", + f"Inherited seccomp mode: {data['observed']['inherited_seccomp']}; " + f"post-exec mode: {data['observed']['seccomp']}.", + "Inherited mode alone does not establish installation of this request's filter.", + "", + "## Bounded capture", + "", +]) +for name, capture in data["output"].items(): + lines.append(f"- {name}: {capture['captured_bytes']} retained / " + f"{capture['total_bytes']} observed bytes; truncated={capture['truncated']}") +args.report.write_text("\n".join(lines) + "\n")