diff --git a/src/openenv/validation/local.py b/src/openenv/validation/local.py index 94526424c..1088cf46b 100644 --- a/src/openenv/validation/local.py +++ b/src/openenv/validation/local.py @@ -96,6 +96,24 @@ def _runtime_environment( return process_env +def _subject_process_kwargs() -> dict[str, Any]: + """Return POSIX identity controls supplied by a trusted isolated runner.""" + raw_uid = os.environ.get("OPENENV_VALIDATION_SUBJECT_UID") + raw_gid = os.environ.get("OPENENV_VALIDATION_SUBJECT_GID") + if raw_uid is None and raw_gid is None: + return {} + if os.name != "posix" or raw_uid is None or raw_gid is None: + raise RuntimeError("The isolated subject identity is incomplete or unsupported") + try: + uid = int(raw_uid) + gid = int(raw_gid) + except ValueError as exc: + raise RuntimeError("The isolated subject identity is malformed") from exc + if uid <= 0 or gid <= 0: + raise RuntimeError("The isolated subject identity must be unprivileged") + return {"user": uid, "group": gid, "extra_groups": ()} + + def _ensure_port_available(port: int) -> None: with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as probe: probe.settimeout(0.25) @@ -283,6 +301,13 @@ def _launch_environment(env_path: Path, timeout_s: float) -> Iterator[str]: base_url = f"http://127.0.0.1:{port}" _ensure_port_available(port) with TemporaryDirectory(prefix="openenv-validation-") as temporary_home: + subject_process_kwargs = _subject_process_kwargs() + if subject_process_kwargs: + os.chown( + temporary_home, + subject_process_kwargs["user"], + subject_process_kwargs["group"], + ) process_env = _runtime_environment( env_path, temporary_home=Path(temporary_home) ) @@ -293,6 +318,7 @@ def _launch_environment(env_path: Path, timeout_s: float) -> Iterator[str]: stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, start_new_session=True, + **subject_process_kwargs, ) try: deadline = time.monotonic() + timeout_s diff --git a/src/openenv/validation/remote.py b/src/openenv/validation/remote.py index 0e3804c7d..f9f65035e 100644 --- a/src/openenv/validation/remote.py +++ b/src/openenv/validation/remote.py @@ -44,7 +44,7 @@ DEFAULT_SANDBOX_FLAVOR = "cpu-basic" _REMOTE_REVISION_ARCHIVE = "/tmp/openenv-revision.tar.gz" _REMOTE_VALIDATOR_ARCHIVE = "/tmp/openenv-validator.tar.gz" -_REMOTE_REPORT = "/workspace/validation-report.json" +_REMOTE_REPORT = "/workspace/trusted/validation-report.json" _REVISION_PATTERN = re.compile(r"(?:[0-9a-f]{40}|[0-9a-f]{64})") _EXCLUDED_PARTS = frozenset( { @@ -195,13 +195,29 @@ def _remote_command( runtime_timeout="$5" environment_root=/workspace/environment validator_root=/workspace/validator -mkdir -p "$environment_root" "$validator_root" || exit 70 +trusted_root=/workspace/trusted +subject_root=/workspace/subject +subject_site="$subject_root/site-packages" +subject_home="$subject_root/home" +subject_uid=10001 +subject_gid=10001 +mkdir -p "$environment_root" "$validator_root" "$trusted_root" || exit 70 +chmod 700 "$trusted_root" || exit 70 tar -xzf "$revision_archive" -C "$environment_root" || exit 70 tar -xzf "$validator_archive" -C "$validator_root" || exit 70 python -m pip install --disable-pip-version-check --no-input "$validator_root" || exit 70 -python -m pip install --disable-pip-version-check --no-input "$environment_root" || exit 71 +chown -R 0:0 "$environment_root" "$validator_root" "$trusted_root" || exit 70 +chmod -R a+rX,go-w "$environment_root" "$validator_root" || exit 70 +mkdir -p "$subject_site" "$subject_home" || exit 70 +chown -R "$subject_uid:$subject_gid" "$subject_root" || exit 70 +setpriv --reuid="$subject_uid" --regid="$subject_gid" --clear-groups \ + env HOME="$subject_home" python -m pip install --disable-pip-version-check \ + --no-input --target "$subject_site" "$environment_root" || exit 71 set +e -PYTHONPATH="$validator_root/src" python -m openenv.cli.__main__ validate \ +OPENENV_VALIDATION_SUBJECT_UID="$subject_uid" \ +OPENENV_VALIDATION_SUBJECT_GID="$subject_gid" \ +PYTHONPATH="$validator_root/src:$subject_site" \ +python -m openenv.cli.__main__ validate \ "$environment_root" --profile "$profile" --json --output "$report_path" \ --timeout "$runtime_timeout" validation_status=$? @@ -575,5 +591,7 @@ def run_remote_validation( ) from exc finally: if sandbox is not None: + with suppress(Exception): + sandbox.kill() with suppress(Exception): sandbox.close() diff --git a/tests/test_validation/test_local.py b/tests/test_validation/test_local.py index 4a9948b98..ba0574c95 100644 --- a/tests/test_validation/test_local.py +++ b/tests/test_validation/test_local.py @@ -13,6 +13,7 @@ from openenv.validation.local import ( _runtime_environment, _server_command, + _subject_process_kwargs, run_local_validation, ) from openenv.validation.models import ( @@ -255,3 +256,19 @@ def test_server_command_honors_manifest_port(tmp_path: Path) -> None: def test_static_profile_rejects_url_in_public_api() -> None: with pytest.raises(ValueError, match="local source"): run_local_validation("https://example.com", profile=ValidationProfile.STATIC) + + +def test_remote_subject_process_drops_to_declared_unprivileged_identity() -> None: + with patch.dict( + os.environ, + { + "OPENENV_VALIDATION_SUBJECT_UID": "10001", + "OPENENV_VALIDATION_SUBJECT_GID": "10002", + }, + clear=False, + ): + assert _subject_process_kwargs() == { + "user": 10001, + "group": 10002, + "extra_groups": (), + } diff --git a/tests/test_validation/test_remote.py b/tests/test_validation/test_remote.py index 13539545e..005c5e2ae 100644 --- a/tests/test_validation/test_remote.py +++ b/tests/test_validation/test_remote.py @@ -187,6 +187,8 @@ def write_download(_remote_path: str, local_path: str | Path) -> None: command_text = " ".join(command) if isinstance(command, list) else command assert "publish" in command_text assert "validation" in command_text + assert "setpriv" in command_text + assert "/workspace/trusted/validation-report.json" in command_text for remote_path in uploads: assert remote_path in command_text sandbox.files.download.assert_called_once() @@ -201,6 +203,7 @@ def write_download(_remote_path: str, local_path: str | Path) -> None: assert report.certified is False assert report.certification_eligible is False assert report.results[0].criterion_id == "source.validation_spec" + sandbox.kill.assert_called_once_with() sandbox.close.assert_called_once_with() @@ -233,6 +236,7 @@ def test_remote_validation_command_failure_is_clean_and_closes_sandbox( assert "failed" in message assert "hf_1234567890abcdef" not in message sandbox.files.download.assert_not_called() + sandbox.kill.assert_called_once_with() sandbox.close.assert_called_once_with() @@ -264,4 +268,5 @@ def write_download(_remote_path: str, local_path: str | Path) -> None: assert "report" in message assert "invalid" in message or "malformed" in message assert "not-json" not in message + sandbox.kill.assert_called_once_with() sandbox.close.assert_called_once_with()