diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index cfeb847..cbc1461 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -23,6 +23,11 @@ jobs: python-version: ${{ matrix.python-version }} cache: pip + - name: Install native ACL tooling + run: | + sudo apt-get update + sudo apt-get install --yes acl + - name: Install WikiBrain run: | python -m pip install --upgrade pip @@ -41,14 +46,57 @@ jobs: python -m compileall -q src tests scripts python -m pip check + release-gates: + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5 + - uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5 + with: + python-version: "3.13" + cache: pip + + - name: Install release gate tools + run: python -m pip install "uv==0.9.21" "ruff==0.15.22" "twine==6.2.0" + + - name: Run lint and locked source checks + run: | + ruff check src tests benchmarks scripts + uv lock --check + python -m compileall -q src tests benchmarks scripts + python scripts/render_benchmark_chart.py --check + python scripts/render_retrieval_quality_chart.py --check + + - name: Build and inspect release artifacts + run: | + uv build + twine check dist/* + + - name: Install and smoke-test the wheel + run: | + python -m venv /tmp/wikibrain-wheel + /tmp/wikibrain-wheel/bin/python -m pip install dist/*.whl + /tmp/wikibrain-wheel/bin/brainctl --version + /tmp/wikibrain-wheel/bin/python -I -c 'from wikibrain.version_policy import _fetch_remote_policy; assert _fetch_remote_policy(child_code="import sys;sys.stdout.buffer.write(b\"{}\")") == b"{}"' + + - name: Install and smoke-test the source distribution + run: | + python -m venv /tmp/wikibrain-sdist + /tmp/wikibrain-sdist/bin/python -m pip install dist/*.tar.gz + /tmp/wikibrain-sdist/bin/brainctl --version + windows: runs-on: windows-latest + strategy: + fail-fast: false + matrix: + python-version: ["3.11", "3.12", "3.13"] steps: - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 - uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5 with: - python-version: "3.13" + python-version: ${{ matrix.python-version }} cache: pip - name: Install WikiBrain @@ -66,6 +114,7 @@ jobs: python -m unittest tests.test_real_wikimap -v - name: Test the native Windows installer + if: matrix.python-version == '3.13' shell: pwsh run: | ./scripts/install-windows.ps1 ` @@ -74,6 +123,7 @@ jobs: -SkipPythonInstall - name: Verify native Windows hooks + if: matrix.python-version == '3.13' shell: pwsh run: | $pipxBin = ( @@ -135,3 +185,52 @@ jobs: run: | python -m compileall -q src tests scripts python -m pip check + + macos: + runs-on: macos-latest + strategy: + fail-fast: false + matrix: + python-version: ["3.11", "3.12", "3.13"] + + steps: + - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 + - uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5 + with: + python-version: ${{ matrix.python-version }} + cache: pip + + - name: Install WikiBrain + run: | + python -m pip install --upgrade pip + python -m pip install . + + - name: Run unit and contract tests + run: python -m unittest discover -s tests -v + + - name: Run the real Wikimap contract + env: + WIKIMAP_BIN: wikimap + run: python -m unittest tests.test_real_wikimap -v + + - name: Check bytecode and dependencies + run: | + python -m compileall -q src tests scripts + python -m pip check + + release-ready: + if: always() + needs: [test, windows, macos, release-gates] + runs-on: ubuntu-latest + steps: + - name: Require every release gate + env: + TEST_RESULT: ${{ needs.test.result }} + WINDOWS_RESULT: ${{ needs.windows.result }} + MACOS_RESULT: ${{ needs.macos.result }} + RELEASE_GATES_RESULT: ${{ needs.release-gates.result }} + run: | + test "$TEST_RESULT" = success + test "$WINDOWS_RESULT" = success + test "$MACOS_RESULT" = success + test "$RELEASE_GATES_RESULT" = success diff --git a/CHANGELOG.md b/CHANGELOG.md index 80b9997..83caf48 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,26 @@ The project follows [Semantic Versioning](https://semver.org/). ## [Unreleased] +## [0.1.8] - 2026-07-23 + +### Security + +- Bound official release-policy retrieval by a monotonic 2.0-second request budget + plus a 0.5-second native cleanup reserve in a directly managed subprocess. The + worker also has a self-deadline, and timeout cleanup verifies OS-native + releases descriptors/handles only after OS-confirmed process death, and redirects are + rejected before urllib can contact any target while retaining the socket timeout and + response-size cap. +- Validate cached policy files before reading them: POSIX caches must be non-symlink + regular files owned by the current user, free of extended ACLs, and not group/other + writable; Windows opens the cache with a non-following kernel handle and validates + the same handle's regular-file/reparse state, final configured-home containment, + owner SID, and DACL. +- Reject policy timestamps before the schema-v1 epoch or more than five minutes in + the future, and reject rollback below the last accepted `updated_at`. Cache schema + v2 preserves that rollback floor across negative-cache entries and system-clock + regressions while reading and migrating existing schema-v1 caches. + ## [0.1.7] - 2026-07-22 ### Added diff --git a/README.ja.md b/README.ja.md index f1d0133..1ff27ac 100644 --- a/README.ja.md +++ b/README.ja.md @@ -297,7 +297,7 @@ brainctl setup && brainctl doctor インストールした場合は次のように更新します。 ```powershell -pipx install --force "git+https://github.com/hungrytech/wikibrain.git@v0.1.7" +pipx install --force "git+https://github.com/hungrytech/wikibrain.git@v0.1.8" brainctl setup brainctl doctor ``` @@ -334,7 +334,7 @@ AI が提示した計画と権限要求を確認してから進めてくださ ```powershell $installer = Join-Path $env:TEMP "install-wikibrain.ps1" Invoke-WebRequest ` - "https://raw.githubusercontent.com/hungrytech/wikibrain/v0.1.7/scripts/install-windows.ps1" ` + "https://raw.githubusercontent.com/hungrytech/wikibrain/v0.1.8/scripts/install-windows.ps1" ` -OutFile $installer Get-Content $installer powershell.exe -NoProfile -ExecutionPolicy Bypass ` diff --git a/README.ko.md b/README.ko.md index 9840960..194e06a 100644 --- a/README.ko.md +++ b/README.ko.md @@ -332,7 +332,7 @@ brainctl setup && brainctl doctor 갱신합니다. ```powershell -pipx install --force "git+https://github.com/hungrytech/wikibrain.git@v0.1.7" +pipx install --force "git+https://github.com/hungrytech/wikibrain.git@v0.1.8" brainctl setup brainctl doctor ``` @@ -372,7 +372,7 @@ AI가 제시한 계획과 권한 요청을 확인한 뒤 진행하세요. 직접 ```powershell $installer = Join-Path $env:TEMP "install-wikibrain.ps1" Invoke-WebRequest ` - "https://raw.githubusercontent.com/hungrytech/wikibrain/v0.1.7/scripts/install-windows.ps1" ` + "https://raw.githubusercontent.com/hungrytech/wikibrain/v0.1.8/scripts/install-windows.ps1" ` -OutFile $installer Get-Content $installer powershell.exe -NoProfile -ExecutionPolicy Bypass ` diff --git a/README.md b/README.md index b22caa9..5188d11 100644 --- a/README.md +++ b/README.md @@ -344,7 +344,7 @@ download and review it as described below. A direct `pipx` installation can be upgraded with: ```powershell -pipx install --force "git+https://github.com/hungrytech/wikibrain.git@v0.1.7" +pipx install --force "git+https://github.com/hungrytech/wikibrain.git@v0.1.8" brainctl setup brainctl doctor ``` @@ -384,7 +384,7 @@ manually, open PowerShell, download the versioned installer, review it, then run ```powershell $installer = Join-Path $env:TEMP "install-wikibrain.ps1" Invoke-WebRequest ` - "https://raw.githubusercontent.com/hungrytech/wikibrain/v0.1.7/scripts/install-windows.ps1" ` + "https://raw.githubusercontent.com/hungrytech/wikibrain/v0.1.8/scripts/install-windows.ps1" ` -OutFile $installer Get-Content $installer powershell.exe -NoProfile -ExecutionPolicy Bypass ` diff --git a/README.zh-CN.md b/README.zh-CN.md index 8cb0e23..f0a4ed0 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -311,7 +311,7 @@ brainctl setup && brainctl doctor 如果是直接通过 `pipx` 安装,请这样升级: ```powershell -pipx install --force "git+https://github.com/hungrytech/wikibrain.git@v0.1.7" +pipx install --force "git+https://github.com/hungrytech/wikibrain.git@v0.1.8" brainctl setup brainctl doctor ``` @@ -346,7 +346,7 @@ Do not bypass Codex hook trust. ```powershell $installer = Join-Path $env:TEMP "install-wikibrain.ps1" Invoke-WebRequest ` - "https://raw.githubusercontent.com/hungrytech/wikibrain/v0.1.7/scripts/install-windows.ps1" ` + "https://raw.githubusercontent.com/hungrytech/wikibrain/v0.1.8/scripts/install-windows.ps1" ` -OutFile $installer Get-Content $installer powershell.exe -NoProfile -ExecutionPolicy Bypass ` diff --git a/benchmarks/results/retrieval-quality-v1.json b/benchmarks/results/retrieval-quality-v1.json index 1a93cb9..93c0489 100644 --- a/benchmarks/results/retrieval-quality-v1.json +++ b/benchmarks/results/retrieval-quality-v1.json @@ -224,16 +224,16 @@ } ], "wikimap_version": "wikimap 1.1.0", - "generated_at": "2026-07-22T13:51:50.252069+00:00", + "generated_at": "2026-07-22T17:03:28.453686+00:00", "environment": { "machine": "arm64", - "platform": "macOS-26.5.2-arm64-arm-64bit", - "python": "3.11.15" + "platform": "macOS-26.5.2-arm64-arm-64bit-Mach-O", + "python": "3.13.11" }, "provenance": { "corpus_sha256": "adc729bcb6a8a8027d11176601046eaeef022bde135c7f225f8e40b78f7a998c", - "source_manifest_sha256": "4d5b53dff2bb39a3d6d24624ad3b2bd752307a1a7ae374fa672f8ea9501665b5", - "git_commit": "2654792c6c2f85ce297eab4f25617c7b3b86810b", + "source_manifest_sha256": "e115feec5e6dafbc7718b6170b4695e96bf622d128d50d52673d1de6b8b5e5a8", + "git_commit": "f5bea451e99b1729ad2aa9f6755e7bbfd1ace557", "git_dirty": false } } diff --git a/benchmarks/results/second-brain-v1.json b/benchmarks/results/second-brain-v1.json index 88fd067..3e2c2a9 100644 --- a/benchmarks/results/second-brain-v1.json +++ b/benchmarks/results/second-brain-v1.json @@ -6,8 +6,8 @@ "handoff_check": "session-start-recent-context" }, "engine": "wikimap 1.1.0", - "python": "3.11.15", - "platform": "macOS-26.5.2-arm64-arm-64bit", + "python": "3.13.11", + "platform": "macOS-26.5.2-arm64-arm-64bit-Mach-O", "corpus_documents": 8, "checks_passed": 8, "checks_total": 8, @@ -20,11 +20,11 @@ "forbidden_atoms": 4 }, "provenance": { - "generated_at": "2026-07-22T13:51:40+00:00", + "generated_at": "2026-07-22T17:03:25+00:00", "corpus_version": "second-brain-corpus-v1", "runner_sha256": "666510b248680357cebe9d70d42f07a7d0619522658fbb0810989aec449ec2aa", - "source_manifest_sha256": "f9fc90c63885c104a5dd439967a5e2bc91455720364d4957d9a26b542843e4db", - "git_commit": "2475e281a5dce0b3320af0e09b3561b7ad02e643", + "source_manifest_sha256": "a4f58b6f522e22515ffc20cf75aabd39bc6dcb52759c717fe635dbdce8befcab", + "git_commit": "f5bea451e99b1729ad2aa9f6755e7bbfd1ace557", "git_dirty": false, "reproduction_command": "uv run python -m benchmarks.second_brain --wikimap wikimap --format json --output benchmarks/results/second-brain-v1.json" }, diff --git a/docs/assets/benchmark-retrieval-quality-v1.svg b/docs/assets/benchmark-retrieval-quality-v1.svg index fe86411..7187214 100644 --- a/docs/assets/benchmark-retrieval-quality-v1.svg +++ b/docs/assets/benchmark-retrieval-quality-v1.svg @@ -35,5 +35,5 @@ clean Index state · deleted 1 -retrieval-quality-corpus-v1 · 12 queries · wikimap 1.1.0 · result 09edadd09cb1 +retrieval-quality-corpus-v1 · 12 queries · wikimap 1.1.0 · result a4330d31d58e diff --git a/docs/assets/benchmark-second-brain-v1.svg b/docs/assets/benchmark-second-brain-v1.svg index 73b9074..f71d479 100644 --- a/docs/assets/benchmark-second-brain-v1.svg +++ b/docs/assets/benchmark-second-brain-v1.svg @@ -48,6 +48,6 @@ 100.00% -macOS 26.5.2-arm64-arm-64bit · Python 3.11.15 · wikimap 1.1.0 +macOS 26.5.2 · arm64 · Python 3.13.11 · wikimap 1.1.0 Higher is better diff --git a/packaging/homebrew/README.md b/packaging/homebrew/README.md index d5c937a..9c9673c 100644 --- a/packaging/homebrew/README.md +++ b/packaging/homebrew/README.md @@ -15,8 +15,8 @@ pinned backend. ```bash python3 scripts/render_homebrew_formula.py \ --owner hungrytech \ - --version 0.1.7 \ - --source-url https://github.com/hungrytech/wikibrain/archive/refs/tags/v0.1.7.tar.gz \ + --version 0.1.8 \ + --source-url https://github.com/hungrytech/wikibrain/archive/refs/tags/v0.1.8.tar.gz \ --source-sha256 64_HEX_CHARACTERS ``` diff --git a/plugins/wikibrain/.codex-plugin/plugin.json b/plugins/wikibrain/.codex-plugin/plugin.json index 6bf8d9f..f000364 100644 --- a/plugins/wikibrain/.codex-plugin/plugin.json +++ b/plugins/wikibrain/.codex-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "wikibrain", - "version": "0.1.7", + "version": "0.1.8", "description": "Recall and curate a local, cross-agent personal second brain.", "author": { "name": "Taekmin Lee" diff --git a/pyproject.toml b/pyproject.toml index 417bd55..c0ad255 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,20 +1,20 @@ [build-system] -requires = ["setuptools>=69"] +requires = ["setuptools>=77"] build-backend = "setuptools.build_meta" [project] name = "wikibrain-agent" -version = "0.1.7" +version = "0.1.8" description = "Local-first personal memory bridge for Claude Code, Codex, Grok Build, and Wikimap" readme = "README.md" requires-python = ">=3.11" -license = { text = "MIT" } +license = "MIT" +license-files = ["LICENSE"] authors = [{ name = "Taekmin Lee" }] keywords = ["second-brain", "wikimap", "claude-code", "codex", "grok-build", "hooks"] classifiers = [ "Development Status :: 3 - Alpha", "Environment :: Console", - "License :: OSI Approved :: MIT License", "Operating System :: MacOS", "Operating System :: Microsoft :: Windows", "Operating System :: POSIX :: Linux", diff --git a/release-policy.json b/release-policy.json index ab76113..e76f996 100644 --- a/release-policy.json +++ b/release-policy.json @@ -1,6 +1,6 @@ { "schema_version": 1, - "latest_version": "0.1.7", + "latest_version": "0.1.8", "minimum_supported_version": "0.1.7", - "updated_at": "2026-07-22T13:42:48Z" + "updated_at": "2026-07-22T14:42:07Z" } diff --git a/scripts/install-windows.ps1 b/scripts/install-windows.ps1 index 216be39..db99902 100644 --- a/scripts/install-windows.ps1 +++ b/scripts/install-windows.ps1 @@ -2,7 +2,7 @@ [CmdletBinding()] param( - [string]$Version = "0.1.7", + [string]$Version = "0.1.8", [string]$PackageSource = "", [switch]$Initialize, [switch]$SkipPythonInstall diff --git a/src/wikibrain/__init__.py b/src/wikibrain/__init__.py index 2cb0a41..44acea9 100644 --- a/src/wikibrain/__init__.py +++ b/src/wikibrain/__init__.py @@ -1,3 +1,3 @@ """WikiBrain: a local-first memory bridge for coding agents.""" -__version__ = "0.1.7" +__version__ = "0.1.8" diff --git a/src/wikibrain/version_policy.py b/src/wikibrain/version_policy.py index 57b54c5..7db4c14 100644 --- a/src/wikibrain/version_policy.py +++ b/src/wikibrain/version_policy.py @@ -1,13 +1,19 @@ from __future__ import annotations import json +import os import re +import signal +import stat +import subprocess +import sys +import time from dataclasses import asdict, dataclass from datetime import UTC, datetime, timedelta from http.client import HTTPException from pathlib import Path -from typing import Callable -from urllib.request import Request, urlopen +from typing import Any, BinaryIO, Callable +from urllib.request import HTTPRedirectHandler, Request, build_opener from .config import atomic_write_text @@ -20,6 +26,13 @@ CACHE_NAME = "release-policy-cache.json" MAX_POLICY_BYTES = 64 * 1024 MAX_CACHE_BYTES = 128 * 1024 +SOCKET_TIMEOUT = 2.0 +FETCH_REQUEST_DEADLINE = 2.0 +FETCH_CLEANUP_RESERVE = 0.5 +TOTAL_FETCH_DEADLINE = 2.5 +_REAL_POPEN = subprocess.Popen +MAX_FUTURE_SKEW = timedelta(minutes=5) +POLICY_SCHEMA_EPOCH = datetime(2026, 7, 22, tzinfo=UTC) _SEMVER = re.compile(r"^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)$") @@ -49,6 +62,13 @@ def upgrade_required(self) -> bool: return self.state == "upgrade-required" +@dataclass(frozen=True, slots=True) +class _CacheEntry: + checked_at: datetime + policy: ReleasePolicy | None + last_accepted_policy: ReleasePolicy | None + + def _version_tuple(version: str) -> tuple[int, int, int]: match = _SEMVER.fullmatch(version) if match is None: @@ -87,7 +107,12 @@ def _decode_json(payload: bytes) -> object: raise ValueError("invalid release policy JSON") from exc -def parse_release_policy(payload: bytes) -> ReleasePolicy: +def parse_release_policy( + payload: bytes, + *, + now: datetime | None = None, + allow_future: bool = False, +) -> ReleasePolicy: if len(payload) > MAX_POLICY_BYTES: raise ValueError("release policy exceeds the size limit") decoded = _decode_json(payload) @@ -110,7 +135,15 @@ def parse_release_policy(payload: bytes) -> ReleasePolicy: if _version_tuple(latest) < _version_tuple(minimum): raise ValueError("latest_version cannot precede minimum_supported_version") updated_at = decoded["updated_at"] - _parse_timestamp(updated_at) + policy_time = _parse_timestamp(updated_at) + reference_time = now or datetime.now(UTC) + if reference_time.tzinfo is None or reference_time.utcoffset() is None: + raise ValueError("release policy time must be timezone-aware") + reference_time = reference_time.astimezone(UTC) + if policy_time < POLICY_SCHEMA_EPOCH: + raise ValueError("release policy predates schema version 1") + if not allow_future and policy_time > reference_time + MAX_FUTURE_SKEW: + raise ValueError("release policy timestamp is too far in the future") return ReleasePolicy( schema_version=1, latest_version=latest, @@ -119,7 +152,24 @@ def parse_release_policy(payload: bytes) -> ReleasePolicy: ) -def _fetch_remote_policy() -> bytes: +class _NoRedirectHandler(HTTPRedirectHandler): + def redirect_request( + self, + req: Request, + fp: Any, + code: int, + msg: str, + headers: Any, + newurl: str, + ) -> None: + return None + + +def _open_policy_url(request: Request, timeout: float) -> Any: + return build_opener(_NoRedirectHandler).open(request, timeout=timeout) + + +def _download_remote_policy() -> bytes: request = Request( POLICY_URL, headers={ @@ -127,7 +177,7 @@ def _fetch_remote_policy() -> bytes: "User-Agent": "wikibrain-version-policy", }, ) - with urlopen(request, timeout=2.0) as response: + with _open_policy_url(request, timeout=SOCKET_TIMEOUT) as response: if response.geturl() != POLICY_URL: raise ValueError("release policy response is not the official policy URL") payload = response.read(MAX_POLICY_BYTES + 1) @@ -136,6 +186,191 @@ def _fetch_remote_policy() -> bytes: return payload +def _default_fetch_child_code() -> str: + source_root = str(Path(__file__).resolve().parent.parent) + return ( + "import os,sys,threading;" + f"_t=threading.Timer({FETCH_REQUEST_DEADLINE!r},lambda:os._exit(124));" + "_t.daemon=True;_t.start();" + f"sys.path.insert(0, {source_root!r});" + "from wikibrain.version_policy import _download_remote_policy;" + "sys.stdout.buffer.write(_download_remote_policy())" + ) + + +def _process_is_running(process: subprocess.Popen[bytes]) -> bool: + try: + return process.poll() is None + except BaseException: + return True + + +def _set_native_returncode(process: subprocess.Popen[bytes], status: int) -> None: + try: + process.returncode = os.waitstatus_to_exitcode(status) + except (AttributeError, ValueError): + process.returncode = -signal.SIGKILL + + +def _native_terminate_and_reap( + process: subprocess.Popen[bytes], deadline: float +) -> bool: + if os.name == "posix": + try: + waited_pid, status = os.waitpid(process.pid, os.WNOHANG) + except ChildProcessError: + return True + except OSError: + return False + if waited_pid == process.pid: + _set_native_returncode(process, status) + return True + try: + os.kill(process.pid, signal.SIGKILL) + except ProcessLookupError: + pass + except OSError: + return False + while True: + try: + waited_pid, status = os.waitpid(process.pid, os.WNOHANG) + except ChildProcessError: + return True + except OSError: + return False + if waited_pid == process.pid: + _set_native_returncode(process, status) + return True + remaining = deadline - time.monotonic() + if remaining <= 0: + # SIGKILL has been delivered to our own child. A blocking wait here + # is the final no-orphan guarantee, not another network allowance. + try: + waited_pid, status = os.waitpid(process.pid, 0) + except ChildProcessError: + return True + except OSError: + return False + _set_native_returncode(process, status) + return waited_pid == process.pid + time.sleep(min(0.005, remaining)) + + if os.name == "nt": + try: + import _winapi + + handle = process._handle # type: ignore[attr-defined] + state = _winapi.WaitForSingleObject(handle, 0) + if state == _winapi.WAIT_TIMEOUT: + _winapi.TerminateProcess(handle, 1) + remaining_ms = max(0, int((deadline - time.monotonic()) * 1000)) + state = _winapi.WaitForSingleObject(handle, remaining_ms) + if state == _winapi.WAIT_TIMEOUT: + state = _winapi.WaitForSingleObject(handle, _winapi.INFINITE) + if state != _winapi.WAIT_OBJECT_0: + return False + process.returncode = _winapi.GetExitCodeProcess(handle) + return True + except (AttributeError, OSError): + return False + + return False + + +def _generic_terminate_and_reap( + process: subprocess.Popen[bytes], deadline: float +) -> bool: + # This path supports test doubles and uncommon runtimes. Each operation remains + # independent, but every wait uses only the absolute cleanup budget. + for method_name in ("kill", "terminate", "kill"): + if not _process_is_running(process): + return True + try: + getattr(process, method_name)() + except BaseException: + pass + remaining = deadline - time.monotonic() + if remaining <= 0: + break + try: + process.wait(timeout=remaining) + except BaseException: + pass + return not _process_is_running(process) + + +def _cleanup_fetch_process( + process: subprocess.Popen[bytes], deadline: float +) -> bool: + if isinstance(process, _REAL_POPEN): + reaped = _native_terminate_and_reap(process, deadline) + else: + reaped = _generic_terminate_and_reap(process, deadline) + for stream_name in ("stdout", "stderr", "stdin"): + try: + stream = getattr(process, stream_name) + except BaseException: + continue + if stream is not None: + try: + stream.close() + except BaseException: + pass + return reaped + + +def _fetch_remote_policy(*, child_code: str | None = None) -> bytes: + started = time.monotonic() + cleanup_budget = min(FETCH_CLEANUP_RESERVE, TOTAL_FETCH_DEADLINE / 5) + absolute_deadline = started + TOTAL_FETCH_DEADLINE + request_deadline = min( + started + FETCH_REQUEST_DEADLINE, + absolute_deadline - cleanup_budget, + ) + creation_flags = 0 + if os.name == "nt": + creation_flags = getattr(subprocess, "CREATE_NO_WINDOW", 0) + try: + process = subprocess.Popen( + [sys.executable, "-I", "-c", child_code or _default_fetch_child_code()], + stdin=subprocess.DEVNULL, + stdout=subprocess.PIPE, + stderr=subprocess.DEVNULL, + close_fds=True, + creationflags=creation_flags, + ) + except Exception as exc: + raise OSError("could not start release policy worker") from exc + + result: bytes | None = None + failure: BaseException | None = None + try: + remaining = request_deadline - time.monotonic() + if remaining <= 0: + raise TimeoutError("release policy request exceeded its total deadline") + try: + stdout, _ = process.communicate(timeout=remaining) + except subprocess.TimeoutExpired as exc: + raise TimeoutError( + "release policy request exceeded its total deadline" + ) from exc + if process.returncode != 0: + raise OSError("release policy worker exited without a result") + if len(stdout) > MAX_POLICY_BYTES: + raise ValueError("release policy response is too large") + result = stdout + except BaseException as exc: + failure = exc + + reaped = _cleanup_fetch_process(process, absolute_deadline) + if not reaped: + raise OSError("release policy worker cleanup could not be verified") from failure + if failure is not None: + raise failure.with_traceback(failure.__traceback__) + assert result is not None + return result + + def _decision( policy: ReleasePolicy, current_version: str, @@ -165,44 +400,139 @@ def _unavailable(current_version: str, *, source: str) -> PolicyDecision: ) -def _read_fresh_cache( - path: Path, - current_version: str, - now: datetime, -) -> PolicyDecision | None: +def _fd_has_extended_acl(descriptor: int) -> bool: + if sys.platform == "darwin": + import ctypes + import errno + + libc = ctypes.CDLL(None, use_errno=True) + acl_get_fd_np = libc.acl_get_fd_np + acl_get_fd_np.argtypes = [ctypes.c_int, ctypes.c_int] + acl_get_fd_np.restype = ctypes.c_void_p + acl_free = libc.acl_free + acl_free.argtypes = [ctypes.c_void_p] + acl_free.restype = ctypes.c_int + ctypes.set_errno(0) + acl = acl_get_fd_np(descriptor, 0x00000100) # ACL_TYPE_EXTENDED + if not acl: + error = ctypes.get_errno() + if error == errno.ENOENT: + return False + raise OSError(error, os.strerror(error)) + if acl_free(acl) != 0: + error = ctypes.get_errno() + raise OSError(error, os.strerror(error)) + return True + + list_xattrs = getattr(os, "listxattr", None) + if list_xattrs is None: + raise OSError("descriptor ACL inspection is unavailable") + acl_markers = { + "system.posix_acl_access", + "system.nfs4_acl", + "security.nfs4_acl", + "trusted.nfs4_acl", + "system.richacl", + "trusted.sgi_acl_file", + } + names = ( + name.decode("ascii", errors="ignore") if isinstance(name, bytes) else name + for name in list_xattrs(descriptor) + ) + return any(name.lower() in acl_markers for name in names) + + +def _open_trusted_cache(path: Path, trusted_home: Path) -> BinaryIO: + if os.name == "nt": + from wikibrain.windows_cache import open_trusted_windows_cache + + return open_trusted_windows_cache(path, trusted_home) + + flags = os.O_RDONLY + flags |= getattr(os, "O_CLOEXEC", 0) + flags |= getattr(os, "O_NOFOLLOW", 0) + descriptor = os.open(path, flags) + try: + metadata = os.fstat(descriptor) + if not stat.S_ISREG(metadata.st_mode): + raise OSError("release policy cache is not a regular file") + if metadata.st_uid != os.getuid(): + raise OSError("release policy cache is not owned by the current user") + if metadata.st_mode & 0o022: + raise OSError("release policy cache is writable by group or others") + if _fd_has_extended_acl(descriptor): + raise OSError("release policy cache has an extended ACL") + return os.fdopen(descriptor, "rb") + except Exception: + os.close(descriptor) + raise + + +def _read_cache(path: Path, trusted_home: Path, now: datetime) -> _CacheEntry | None: try: - with path.open("rb") as cache_file: + with _open_trusted_cache(path, trusted_home) as cache_file: cache_payload = cache_file.read(MAX_CACHE_BYTES + 1) if len(cache_payload) > MAX_CACHE_BYTES: raise ValueError("release policy cache exceeds the size limit") cached = _decode_json(cache_payload) if not isinstance(cached, dict): raise ValueError("release policy cache must be a JSON object") - if set(cached) != {"schema_version", "checked_at", "policy"}: - raise ValueError("release policy cache fields do not match schema version 1") - if ( - type(cached.get("schema_version")) is not int - or cached["schema_version"] != 1 - ): + schema_version = cached.get("schema_version") + if type(schema_version) is not int or schema_version not in {1, 2}: raise ValueError("unsupported release policy cache schema") + expected = ( + {"schema_version", "checked_at", "policy"} + if schema_version == 1 + else { + "schema_version", + "checked_at", + "policy", + "last_accepted_policy", + } + ) + if set(cached) != expected: + raise ValueError("release policy cache fields do not match its schema") checked_at = _parse_timestamp(cached["checked_at"]) - age = now - checked_at - if age < timedelta(0) or age >= CACHE_TTL: - return None - payload = cached.get("policy") - if payload is None: - return _unavailable(current_version, source="cache") - policy = parse_release_policy(json.dumps(payload).encode("utf-8")) - return _decision(policy, current_version, source="cache") + + def decode_policy(value: object) -> ReleasePolicy | None: + if value is None: + return None + return parse_release_policy( + json.dumps(value).encode("utf-8"), + now=now, + allow_future=True, + ) + + policy = decode_policy(cached.get("policy")) + last_accepted = ( + decode_policy(cached.get("last_accepted_policy")) + if schema_version == 2 + else policy + ) + if policy is not None and policy != last_accepted: + raise ValueError("cached policy must match last accepted policy") + return _CacheEntry( + checked_at=checked_at, + policy=policy, + last_accepted_policy=last_accepted, + ) except (KeyError, MemoryError, OSError, RecursionError, TypeError, ValueError): return None -def _write_cache(path: Path, now: datetime, policy: ReleasePolicy | None) -> None: +def _write_cache( + path: Path, + now: datetime, + policy: ReleasePolicy | None, + last_accepted_policy: ReleasePolicy | None, +) -> None: payload = { - "schema_version": 1, + "schema_version": 2, "checked_at": now.astimezone(UTC).isoformat().replace("+00:00", "Z"), "policy": asdict(policy) if policy is not None else None, + "last_accepted_policy": ( + asdict(last_accepted_policy) if last_accepted_policy is not None else None + ), } atomic_write_text(path, json.dumps(payload, indent=2) + "\n") @@ -214,28 +544,48 @@ def check_release_policy( now: datetime | None = None, fetcher: Callable[[], bytes] | None = None, ) -> PolicyDecision: - checked_at = (now or datetime.now(UTC)).astimezone(UTC) - cache_path = home.expanduser().resolve() / CACHE_NAME - cached = _read_fresh_cache(cache_path, current_version, checked_at) + checked_at = now or datetime.now(UTC) + if checked_at.tzinfo is None or checked_at.utcoffset() is None: + raise ValueError("release policy check time must be timezone-aware") + checked_at = checked_at.astimezone(UTC) + trusted_home = home.expanduser().resolve() + cache_path = trusted_home / CACHE_NAME + cached = _read_cache(cache_path, trusted_home, checked_at) if cached is not None: - return cached + age = checked_at - cached.checked_at + if timedelta(0) <= age < CACHE_TTL: + if cached.policy is None: + return _unavailable(current_version, source="cache") + return _decision(cached.policy, current_version, source="cache") + previous_policy = cached.last_accepted_policy if cached is not None else None try: - policy = parse_release_policy((fetcher or _fetch_remote_policy)()) + policy = parse_release_policy( + (fetcher or _fetch_remote_policy)(), + now=checked_at, + ) + if previous_policy is not None and _parse_timestamp( + policy.updated_at + ) < _parse_timestamp(previous_policy.updated_at): + raise ValueError("release policy rollback detected") decision = _decision(policy, current_version, source="remote") + last_accepted_policy = policy except ( HTTPException, MemoryError, OSError, RecursionError, + RuntimeError, + subprocess.SubprocessError, UnicodeError, ValueError, ): policy = None + last_accepted_policy = previous_policy decision = _unavailable(current_version, source="remote-error") try: - _write_cache(cache_path, checked_at, policy) + _write_cache(cache_path, checked_at, policy, last_accepted_policy) except (MemoryError, OSError, RecursionError): pass return decision diff --git a/src/wikibrain/windows_cache.py b/src/wikibrain/windows_cache.py new file mode 100644 index 0000000..f6d1a56 --- /dev/null +++ b/src/wikibrain/windows_cache.py @@ -0,0 +1,335 @@ +from __future__ import annotations + +import ntpath +import os +from pathlib import Path +from typing import BinaryIO + + +class WindowsCacheTrustError(OSError): + pass + + +def _normalize_final_path(value: str) -> str: + if value.startswith("\\\\?\\UNC\\"): + return "\\\\" + value[8:] + if value.startswith("\\\\?\\"): + return value[4:] + return value + + +def _is_within_profile(final_path: str, user_home: Path) -> bool: + try: + normalized_path = ntpath.normcase(ntpath.abspath(final_path)) + normalized_home = ntpath.normcase(ntpath.abspath(str(user_home.resolve(strict=True)))) + return ntpath.commonpath((normalized_path, normalized_home)) == normalized_home + except (OSError, ValueError): + return False + + +def open_trusted_windows_cache(path: Path, user_home: Path) -> BinaryIO: + """Open and validate a cache using one Windows kernel handle. + + The final object, owner, and DACL are all inspected through the handle returned + by CreateFileW. Pathname checks are never used as authorization decisions. + """ + if os.name != "nt": + raise WindowsCacheTrustError("Windows cache validation requires Windows") + + import ctypes + import msvcrt + from ctypes import wintypes + + kernel32 = ctypes.WinDLL("kernel32", use_last_error=True) + advapi32 = ctypes.WinDLL("advapi32", use_last_error=True) + + generic_read = 0x80000000 + read_control = 0x00020000 + share_all = 0x00000001 | 0x00000002 | 0x00000004 + open_existing = 3 + open_reparse_point = 0x00200000 + file_attribute_directory = 0x00000010 + file_attribute_reparse_point = 0x00000400 + se_file_object = 1 + owner_security_information = 0x00000001 + dacl_security_information = 0x00000004 + token_query = 0x0008 + token_user_class = 1 + error_insufficient_buffer = 122 + invalid_handle_value = ctypes.c_void_p(-1).value + + class ByHandleFileInformation(ctypes.Structure): + _fields_ = [ + ("dwFileAttributes", wintypes.DWORD), + ("ftCreationTime", wintypes.FILETIME), + ("ftLastAccessTime", wintypes.FILETIME), + ("ftLastWriteTime", wintypes.FILETIME), + ("dwVolumeSerialNumber", wintypes.DWORD), + ("nFileSizeHigh", wintypes.DWORD), + ("nFileSizeLow", wintypes.DWORD), + ("nNumberOfLinks", wintypes.DWORD), + ("nFileIndexHigh", wintypes.DWORD), + ("nFileIndexLow", wintypes.DWORD), + ] + + class SidAndAttributes(ctypes.Structure): + _fields_ = [("Sid", ctypes.c_void_p), ("Attributes", wintypes.DWORD)] + + class TokenUser(ctypes.Structure): + _fields_ = [("User", SidAndAttributes)] + + class AclHeader(ctypes.Structure): + _fields_ = [ + ("AclRevision", ctypes.c_ubyte), + ("Sbz1", ctypes.c_ubyte), + ("AclSize", ctypes.c_ushort), + ("AceCount", ctypes.c_ushort), + ("Sbz2", ctypes.c_ushort), + ] + + kernel32.CreateFileW.argtypes = [ + wintypes.LPCWSTR, + wintypes.DWORD, + wintypes.DWORD, + ctypes.c_void_p, + wintypes.DWORD, + wintypes.DWORD, + wintypes.HANDLE, + ] + kernel32.CreateFileW.restype = wintypes.HANDLE + kernel32.GetFileInformationByHandle.argtypes = [ + wintypes.HANDLE, + ctypes.POINTER(ByHandleFileInformation), + ] + kernel32.GetFileInformationByHandle.restype = wintypes.BOOL + kernel32.GetFinalPathNameByHandleW.argtypes = [ + wintypes.HANDLE, + wintypes.LPWSTR, + wintypes.DWORD, + wintypes.DWORD, + ] + kernel32.GetFinalPathNameByHandleW.restype = wintypes.DWORD + kernel32.GetCurrentProcess.restype = wintypes.HANDLE + kernel32.CloseHandle.argtypes = [wintypes.HANDLE] + kernel32.CloseHandle.restype = wintypes.BOOL + kernel32.LocalFree.argtypes = [ctypes.c_void_p] + kernel32.LocalFree.restype = ctypes.c_void_p + + advapi32.GetSecurityInfo.argtypes = [ + wintypes.HANDLE, + wintypes.DWORD, + wintypes.DWORD, + ctypes.POINTER(ctypes.c_void_p), + ctypes.c_void_p, + ctypes.POINTER(ctypes.c_void_p), + ctypes.c_void_p, + ctypes.POINTER(ctypes.c_void_p), + ] + advapi32.GetSecurityInfo.restype = wintypes.DWORD + advapi32.OpenProcessToken.argtypes = [ + wintypes.HANDLE, + wintypes.DWORD, + ctypes.POINTER(wintypes.HANDLE), + ] + advapi32.OpenProcessToken.restype = wintypes.BOOL + advapi32.GetTokenInformation.argtypes = [ + wintypes.HANDLE, + ctypes.c_int, + ctypes.c_void_p, + wintypes.DWORD, + ctypes.POINTER(wintypes.DWORD), + ] + advapi32.GetTokenInformation.restype = wintypes.BOOL + advapi32.GetAce.argtypes = [ + ctypes.c_void_p, + wintypes.DWORD, + ctypes.POINTER(ctypes.c_void_p), + ] + advapi32.GetAce.restype = wintypes.BOOL + advapi32.ConvertSidToStringSidW.argtypes = [ + ctypes.c_void_p, + ctypes.POINTER(wintypes.LPWSTR), + ] + advapi32.ConvertSidToStringSidW.restype = wintypes.BOOL + + handle = kernel32.CreateFileW( + str(path), + generic_read | read_control, + share_all, + None, + open_existing, + open_reparse_point, + None, + ) + if handle == invalid_handle_value: + raise WindowsCacheTrustError(ctypes.get_last_error(), "CreateFileW failed") + + token = wintypes.HANDLE() + security_descriptor = ctypes.c_void_p() + transferred = False + try: + information = ByHandleFileInformation() + if not kernel32.GetFileInformationByHandle(handle, ctypes.byref(information)): + raise WindowsCacheTrustError( + ctypes.get_last_error(), "GetFileInformationByHandle failed" + ) + if information.dwFileAttributes & file_attribute_directory: + raise WindowsCacheTrustError("release policy cache is a directory") + if information.dwFileAttributes & file_attribute_reparse_point: + raise WindowsCacheTrustError("release policy cache is a reparse point") + + capacity = 32768 + final_path_buffer = ctypes.create_unicode_buffer(capacity) + path_length = kernel32.GetFinalPathNameByHandleW( + handle, final_path_buffer, capacity, 0 + ) + if path_length == 0 or path_length >= capacity: + raise WindowsCacheTrustError( + ctypes.get_last_error(), "GetFinalPathNameByHandleW failed" + ) + final_path = _normalize_final_path(final_path_buffer.value) + if not _is_within_profile(final_path, user_home): + raise WindowsCacheTrustError( + "release policy cache is outside the Windows user profile" + ) + + owner_sid = ctypes.c_void_p() + dacl = ctypes.c_void_p() + security_status = advapi32.GetSecurityInfo( + handle, + se_file_object, + owner_security_information | dacl_security_information, + ctypes.byref(owner_sid), + None, + ctypes.byref(dacl), + None, + ctypes.byref(security_descriptor), + ) + if security_status != 0: + raise WindowsCacheTrustError(security_status, "GetSecurityInfo failed") + if not dacl.value: + raise WindowsCacheTrustError("release policy cache has a null DACL") + + if not advapi32.OpenProcessToken( + kernel32.GetCurrentProcess(), token_query, ctypes.byref(token) + ): + raise WindowsCacheTrustError(ctypes.get_last_error(), "OpenProcessToken failed") + token_size = wintypes.DWORD() + advapi32.GetTokenInformation( + token, token_user_class, None, 0, ctypes.byref(token_size) + ) + if ctypes.get_last_error() != error_insufficient_buffer: + raise WindowsCacheTrustError( + ctypes.get_last_error(), "GetTokenInformation sizing failed" + ) + token_buffer = ctypes.create_string_buffer(token_size.value) + if not advapi32.GetTokenInformation( + token, + token_user_class, + token_buffer, + token_size, + ctypes.byref(token_size), + ): + raise WindowsCacheTrustError( + ctypes.get_last_error(), "GetTokenInformation failed" + ) + current_sid = ctypes.cast( + token_buffer, ctypes.POINTER(TokenUser) + ).contents.User.Sid + def sid_string(sid: ctypes.c_void_p) -> str: + text = wintypes.LPWSTR() + if not advapi32.ConvertSidToStringSidW(sid, ctypes.byref(text)): + raise WindowsCacheTrustError( + ctypes.get_last_error(), "ConvertSidToStringSidW failed" + ) + try: + return text.value + finally: + kernel32.LocalFree(ctypes.cast(text, ctypes.c_void_p)) + + current_sid_text = sid_string(current_sid) + owner_sid_text = sid_string(owner_sid) + if owner_sid_text not in { + current_sid_text, + "S-1-5-32-544", # Elevated Windows creates files owned by Administrators. + }: + raise WindowsCacheTrustError( + "release policy cache has an untrusted owner" + ) + + allowed_writers = { + current_sid_text, + "S-1-5-18", # LocalSystem + "S-1-5-32-544", # Builtin Administrators + "S-1-3-0", # Creator Owner + "S-1-3-4", # Owner Rights + } + write_mask = ( + 0x00000002 + | 0x00000004 + | 0x00000010 + | 0x00000100 + | 0x00010000 + | 0x00040000 + | 0x00080000 + | 0x10000000 + | 0x40000000 + ) + allow_ace_types = {0, 5, 9, 11} + safe_non_allow_ace_types = { + 1, + 2, + 3, + 6, + 7, + 8, + 10, + 12, + 13, + 14, + 15, + 16, + 17, + 18, + 19, + 20, + 21, + } + acl = ctypes.cast(dacl, ctypes.POINTER(AclHeader)).contents + for index in range(acl.AceCount): + ace = ctypes.c_void_p() + if not advapi32.GetAce(dacl, index, ctypes.byref(ace)): + raise WindowsCacheTrustError(ctypes.get_last_error(), "GetAce failed") + ace_type = ctypes.c_ubyte.from_address(ace.value).value + if ace_type not in allow_ace_types: + if ace_type not in safe_non_allow_ace_types: + raise WindowsCacheTrustError("unsupported cache ACL entry") + continue + if ace_type != 0: + raise WindowsCacheTrustError("complex allow ACE is not trusted") + access_mask = ctypes.c_uint32.from_address(ace.value + 4).value + if not access_mask & write_mask: + continue + ace_sid = ctypes.c_void_p(ace.value + 8) + if sid_string(ace_sid) not in allowed_writers: + raise WindowsCacheTrustError( + "release policy cache is writable by another principal" + ) + + descriptor = msvcrt.open_osfhandle(handle, os.O_RDONLY) + transferred = True + try: + return os.fdopen(descriptor, "rb") + except BaseException: + try: + os.close(descriptor) + except OSError: + pass + raise + finally: + if token: + kernel32.CloseHandle(token) + if security_descriptor: + kernel32.LocalFree(security_descriptor) + if not transferred: + kernel32.CloseHandle(handle) diff --git a/tests/test_version_policy.py b/tests/test_version_policy.py index 86f4a65..e1ebc3e 100644 --- a/tests/test_version_policy.py +++ b/tests/test_version_policy.py @@ -2,7 +2,10 @@ import json import os +import subprocess +import sys import tempfile +import time import unittest from argparse import Namespace from contextlib import redirect_stderr @@ -10,15 +13,21 @@ from http.client import IncompleteRead from io import StringIO from pathlib import Path +from types import SimpleNamespace from unittest.mock import patch from wikibrain.cli import _enforce_minimum_supported_version, main from wikibrain.version_policy import ( + CACHE_NAME, CACHE_TTL, MAX_CACHE_BYTES, POLICY_URL, PolicyDecision, + _cleanup_fetch_process, + _download_remote_policy, + _fd_has_extended_acl, _fetch_remote_policy, + _open_trusted_cache, check_release_policy, parse_release_policy, ) @@ -28,17 +37,160 @@ NOW = datetime(2026, 7, 22, 12, 0, tzinfo=UTC) -def _policy(*, latest: str = "0.1.7", minimum: str = "0.1.6") -> bytes: +def _policy( + *, + latest: str = "0.1.7", + minimum: str = "0.1.6", + updated_at: str = "2026-07-22T12:00:00Z", +) -> bytes: return json.dumps( { "schema_version": 1, "latest_version": latest, "minimum_supported_version": minimum, - "updated_at": "2026-07-22T12:00:00Z", + "updated_at": updated_at, } ).encode() +def _secure_windows_directory(path: Path) -> None: + account = subprocess.run( + ["whoami"], check=True, capture_output=True, text=True + ).stdout.strip() + subprocess.run( + [ + "icacls", + str(path), + "/inheritance:r", + "/grant:r", + f"{account}:(OI)(CI)F", + ], + check=True, + capture_output=True, + text=True, + ) + + +def _enable_windows_restore_privilege() -> None: + import ctypes + from ctypes import wintypes + + class Luid(ctypes.Structure): + _fields_ = [("LowPart", wintypes.DWORD), ("HighPart", wintypes.LONG)] + + class LuidAndAttributes(ctypes.Structure): + _fields_ = [("Luid", Luid), ("Attributes", wintypes.DWORD)] + + class TokenPrivileges(ctypes.Structure): + _fields_ = [ + ("PrivilegeCount", wintypes.DWORD), + ("Privileges", LuidAndAttributes * 1), + ] + + win_dll = getattr(ctypes, "WinDLL") + win_error = getattr(ctypes, "WinError") + get_last_error = getattr(ctypes, "get_last_error") + set_last_error = getattr(ctypes, "set_last_error") + advapi32 = win_dll("advapi32", use_last_error=True) + kernel32 = win_dll("kernel32", use_last_error=True) + token = wintypes.HANDLE() + advapi32.OpenProcessToken.argtypes = [ + wintypes.HANDLE, + wintypes.DWORD, + ctypes.POINTER(wintypes.HANDLE), + ] + advapi32.OpenProcessToken.restype = wintypes.BOOL + advapi32.LookupPrivilegeValueW.argtypes = [ + wintypes.LPCWSTR, + wintypes.LPCWSTR, + ctypes.POINTER(Luid), + ] + advapi32.LookupPrivilegeValueW.restype = wintypes.BOOL + advapi32.AdjustTokenPrivileges.argtypes = [ + wintypes.HANDLE, + wintypes.BOOL, + ctypes.POINTER(TokenPrivileges), + wintypes.DWORD, + ctypes.c_void_p, + ctypes.c_void_p, + ] + advapi32.AdjustTokenPrivileges.restype = wintypes.BOOL + kernel32.GetCurrentProcess.restype = wintypes.HANDLE + kernel32.CloseHandle.argtypes = [wintypes.HANDLE] + kernel32.CloseHandle.restype = wintypes.BOOL + if not advapi32.OpenProcessToken( + kernel32.GetCurrentProcess(), 0x0020 | 0x0008, ctypes.byref(token) + ): + raise win_error(get_last_error()) + try: + luid = Luid() + if not advapi32.LookupPrivilegeValueW( + None, "SeRestorePrivilege", ctypes.byref(luid) + ): + raise win_error(get_last_error()) + privileges = TokenPrivileges() + privileges.PrivilegeCount = 1 + privileges.Privileges[0].Luid = luid + privileges.Privileges[0].Attributes = 0x0002 + set_last_error(0) + if not advapi32.AdjustTokenPrivileges( + token, False, ctypes.byref(privileges), 0, None, None + ): + raise win_error(get_last_error()) + if get_last_error() == 1300: + raise win_error(1300) + finally: + kernel32.CloseHandle(token) + + +def _set_windows_security( + path: Path, *, owner_sid: str | None = None, null_dacl: bool = False +) -> None: + import ctypes + from ctypes import wintypes + + win_dll = getattr(ctypes, "WinDLL") + win_error = getattr(ctypes, "WinError") + get_last_error = getattr(ctypes, "get_last_error") + advapi32 = win_dll("advapi32", use_last_error=True) + kernel32 = win_dll("kernel32", use_last_error=True) + owner = ctypes.c_void_p() + advapi32.ConvertStringSidToSidW.argtypes = [ + wintypes.LPCWSTR, + ctypes.POINTER(ctypes.c_void_p), + ] + advapi32.ConvertStringSidToSidW.restype = wintypes.BOOL + advapi32.SetNamedSecurityInfoW.argtypes = [ + wintypes.LPWSTR, + wintypes.DWORD, + wintypes.DWORD, + ctypes.c_void_p, + ctypes.c_void_p, + ctypes.c_void_p, + ctypes.c_void_p, + ] + advapi32.SetNamedSecurityInfoW.restype = wintypes.DWORD + kernel32.LocalFree.argtypes = [ctypes.c_void_p] + kernel32.LocalFree.restype = ctypes.c_void_p + security_information = 0 + if owner_sid is not None: + _enable_windows_restore_privilege() + if not advapi32.ConvertStringSidToSidW(owner_sid, ctypes.byref(owner)): + raise win_error(get_last_error()) + security_information |= 0x00000001 + if null_dacl: + security_information |= 0x00000004 + try: + status = advapi32.SetNamedSecurityInfoW( + str(path), 1, security_information, owner, None, None, None + ) + if status != 0: + raise win_error(status) + finally: + if owner: + kernel32.LocalFree(owner) + + class _Response: def __init__(self, payload: bytes, url: str) -> None: self.payload = payload @@ -114,19 +266,19 @@ def open_trusted(request: object, *, timeout: float) -> _Response: "https://raw.githubusercontent.com/hungrytech/wikibrain/main/release-policy.json", ) - with patch("wikibrain.version_policy.urlopen", side_effect=open_trusted): - self.assertEqual(_fetch_remote_policy(), _policy()) + with patch("wikibrain.version_policy._open_policy_url", side_effect=open_trusted): + self.assertEqual(_download_remote_policy(), _policy()) request, timeout = requests[0] self.assertIsNone(request.data) self.assertEqual(timeout, 2.0) with patch( - "wikibrain.version_policy.urlopen", + "wikibrain.version_policy._open_policy_url", return_value=_Response(_policy(), "https://example.com/policy.json"), ): with self.assertRaisesRegex(ValueError, "official policy URL"): - _fetch_remote_policy() + _download_remote_policy() for redirected_url in ( "https://raw.githubusercontent.com/attacker/repo/main/release-policy.json", @@ -134,14 +286,64 @@ def open_trusted(request: object, *, timeout: float) -> _Response: ): with self.subTest(redirected_url=redirected_url): with patch( - "wikibrain.version_policy.urlopen", + "wikibrain.version_policy._open_policy_url", return_value=_Response(_policy(), redirected_url), ): with self.assertRaisesRegex(ValueError, "official policy URL"): - _fetch_remote_policy() + _download_remote_policy() self.assertEqual(POLICY_URL, requests[0][0].full_url) + def test_remote_policy_redirect_is_rejected_before_target_request(self) -> None: + import threading + from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer + from urllib.error import HTTPError + + target_hits: list[str] = [] + + class TargetHandler(BaseHTTPRequestHandler): + def do_GET(self) -> None: + target_hits.append(self.path) + self.send_response(200) + self.end_headers() + self.wfile.write(_policy()) + + def log_message(self, format: str, *args: object) -> None: + return None + + target = ThreadingHTTPServer(("127.0.0.1", 0), TargetHandler) + target_url = f"http://127.0.0.1:{target.server_port}/attacker" + + class RedirectHandler(BaseHTTPRequestHandler): + def do_GET(self) -> None: + self.send_response(302) + self.send_header("Location", target_url) + self.end_headers() + + def log_message(self, format: str, *args: object) -> None: + return None + + redirect = ThreadingHTTPServer(("127.0.0.1", 0), RedirectHandler) + threads = [ + threading.Thread(target=target.serve_forever, daemon=True), + threading.Thread(target=redirect.serve_forever, daemon=True), + ] + for thread in threads: + thread.start() + try: + trusted_url = f"http://127.0.0.1:{redirect.server_port}/policy.json" + with patch("wikibrain.version_policy.POLICY_URL", trusted_url): + with self.assertRaises(HTTPError): + _download_remote_policy() + self.assertEqual(target_hits, []) + finally: + redirect.shutdown() + target.shutdown() + redirect.server_close() + target.server_close() + for thread in threads: + thread.join(timeout=1) + def test_truncated_http_body_fails_open_and_is_negatively_cached(self) -> None: class TruncatedResponse: def __enter__(self) -> TruncatedResponse: @@ -159,12 +361,17 @@ def read(self, _limit: int) -> bytes: with tempfile.TemporaryDirectory() as temporary: home = Path(temporary) with patch( - "wikibrain.version_policy.urlopen", + "wikibrain.version_policy._open_policy_url", return_value=TruncatedResponse(), ): - decision = check_release_policy(home, "0.1.6", now=NOW) + decision = check_release_policy( + home, + "0.1.6", + now=NOW, + fetcher=_download_remote_policy, + ) with patch( - "wikibrain.version_policy.urlopen", + "wikibrain.version_policy._open_policy_url", side_effect=AssertionError("negative cache must avoid another request"), ): cached = check_release_policy( @@ -178,6 +385,212 @@ def read(self, _limit: int) -> bytes: self.assertEqual(cached.state, "unavailable") self.assertEqual(cached.source, "cache") + def test_remote_fetch_deadline_terminates_its_worker(self) -> None: + started = time.monotonic() + with patch("wikibrain.version_policy.TOTAL_FETCH_DEADLINE", 0.05): + with self.assertRaises(TimeoutError): + _fetch_remote_policy(child_code="import time; time.sleep(3600)") + self.assertLess(time.monotonic() - started, 0.5) + + def test_remote_fetch_worker_start_failure_is_an_io_failure(self) -> None: + with patch( + "wikibrain.version_policy.subprocess.Popen", + side_effect=RuntimeError("spawn disabled"), + ): + with self.assertRaisesRegex(OSError, "could not start"): + _fetch_remote_policy() + + def test_cleanup_continues_after_lifecycle_and_stream_errors(self) -> None: + class BrokenStream: + def __init__(self) -> None: + self.close_calls = 0 + + def close(self) -> None: + self.close_calls += 1 + raise RuntimeError("close failed") + + class FakeProcess: + def __init__(self) -> None: + self.stdout = BrokenStream() + self.stderr = None + self.stdin = None + self.returncode = None + self.running = True + self.kill_calls = 0 + self.wait_calls = 0 + self.communicate_calls = 0 + + def poll(self) -> int | None: + return None if self.running else -9 + + def kill(self) -> None: + self.kill_calls += 1 + self.running = False + + def terminate(self) -> None: + raise AssertionError("terminate failed") + + def wait(self, timeout: float) -> int: + self.wait_calls += 1 + if self.running: + raise subprocess.TimeoutExpired("worker", timeout) + return -9 + + def communicate(self, timeout: float) -> tuple[bytes, None]: + self.communicate_calls += 1 + if self.communicate_calls == 1: + raise subprocess.TimeoutExpired("worker", timeout) + return b"", None + + process = FakeProcess() + with patch("wikibrain.version_policy.subprocess.Popen", return_value=process): + with patch("wikibrain.version_policy.TOTAL_FETCH_DEADLINE", 0.01): + with self.assertRaises(TimeoutError): + _fetch_remote_policy() + + self.assertGreaterEqual(process.kill_calls, 1) + self.assertGreaterEqual(process.wait_calls, 1) + self.assertEqual(process.communicate_calls, 1) + self.assertEqual(process.stdout.close_calls, 1) + + def test_cleanup_failure_is_surfaced_within_the_absolute_budget(self) -> None: + class UnkillableFakeProcess: + stdout = None + stderr = None + stdin = None + returncode = None + + def poll(self) -> None: + return None + + def kill(self) -> None: + raise OSError("kill failed") + + def terminate(self) -> None: + raise OSError("terminate failed") + + def wait(self, timeout: float) -> int: + raise subprocess.TimeoutExpired("worker", timeout) + + def communicate(self, timeout: float) -> tuple[bytes, None]: + raise subprocess.TimeoutExpired("worker", timeout) + + started = time.monotonic() + with patch( + "wikibrain.version_policy.subprocess.Popen", + return_value=UnkillableFakeProcess(), + ), patch("wikibrain.version_policy.TOTAL_FETCH_DEADLINE", 0.01): + with self.assertRaisesRegex(OSError, "cleanup could not be verified"): + _fetch_remote_policy() + self.assertLess(time.monotonic() - started, 0.2) + + def test_native_reap_bypasses_broken_popen_lifecycle_methods(self) -> None: + process = subprocess.Popen( + [sys.executable, "-c", "import time; time.sleep(3600)"], + stdin=subprocess.DEVNULL, + stdout=subprocess.PIPE, + stderr=subprocess.DEVNULL, + ) + process_id = process.pid + with patch.object(process, "kill", side_effect=OSError("kill failed")), patch.object( + process, "terminate", side_effect=OSError("terminate failed") + ), patch.object(process, "wait", side_effect=OSError("wait failed")): + self.assertTrue( + _cleanup_fetch_process(process, time.monotonic() + 0.5) + ) + self.assertIsNotNone(process.returncode) + if os.name != "nt": + with self.assertRaises(ChildProcessError): + os.waitpid(process_id, os.WNOHANG) + + @unittest.skipIf(os.name == "nt", "POSIX child/FD inspection") + def test_repeated_timeouts_leave_no_child_or_file_descriptor(self) -> None: + def children() -> set[int]: + result = subprocess.run( + ["ps", "-axo", "pid=,ppid="], + check=True, + capture_output=True, + text=True, + ) + candidates = { + int(fields[0]) + for line in result.stdout.splitlines() + if len(fields := line.split()) == 2 and int(fields[1]) == os.getpid() + } + alive: set[int] = set() + for process_id in candidates: + try: + os.kill(process_id, 0) + except ProcessLookupError: + continue + alive.add(process_id) + return alive + + before_children = children() + before_fds = len(os.listdir("/dev/fd")) + for _ in range(10): + with patch("wikibrain.version_policy.TOTAL_FETCH_DEADLINE", 0.02): + with self.assertRaises(TimeoutError): + _fetch_remote_policy(child_code="import time; time.sleep(3600)") + self.assertEqual(children(), before_children) + self.assertEqual(len(os.listdir("/dev/fd")), before_fds) + + @unittest.skipUnless(os.name == "nt", "Windows native handle inspection") + def test_repeated_timeouts_leave_no_windows_process_handles(self) -> None: + import ctypes + import gc + import threading + from ctypes import wintypes + + win_dll = getattr(ctypes, "WinDLL") + win_error = getattr(ctypes, "WinError") + get_last_error = getattr(ctypes, "get_last_error") + kernel32 = win_dll("kernel32", use_last_error=True) + kernel32.GetCurrentProcess.restype = wintypes.HANDLE + kernel32.GetProcessHandleCount.argtypes = [ + wintypes.HANDLE, + ctypes.POINTER(wintypes.DWORD), + ] + kernel32.GetProcessHandleCount.restype = wintypes.BOOL + + def handle_count() -> int: + count = wintypes.DWORD() + if not kernel32.GetProcessHandleCount( + kernel32.GetCurrentProcess(), ctypes.byref(count) + ): + raise win_error(get_last_error()) + return count.value + + with ( + patch("wikibrain.version_policy.FETCH_REQUEST_DEADLINE", 0.02), + patch("wikibrain.version_policy.FETCH_CLEANUP_RESERVE", 0.01), + patch("wikibrain.version_policy.TOTAL_FETCH_DEADLINE", 0.03), + ): + for _ in range(3): + with self.assertRaises(TimeoutError): + _fetch_remote_policy(child_code="import time; time.sleep(3600)") + gc.collect() + baseline_handles = handle_count() + baseline_threads = threading.active_count() + for _ in range(25): + with self.assertRaises(TimeoutError): + _fetch_remote_policy(child_code="import time; time.sleep(3600)") + gc.collect() + + stabilization_deadline = time.monotonic() + 1.0 + current_handles = handle_count() + current_threads = threading.active_count() + while ( + current_handles > baseline_handles + 1 + or current_threads != baseline_threads + ) and time.monotonic() < stabilization_deadline: + time.sleep(0.01) + gc.collect() + current_handles = handle_count() + current_threads = threading.active_count() + self.assertLessEqual(current_handles, baseline_handles + 1) + self.assertEqual(current_threads, baseline_threads) + def test_schema_rejects_bool_float_and_duplicate_keys(self) -> None: valid_fields = ( '"latest_version":"0.1.7",' @@ -287,6 +700,352 @@ def test_cache_rejects_unexpected_top_level_fields_and_refetches(self) -> None: self.assertFalse(decision.upgrade_required) self.assertEqual(decision.source, "remote") + def test_cache_rejects_mismatched_current_and_last_accepted_policy(self) -> None: + with tempfile.TemporaryDirectory() as temporary: + home = Path(temporary) + home.joinpath(CACHE_NAME).write_text( + json.dumps( + { + "schema_version": 2, + "checked_at": NOW.isoformat().replace("+00:00", "Z"), + "policy": json.loads(_policy()), + "last_accepted_policy": json.loads( + _policy(latest="9.0.0", minimum="9.0.0") + ), + } + ), + encoding="utf-8", + ) + decision = check_release_policy( + home, + "0.1.7", + now=NOW, + fetcher=lambda: _policy(), + ) + + self.assertEqual(decision.state, "supported") + self.assertEqual(decision.source, "remote") + + @unittest.skipIf(os.name == "nt", "POSIX ownership and mode contract") + def test_cache_rejects_symlinks_and_group_or_other_writable_files(self) -> None: + poisoned = { + "schema_version": 1, + "checked_at": NOW.isoformat().replace("+00:00", "Z"), + "policy": json.loads(_policy(latest="9.0.0", minimum="9.0.0")), + } + for attack in ("symlink", "writable"): + with self.subTest(attack=attack), tempfile.TemporaryDirectory() as temporary: + home = Path(temporary) + cache = home / "release-policy-cache.json" + if attack == "symlink": + target = home / "attacker-controlled.json" + target.write_text(json.dumps(poisoned), encoding="utf-8") + cache.symlink_to(target) + else: + cache.write_text(json.dumps(poisoned), encoding="utf-8") + cache.chmod(0o666) + + decision = check_release_policy( + home, + "0.1.7", + now=NOW, + fetcher=lambda: _policy(latest="0.1.7", minimum="0.1.7"), + ) + + self.assertEqual(decision.state, "supported") + self.assertEqual(decision.source, "remote") + + @unittest.skipUnless(sys.platform == "darwin", "macOS extended ACL contract") + def test_cache_rejects_a_macos_extended_acl(self) -> None: + with tempfile.TemporaryDirectory() as temporary: + cache = Path(temporary) / CACHE_NAME + cache.write_text("{}", encoding="utf-8") + cache.chmod(0o600) + subprocess.run( + ["chmod", "+a", "everyone allow write", str(cache)], + check=True, + ) + with self.assertRaisesRegex(OSError, "extended ACL"): + _open_trusted_cache(cache, cache.parent) + + @unittest.skipUnless(sys.platform.startswith("linux"), "Linux ACL contract") + def test_cache_rejects_a_native_linux_posix_acl(self) -> None: + import shutil + + if shutil.which("setfacl") is None: + self.skipTest("setfacl is unavailable") + with tempfile.TemporaryDirectory() as temporary: + cache = Path(temporary) / CACHE_NAME + cache.write_text("{}", encoding="utf-8") + cache.chmod(0o600) + subprocess.run( + ["setfacl", "-m", "u:65534:r--", str(cache)], + check=True, + ) + with self.assertRaisesRegex(OSError, "extended ACL"): + _open_trusted_cache(cache, cache.parent) + + @unittest.skipUnless(sys.platform.startswith("linux"), "Linux ACL contract") + def test_linux_acl_markers_and_inspection_errors_fail_closed(self) -> None: + with tempfile.TemporaryFile() as cache: + for marker in ( + "system.posix_acl_access", + "system.nfs4_acl", + "security.nfs4_acl", + "trusted.nfs4_acl", + "system.richacl", + "trusted.sgi_acl_file", + ): + with self.subTest(marker=marker), patch( + "wikibrain.version_policy.os.listxattr", + return_value=[marker], + ): + self.assertTrue(_fd_has_extended_acl(cache.fileno())) + with patch( + "wikibrain.version_policy.os.listxattr", + side_effect=OSError("inspection failed"), + ): + with self.assertRaisesRegex(OSError, "inspection failed"): + _fd_has_extended_acl(cache.fileno()) + with patch("wikibrain.version_policy.os.listxattr", None): + with self.assertRaisesRegex(OSError, "inspection is unavailable"): + _fd_has_extended_acl(cache.fileno()) + + @unittest.skipUnless(os.name == "nt", "Windows reparse contract") + def test_windows_cache_rejects_final_reparse_points_and_junction_escapes( + self, + ) -> None: + with tempfile.TemporaryDirectory(dir=Path.home()) as temporary: + root = Path(temporary) + trusted_home = root / "trusted" + outside = root / "outside" + trusted_home.mkdir() + outside.mkdir() + + target = trusted_home / "target.json" + target.write_bytes(b"target") + linked_cache = trusted_home / CACHE_NAME + linked_cache.symlink_to(target) + with self.assertRaisesRegex(OSError, "reparse point"): + _open_trusted_cache(linked_cache, trusted_home) + linked_cache.unlink() + + outside_cache = outside / CACHE_NAME + outside_cache.write_bytes(b"outside") + junction = trusted_home / "escape" + subprocess.run( + ["cmd", "/c", "mklink", "/J", str(junction), str(outside)], + check=True, + capture_output=True, + text=True, + ) + with self.assertRaisesRegex(OSError, "outside"): + _open_trusted_cache(junction / CACHE_NAME, trusted_home) + + @unittest.skipUnless(os.name == "nt", "Windows DACL contract") + def test_windows_cache_rejects_everyone_write_and_null_dacl(self) -> None: + with tempfile.TemporaryDirectory(dir=Path.home()) as temporary: + root = Path(temporary) + everyone_cache = root / "everyone.json" + everyone_cache.write_bytes(b"unsafe") + subprocess.run( + ["icacls", str(everyone_cache), "/grant", "*S-1-1-0:(W)"], + check=True, + capture_output=True, + text=True, + ) + with self.assertRaisesRegex(OSError, "another principal"): + _open_trusted_cache(everyone_cache, root) + + null_dacl_cache = root / "null-dacl.json" + null_dacl_cache.write_bytes(b"unsafe") + _set_windows_security(null_dacl_cache, null_dacl=True) + with self.assertRaisesRegex(OSError, "null DACL"): + _open_trusted_cache(null_dacl_cache, root) + + @unittest.skipUnless(os.name == "nt", "Windows owner contract") + def test_windows_cache_rejects_foreign_owner(self) -> None: + with tempfile.TemporaryDirectory(dir=Path.home()) as temporary: + cache = Path(temporary) / CACHE_NAME + cache.write_bytes(b"unsafe") + _set_windows_security(cache, owner_sid="S-1-5-18") + with self.assertRaisesRegex(OSError, "untrusted owner"): + _open_trusted_cache(cache, cache.parent) + + @unittest.skipUnless(os.name == "nt", "Windows handle and DACL contract") + def test_windows_handle_validation_accepts_a_private_user_cache(self) -> None: + with tempfile.TemporaryDirectory(dir=Path.home()) as temporary: + cache = Path(temporary) / CACHE_NAME + cache.write_bytes(b"trusted") + with _open_trusted_cache(cache, cache.parent) as handle: + self.assertEqual(handle.read(), b"trusted") + + @unittest.skipUnless(os.name == "nt", "Windows CRT descriptor contract") + def test_windows_fdopen_failure_closes_the_transferred_descriptor(self) -> None: + with tempfile.TemporaryDirectory(dir=Path.home()) as temporary: + cache = Path(temporary) / CACHE_NAME + cache.write_bytes(b"trusted") + with patch( + "wikibrain.windows_cache.os.fdopen", + side_effect=RuntimeError("fdopen failed"), + ), patch( + "wikibrain.windows_cache.os.close", wraps=os.close + ) as close_descriptor: + with self.assertRaisesRegex(RuntimeError, "fdopen failed"): + _open_trusted_cache(cache, cache.parent) + close_descriptor.assert_called_once() + + @unittest.skipUnless(os.name == "nt", "Windows custom-home contract") + def test_windows_cache_accepts_a_private_configured_home_outside_profile( + self, + ) -> None: + with tempfile.TemporaryDirectory(dir=ROOT) as temporary: + configured_home = Path(temporary) + try: + configured_home.resolve().relative_to(Path.home().resolve()) + except ValueError: + pass + else: + self.skipTest("repository is inside the Windows user profile") + _secure_windows_directory(configured_home) + cache = configured_home / CACHE_NAME + cache.write_bytes(b"trusted") + with _open_trusted_cache(cache, configured_home) as handle: + self.assertEqual(handle.read(), b"trusted") + + @unittest.skipIf(os.name == "nt", "POSIX ownership contract") + def test_cache_rejects_a_foreign_owner(self) -> None: + with tempfile.TemporaryDirectory() as temporary: + cache = Path(temporary) / CACHE_NAME + cache.write_text("{}", encoding="utf-8") + metadata = cache.stat() + foreign_metadata = SimpleNamespace( + st_mode=metadata.st_mode, + st_uid=os.getuid() + 1, + ) + with patch( + "wikibrain.version_policy.os.fstat", + return_value=foreign_metadata, + ): + with self.assertRaisesRegex(OSError, "not owned"): + _open_trusted_cache(cache, cache.parent) + + def test_policy_rejects_pre_schema_epoch_and_excessive_future_timestamp(self) -> None: + for updated_at in ( + "1970-01-01T00:00:00Z", + "2026-07-22T12:05:01Z", + "9999-01-01T00:00:00Z", + ): + with self.subTest(updated_at=updated_at): + with self.assertRaises(ValueError): + parse_release_policy(_policy(updated_at=updated_at), now=NOW) + + def test_remote_policy_rollback_is_rejected_against_stale_accepted_policy(self) -> None: + with tempfile.TemporaryDirectory() as temporary: + home = Path(temporary) + accepted = check_release_policy( + home, + "0.1.7", + now=NOW, + fetcher=lambda: _policy( + latest="0.1.7", + minimum="0.1.7", + updated_at="2026-07-22T12:00:00Z", + ), + ) + rollback = check_release_policy( + home, + "0.1.7", + now=NOW + CACHE_TTL + timedelta(seconds=1), + fetcher=lambda: _policy( + latest="9.0.0", + minimum="9.0.0", + updated_at="2026-07-22T11:59:59Z", + ), + ) + negatively_cached = check_release_policy( + home, + "0.1.7", + now=NOW + CACHE_TTL + timedelta(minutes=1), + fetcher=lambda: self.fail("rollback failure must be negatively cached"), + ) + repeated_rollback = check_release_policy( + home, + "0.1.7", + now=NOW + (CACHE_TTL * 2) + timedelta(seconds=2), + fetcher=lambda: _policy( + latest="10.0.0", + minimum="10.0.0", + updated_at="2026-07-22T11:00:00Z", + ), + ) + + self.assertEqual(accepted.state, "supported") + self.assertEqual(rollback.state, "unavailable") + self.assertEqual(rollback.source, "remote-error") + self.assertEqual(negatively_cached.source, "cache") + self.assertEqual(repeated_rollback.state, "unavailable") + self.assertEqual(repeated_rollback.source, "remote-error") + + def test_clock_regression_does_not_delete_the_rollback_floor(self) -> None: + with tempfile.TemporaryDirectory() as temporary: + home = Path(temporary) + future_now = NOW + timedelta(days=1) + accepted = check_release_policy( + home, + "0.1.7", + now=future_now, + fetcher=lambda: _policy( + updated_at="2026-07-23T12:00:00Z", + ), + ) + offline_after_clock_rollback = check_release_policy( + home, + "0.1.7", + now=NOW, + fetcher=lambda: (_ for _ in ()).throw(OSError("offline")), + ) + rejected_old_policy = check_release_policy( + home, + "0.1.7", + now=future_now + CACHE_TTL + timedelta(seconds=1), + fetcher=lambda: _policy( + latest="9.0.0", + minimum="9.0.0", + updated_at="2026-07-22T12:00:00Z", + ), + ) + + self.assertEqual(accepted.state, "supported") + self.assertEqual(offline_after_clock_rollback.state, "unavailable") + self.assertEqual(rejected_old_policy.state, "unavailable") + self.assertEqual(rejected_old_policy.source, "remote-error") + + def test_unexpected_runtime_fetch_failure_fails_open(self) -> None: + for failure in ( + RuntimeError("no workers"), + subprocess.SubprocessError("subprocess failed"), + ): + with self.subTest(failure=type(failure).__name__), tempfile.TemporaryDirectory() as temporary: + decision = check_release_policy( + Path(temporary), + "0.1.7", + now=NOW, + fetcher=lambda failure=failure: (_ for _ in ()).throw(failure), + ) + self.assertEqual(decision.state, "unavailable") + self.assertEqual(decision.source, "remote-error") + + def test_naive_now_is_rejected(self) -> None: + with tempfile.TemporaryDirectory() as temporary: + with self.assertRaisesRegex(ValueError, "timezone-aware"): + check_release_policy( + Path(temporary), + "0.1.7", + now=datetime(2026, 7, 22, 12, 0), + fetcher=_policy, + ) + def test_remote_policy_blocks_a_version_below_the_minimum_and_is_cached(self) -> None: with tempfile.TemporaryDirectory() as temporary: home = Path(temporary) diff --git a/uv.lock b/uv.lock index c1bda0d..c9233d1 100644 --- a/uv.lock +++ b/uv.lock @@ -59,7 +59,7 @@ wheels = [ [[package]] name = "wikibrain-agent" -version = "0.1.7" +version = "0.1.8" source = { editable = "." } dependencies = [ { name = "pyyaml" },