From ae55ce04a362d4c0dab25b09e31d24f8dd1d0b04 Mon Sep 17 00:00:00 2001
From: hungrytech <74886812+hungrytech@users.noreply.github.com>
Date: Thu, 23 Jul 2026 00:13:05 +0900
Subject: [PATCH 01/16] fix: harden release policy enforcement
---
.github/workflows/ci.yml | 40 ++-
CHANGELOG.md | 17 +
README.ja.md | 4 +-
README.ko.md | 4 +-
README.md | 4 +-
README.zh-CN.md | 4 +-
benchmarks/results/retrieval-quality-v1.json | 12 +-
benchmarks/results/second-brain-v1.json | 12 +-
.../assets/benchmark-retrieval-quality-v1.svg | 2 +-
docs/assets/benchmark-second-brain-v1.svg | 2 +-
packaging/homebrew/README.md | 4 +-
plugins/wikibrain/.codex-plugin/plugin.json | 2 +-
pyproject.toml | 8 +-
release-policy.json | 4 +-
scripts/install-windows.ps1 | 2 +-
src/wikibrain/__init__.py | 2 +-
src/wikibrain/version_policy.py | 240 +++++++++++--
src/wikibrain/windows_cache.py | 325 ++++++++++++++++++
tests/test_version_policy.py | 288 +++++++++++++++-
uv.lock | 2 +-
20 files changed, 905 insertions(+), 73 deletions(-)
create mode 100644 src/wikibrain/windows_cache.py
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index cfeb847..48a9794 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -43,12 +43,16 @@ jobs:
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 +70,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 +79,7 @@ jobs:
-SkipPythonInstall
- name: Verify native Windows hooks
+ if: matrix.python-version == '3.13'
shell: pwsh
run: |
$pipxBin = (
@@ -135,3 +141,35 @@ 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
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 80b9997..e79b788 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -6,6 +6,23 @@ The project follows [Semantic Versioning](https://semver.org/).
## [Unreleased]
+## [0.1.8] - 2026-07-22
+
+### Security
+
+- Bound official release-policy retrieval by a monotonic 2.5-second total deadline
+ in a killable worker process, in addition to the socket timeout and response-size
+ limit; deadline expiry terminates and joins the worker so slow-drip responses
+ cannot leave commands, hooks, threads, or sockets behind.
+- Validate cached policy files before reading them: POSIX caches must be non-symlink
+ regular files owned by the current user 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 user-profile 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..80e73fb 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-22T15:12:49.498996+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",
- "git_dirty": false
+ "source_manifest_sha256": "dd6e09475e08cff973ff7c4233c872a4cad5ab9a6d80173ef66652b237e41e0b",
+ "git_commit": "3b9f7c59872f82a286675a982a2468ce41f229cc",
+ "git_dirty": true
}
}
diff --git a/benchmarks/results/second-brain-v1.json b/benchmarks/results/second-brain-v1.json
index 88fd067..7cf1442 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,12 +20,12 @@
"forbidden_atoms": 4
},
"provenance": {
- "generated_at": "2026-07-22T13:51:40+00:00",
+ "generated_at": "2026-07-22T15:12:47+00:00",
"corpus_version": "second-brain-corpus-v1",
"runner_sha256": "666510b248680357cebe9d70d42f07a7d0619522658fbb0810989aec449ec2aa",
- "source_manifest_sha256": "f9fc90c63885c104a5dd439967a5e2bc91455720364d4957d9a26b542843e4db",
- "git_commit": "2475e281a5dce0b3320af0e09b3561b7ad02e643",
- "git_dirty": false,
+ "source_manifest_sha256": "3a7d9fc4d91992562008f4270522be7189bf42520e1bb15578b72f497887674b",
+ "git_commit": "3b9f7c59872f82a286675a982a2468ce41f229cc",
+ "git_dirty": true,
"reproduction_command": "uv run python -m benchmarks.second_brain --wikimap wikimap --format json --output benchmarks/results/second-brain-v1.json"
},
"cases": [
diff --git a/docs/assets/benchmark-retrieval-quality-v1.svg b/docs/assets/benchmark-retrieval-quality-v1.svg
index fe86411..18eb049 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
-
+
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..316c382 100644
--- a/src/wikibrain/version_policy.py
+++ b/src/wikibrain/version_policy.py
@@ -1,12 +1,16 @@
from __future__ import annotations
import json
+import multiprocessing
+import os
import re
+import stat
+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 typing import BinaryIO, Callable
from urllib.request import Request, urlopen
from .config import atomic_write_text
@@ -20,6 +24,10 @@
CACHE_NAME = "release-policy-cache.json"
MAX_POLICY_BYTES = 64 * 1024
MAX_CACHE_BYTES = 128 * 1024
+SOCKET_TIMEOUT = 2.0
+TOTAL_FETCH_DEADLINE = 2.5
+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 +57,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 +102,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 +130,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 +147,7 @@ def parse_release_policy(payload: bytes) -> ReleasePolicy:
)
-def _fetch_remote_policy() -> bytes:
+def _download_remote_policy() -> bytes:
request = Request(
POLICY_URL,
headers={
@@ -127,7 +155,7 @@ def _fetch_remote_policy() -> bytes:
"User-Agent": "wikibrain-version-policy",
},
)
- with urlopen(request, timeout=2.0) as response:
+ with urlopen(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 +164,77 @@ def _fetch_remote_policy() -> bytes:
return payload
+def _policy_fetch_process(
+ connection: object,
+ worker: Callable[[], bytes],
+) -> None:
+ try:
+ payload = worker()
+ connection.send_bytes(b"\x01" + payload) # type: ignore[attr-defined]
+ except BaseException as exc:
+ error = f"{type(exc).__name__}: {exc}".encode("utf-8", errors="replace")
+ try:
+ connection.send_bytes(b"\x00" + error[:4096]) # type: ignore[attr-defined]
+ except (BrokenPipeError, EOFError, OSError):
+ pass
+ finally:
+ connection.close() # type: ignore[attr-defined]
+
+
+def _stop_fetch_process(process: multiprocessing.Process) -> None:
+ if process.is_alive():
+ process.terminate()
+ process.join(0.2)
+ if process.is_alive() and hasattr(process, "kill"):
+ process.kill()
+ process.join(0.2)
+ if process.is_alive():
+ raise RuntimeError("release policy worker could not be stopped")
+
+
+def _fetch_remote_policy(
+ *,
+ worker: Callable[[], bytes] = _download_remote_policy,
+) -> bytes:
+ context = multiprocessing.get_context("spawn")
+ receiver, sender = context.Pipe(duplex=False)
+ process = context.Process(
+ target=_policy_fetch_process,
+ args=(sender, worker),
+ name="wikibrain-release-policy-fetch",
+ daemon=True,
+ )
+ started = time.monotonic()
+ started_process = False
+ try:
+ try:
+ process.start()
+ started_process = True
+ except Exception as exc:
+ raise OSError("could not start release policy worker") from exc
+ finally:
+ sender.close()
+
+ remaining = TOTAL_FETCH_DEADLINE - (time.monotonic() - started)
+ if remaining <= 0 or not receiver.poll(remaining):
+ raise TimeoutError("release policy request exceeded its total deadline")
+ try:
+ message = receiver.recv_bytes(MAX_POLICY_BYTES + 4097)
+ except (EOFError, OSError) as exc:
+ raise OSError("release policy worker exited without a result") from exc
+ if not message or message[0] != 1:
+ detail = message[1:].decode("utf-8", errors="replace") if message else ""
+ raise OSError(f"release policy request failed: {detail}")
+ return message[1:]
+ finally:
+ receiver.close()
+ if started_process:
+ remaining = TOTAL_FETCH_DEADLINE - (time.monotonic() - started)
+ process.join(max(0.0, remaining))
+ _stop_fetch_process(process)
+ process.close()
+
+
def _decision(
policy: ReleasePolicy,
current_version: str,
@@ -165,44 +264,103 @@ def _unavailable(current_version: str, *, source: str) -> PolicyDecision:
)
-def _read_fresh_cache(
- path: Path,
- current_version: str,
- now: datetime,
-) -> PolicyDecision | None:
+def _windows_cache_path_is_private(path: Path, user_home: Path) -> bool:
try:
- with path.open("rb") as cache_file:
+ path.resolve(strict=True).relative_to(user_home.resolve(strict=True))
+ except (OSError, ValueError):
+ return False
+ return not path.is_symlink() and path.is_file()
+
+
+def _open_trusted_cache(path: Path) -> BinaryIO:
+ if os.name == "nt":
+ from wikibrain.windows_cache import open_trusted_windows_cache
+
+ return open_trusted_windows_cache(path, Path.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")
+ return os.fdopen(descriptor, "rb")
+ except Exception:
+ os.close(descriptor)
+ raise
+
+
+def _read_cache(path: Path, now: datetime) -> _CacheEntry | None:
+ try:
+ with _open_trusted_cache(path) 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 +372,46 @@ def check_release_policy(
now: datetime | None = None,
fetcher: Callable[[], bytes] | None = None,
) -> PolicyDecision:
- checked_at = (now or datetime.now(UTC)).astimezone(UTC)
+ 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)
cache_path = home.expanduser().resolve() / CACHE_NAME
- cached = _read_fresh_cache(cache_path, current_version, checked_at)
+ cached = _read_cache(cache_path, 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,
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..e39d4ad
--- /dev/null
+++ b/src/wikibrain/windows_cache.py
@@ -0,0 +1,325 @@
+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.EqualSid.argtypes = [ctypes.c_void_p, ctypes.c_void_p]
+ advapi32.EqualSid.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
+ if not advapi32.EqualSid(owner_sid, current_sid):
+ raise WindowsCacheTrustError(
+ "release policy cache is not owned by the current user"
+ )
+
+ 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))
+
+ allowed_writers = {
+ sid_string(current_sid),
+ "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
+ return os.fdopen(descriptor, "rb")
+ 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..c2b9e17 100644
--- a/tests/test_version_policy.py
+++ b/tests/test_version_policy.py
@@ -3,6 +3,7 @@
import json
import os
import tempfile
+import time
import unittest
from argparse import Namespace
from contextlib import redirect_stderr
@@ -10,15 +11,20 @@
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,
+ _download_remote_policy,
_fetch_remote_policy,
+ _open_trusted_cache,
+ _windows_cache_path_is_private,
check_release_policy,
parse_release_policy,
)
@@ -28,13 +34,18 @@
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()
@@ -57,6 +68,11 @@ def read(self, limit: int) -> bytes:
return self.payload[:limit]
+def _blocking_policy_worker() -> bytes:
+ while True:
+ time.sleep(1)
+
+
class VersionPolicyTests(unittest.TestCase):
def test_repository_policy_is_valid_and_does_not_reject_current_release(self) -> None:
policy = parse_release_policy((ROOT / "release-policy.json").read_bytes())
@@ -115,7 +131,7 @@ def open_trusted(request: object, *, timeout: float) -> _Response:
)
with patch("wikibrain.version_policy.urlopen", side_effect=open_trusted):
- self.assertEqual(_fetch_remote_policy(), _policy())
+ self.assertEqual(_download_remote_policy(), _policy())
request, timeout = requests[0]
self.assertIsNone(request.data)
@@ -126,7 +142,7 @@ def open_trusted(request: object, *, timeout: float) -> _Response:
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",
@@ -138,7 +154,7 @@ def open_trusted(request: object, *, timeout: float) -> _Response:
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)
@@ -162,7 +178,12 @@ def read(self, _limit: int) -> bytes:
"wikibrain.version_policy.urlopen",
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",
side_effect=AssertionError("negative cache must avoid another request"),
@@ -178,6 +199,44 @@ 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(worker=_blocking_policy_worker)
+ self.assertLess(time.monotonic() - started, 0.5)
+ self.assertFalse(
+ any(
+ child.name == "wikibrain-release-policy-fetch"
+ for child in __import__("multiprocessing").active_children()
+ )
+ )
+
+ def test_remote_fetch_worker_start_failure_is_an_io_failure(self) -> None:
+ class Endpoint:
+ def close(self) -> None:
+ pass
+
+ class FakeProcess:
+ def start(self) -> None:
+ raise RuntimeError("spawn disabled")
+
+ def close(self) -> None:
+ pass
+
+ class Context:
+ def Pipe(self, *, duplex: bool) -> tuple[Endpoint, Endpoint]:
+ self.duplex = duplex
+ return Endpoint(), Endpoint()
+
+ def Process(self, **kwargs: object) -> FakeProcess:
+ self.process_kwargs = kwargs
+ return FakeProcess()
+
+ with patch("wikibrain.version_policy.multiprocessing.get_context", return_value=Context()):
+ with self.assertRaisesRegex(OSError, "could not start"):
+ _fetch_remote_policy()
+
def test_schema_rejects_bool_float_and_duplicate_keys(self) -> None:
valid_fields = (
'"latest_version":"0.1.7",'
@@ -287,6 +346,223 @@ 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")
+
+ def test_windows_cache_policy_requires_a_regular_file_in_user_profile(self) -> None:
+ with tempfile.TemporaryDirectory() as temporary:
+ root = Path(temporary)
+ profile = root / "profile"
+ profile.mkdir()
+ private_cache = profile / ".wikibrain" / "release-policy-cache.json"
+ private_cache.parent.mkdir()
+ private_cache.write_text("{}", encoding="utf-8")
+ shared_cache = root / "shared" / "release-policy-cache.json"
+ shared_cache.parent.mkdir()
+ shared_cache.write_text("{}", encoding="utf-8")
+
+ self.assertTrue(
+ _windows_cache_path_is_private(private_cache, profile)
+ )
+ self.assertFalse(
+ _windows_cache_path_is_private(shared_cache, profile)
+ )
+ if os.name != "nt":
+ symlink_cache = profile / ".wikibrain" / "linked-cache.json"
+ symlink_cache.symlink_to(shared_cache)
+ self.assertFalse(
+ _windows_cache_path_is_private(symlink_cache, profile)
+ )
+
+ @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) 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)
+
+ 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:
+ with tempfile.TemporaryDirectory() as temporary:
+ decision = check_release_policy(
+ Path(temporary),
+ "0.1.7",
+ now=NOW,
+ fetcher=lambda: (_ for _ in ()).throw(RuntimeError("no workers")),
+ )
+ 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" },
From 34e10f1a1c88195f6fce245b94049e4885c94174 Mon Sep 17 00:00:00 2001
From: hungrytech <74886812+hungrytech@users.noreply.github.com>
Date: Thu, 23 Jul 2026 00:18:59 +0900
Subject: [PATCH 02/16] docs: record v0.1.8 benchmark provenance
---
benchmarks/results/retrieval-quality-v1.json | 6 +++---
benchmarks/results/second-brain-v1.json | 6 +++---
docs/assets/benchmark-retrieval-quality-v1.svg | 2 +-
3 files changed, 7 insertions(+), 7 deletions(-)
diff --git a/benchmarks/results/retrieval-quality-v1.json b/benchmarks/results/retrieval-quality-v1.json
index 80e73fb..b00845c 100644
--- a/benchmarks/results/retrieval-quality-v1.json
+++ b/benchmarks/results/retrieval-quality-v1.json
@@ -224,7 +224,7 @@
}
],
"wikimap_version": "wikimap 1.1.0",
- "generated_at": "2026-07-22T15:12:49.498996+00:00",
+ "generated_at": "2026-07-22T15:18:14.082875+00:00",
"environment": {
"machine": "arm64",
"platform": "macOS-26.5.2-arm64-arm-64bit-Mach-O",
@@ -233,7 +233,7 @@
"provenance": {
"corpus_sha256": "adc729bcb6a8a8027d11176601046eaeef022bde135c7f225f8e40b78f7a998c",
"source_manifest_sha256": "dd6e09475e08cff973ff7c4233c872a4cad5ab9a6d80173ef66652b237e41e0b",
- "git_commit": "3b9f7c59872f82a286675a982a2468ce41f229cc",
- "git_dirty": true
+ "git_commit": "ae55ce04a362d4c0dab25b09e31d24f8dd1d0b04",
+ "git_dirty": false
}
}
diff --git a/benchmarks/results/second-brain-v1.json b/benchmarks/results/second-brain-v1.json
index 7cf1442..8ed8301 100644
--- a/benchmarks/results/second-brain-v1.json
+++ b/benchmarks/results/second-brain-v1.json
@@ -20,12 +20,12 @@
"forbidden_atoms": 4
},
"provenance": {
- "generated_at": "2026-07-22T15:12:47+00:00",
+ "generated_at": "2026-07-22T15:18:10+00:00",
"corpus_version": "second-brain-corpus-v1",
"runner_sha256": "666510b248680357cebe9d70d42f07a7d0619522658fbb0810989aec449ec2aa",
"source_manifest_sha256": "3a7d9fc4d91992562008f4270522be7189bf42520e1bb15578b72f497887674b",
- "git_commit": "3b9f7c59872f82a286675a982a2468ce41f229cc",
- "git_dirty": true,
+ "git_commit": "ae55ce04a362d4c0dab25b09e31d24f8dd1d0b04",
+ "git_dirty": false,
"reproduction_command": "uv run python -m benchmarks.second_brain --wikimap wikimap --format json --output benchmarks/results/second-brain-v1.json"
},
"cases": [
diff --git a/docs/assets/benchmark-retrieval-quality-v1.svg b/docs/assets/benchmark-retrieval-quality-v1.svg
index 18eb049..9747a6a 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
-
+
From 8673ba090103af7a76144c1e82e31a67933e7002 Mon Sep 17 00:00:00 2001
From: hungrytech <74886812+hungrytech@users.noreply.github.com>
Date: Thu, 23 Jul 2026 00:45:50 +0900
Subject: [PATCH 03/16] fix: close release policy lifecycle gaps
---
.github/workflows/ci.yml | 26 +++
CHANGELOG.md | 18 +-
benchmarks/results/retrieval-quality-v1.json | 8 +-
benchmarks/results/second-brain-v1.json | 8 +-
.../assets/benchmark-retrieval-quality-v1.svg | 2 +-
src/wikibrain/version_policy.py | 184 +++++++++++------
src/wikibrain/windows_cache.py | 9 +-
tests/test_version_policy.py | 185 ++++++++++++++----
8 files changed, 326 insertions(+), 114 deletions(-)
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 48a9794..ac63c51 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -41,6 +41,32 @@ 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/*
+
windows:
runs-on: windows-latest
strategy:
diff --git a/CHANGELOG.md b/CHANGELOG.md
index e79b788..c6bb838 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -6,18 +6,20 @@ The project follows [Semantic Versioning](https://semver.org/).
## [Unreleased]
-## [0.1.8] - 2026-07-22
+## [0.1.8] - 2026-07-23
### Security
-- Bound official release-policy retrieval by a monotonic 2.5-second total deadline
- in a killable worker process, in addition to the socket timeout and response-size
- limit; deadline expiry terminates and joins the worker so slow-drip responses
- cannot leave commands, hooks, threads, or sockets behind.
+- Bound official release-policy retrieval by a monotonic 2.5-second network deadline
+ in a directly managed, killable subprocess without a persistent multiprocessing
+ helper; deadline expiry kills and reaps the worker and independently closes IPC
+ handles so slow-drip responses cannot leave commands, hooks, workers, or sockets
+ behind.
- Validate cached policy files before reading them: POSIX caches must be non-symlink
- regular files owned by the current user 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 user-profile containment, owner SID, and DACL.
+ 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
diff --git a/benchmarks/results/retrieval-quality-v1.json b/benchmarks/results/retrieval-quality-v1.json
index b00845c..5a3c05c 100644
--- a/benchmarks/results/retrieval-quality-v1.json
+++ b/benchmarks/results/retrieval-quality-v1.json
@@ -224,7 +224,7 @@
}
],
"wikimap_version": "wikimap 1.1.0",
- "generated_at": "2026-07-22T15:18:14.082875+00:00",
+ "generated_at": "2026-07-22T15:44:54.984042+00:00",
"environment": {
"machine": "arm64",
"platform": "macOS-26.5.2-arm64-arm-64bit-Mach-O",
@@ -232,8 +232,8 @@
},
"provenance": {
"corpus_sha256": "adc729bcb6a8a8027d11176601046eaeef022bde135c7f225f8e40b78f7a998c",
- "source_manifest_sha256": "dd6e09475e08cff973ff7c4233c872a4cad5ab9a6d80173ef66652b237e41e0b",
- "git_commit": "ae55ce04a362d4c0dab25b09e31d24f8dd1d0b04",
- "git_dirty": false
+ "source_manifest_sha256": "84b48e997c8a6b5c3f34a4115834b95257695212a114791d16beecc860d38a28",
+ "git_commit": "34e10f1a1c88195f6fce245b94049e4885c94174",
+ "git_dirty": true
}
}
diff --git a/benchmarks/results/second-brain-v1.json b/benchmarks/results/second-brain-v1.json
index 8ed8301..5934d01 100644
--- a/benchmarks/results/second-brain-v1.json
+++ b/benchmarks/results/second-brain-v1.json
@@ -20,12 +20,12 @@
"forbidden_atoms": 4
},
"provenance": {
- "generated_at": "2026-07-22T15:18:10+00:00",
+ "generated_at": "2026-07-22T15:44:52+00:00",
"corpus_version": "second-brain-corpus-v1",
"runner_sha256": "666510b248680357cebe9d70d42f07a7d0619522658fbb0810989aec449ec2aa",
- "source_manifest_sha256": "3a7d9fc4d91992562008f4270522be7189bf42520e1bb15578b72f497887674b",
- "git_commit": "ae55ce04a362d4c0dab25b09e31d24f8dd1d0b04",
- "git_dirty": false,
+ "source_manifest_sha256": "7358680fc2946fe274789b9e0aee63a9b3a6f623f09e28dd0d3014ea99360328",
+ "git_commit": "34e10f1a1c88195f6fce245b94049e4885c94174",
+ "git_dirty": true,
"reproduction_command": "uv run python -m benchmarks.second_brain --wikimap wikimap --format json --output benchmarks/results/second-brain-v1.json"
},
"cases": [
diff --git a/docs/assets/benchmark-retrieval-quality-v1.svg b/docs/assets/benchmark-retrieval-quality-v1.svg
index 9747a6a..c10e735 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
-
+
diff --git a/src/wikibrain/version_policy.py b/src/wikibrain/version_policy.py
index 316c382..b55ea31 100644
--- a/src/wikibrain/version_policy.py
+++ b/src/wikibrain/version_policy.py
@@ -1,10 +1,11 @@
from __future__ import annotations
import json
-import multiprocessing
import os
import re
import stat
+import subprocess
+import sys
import time
from dataclasses import asdict, dataclass
from datetime import UTC, datetime, timedelta
@@ -164,75 +165,92 @@ def _download_remote_policy() -> bytes:
return payload
-def _policy_fetch_process(
- connection: object,
- worker: Callable[[], bytes],
-) -> None:
+def _default_fetch_child_code() -> str:
+ source_root = str(Path(__file__).resolve().parent.parent)
+ return (
+ "import sys;"
+ 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:
- payload = worker()
- connection.send_bytes(b"\x01" + payload) # type: ignore[attr-defined]
- except BaseException as exc:
- error = f"{type(exc).__name__}: {exc}".encode("utf-8", errors="replace")
+ return process.poll() is None
+ except BaseException:
+ return True
+
+
+def _cleanup_fetch_process(process: subprocess.Popen[bytes]) -> None:
+ # Keep each cleanup operation independent. A broken close/wait method must not
+ # prevent later kill, reap, and descriptor-close attempts.
+ for method_name in ("kill", "terminate", "kill"):
+ if not _process_is_running(process):
+ break
try:
- connection.send_bytes(b"\x00" + error[:4096]) # type: ignore[attr-defined]
- except (BrokenPipeError, EOFError, OSError):
+ getattr(process, method_name)()
+ except BaseException:
+ pass
+ try:
+ process.wait(timeout=0.1)
+ except BaseException:
pass
- finally:
- connection.close() # type: ignore[attr-defined]
-
-def _stop_fetch_process(process: multiprocessing.Process) -> None:
- if process.is_alive():
- process.terminate()
- process.join(0.2)
- if process.is_alive() and hasattr(process, "kill"):
- process.kill()
- process.join(0.2)
- if process.is_alive():
- raise RuntimeError("release policy worker could not be stopped")
+ try:
+ process.communicate(timeout=0.2)
+ except BaseException:
+ pass
+ try:
+ process.wait(timeout=0.2)
+ except BaseException:
+ pass
+ 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
-def _fetch_remote_policy(
- *,
- worker: Callable[[], bytes] = _download_remote_policy,
-) -> bytes:
- context = multiprocessing.get_context("spawn")
- receiver, sender = context.Pipe(duplex=False)
- process = context.Process(
- target=_policy_fetch_process,
- args=(sender, worker),
- name="wikibrain-release-policy-fetch",
- daemon=True,
- )
+def _fetch_remote_policy(*, child_code: str | None = None) -> bytes:
started = time.monotonic()
- started_process = False
+ creation_flags = 0
+ if os.name == "nt":
+ creation_flags = getattr(subprocess, "CREATE_NO_WINDOW", 0)
try:
- try:
- process.start()
- started_process = True
- except Exception as exc:
- raise OSError("could not start release policy worker") from exc
- finally:
- sender.close()
+ 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
+ try:
remaining = TOTAL_FETCH_DEADLINE - (time.monotonic() - started)
- if remaining <= 0 or not receiver.poll(remaining):
+ if remaining <= 0:
raise TimeoutError("release policy request exceeded its total deadline")
try:
- message = receiver.recv_bytes(MAX_POLICY_BYTES + 4097)
- except (EOFError, OSError) as exc:
- raise OSError("release policy worker exited without a result") from exc
- if not message or message[0] != 1:
- detail = message[1:].decode("utf-8", errors="replace") if message else ""
- raise OSError(f"release policy request failed: {detail}")
- return message[1:]
+ 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")
+ return stdout
finally:
- receiver.close()
- if started_process:
- remaining = TOTAL_FETCH_DEADLINE - (time.monotonic() - started)
- process.join(max(0.0, remaining))
- _stop_fetch_process(process)
- process.close()
+ _cleanup_fetch_process(process)
def _decision(
@@ -272,11 +290,48 @@ def _windows_cache_path_is_private(path: Path, user_home: Path) -> bool:
return not path.is_symlink() and path.is_file()
-def _open_trusted_cache(path: Path) -> BinaryIO:
+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:
+ return False
+ acl_markers = {
+ "system.posix_acl_access",
+ "system.nfs4_acl",
+ "trusted.sgi_acl_file",
+ }
+ return any(
+ str(name).lower() in acl_markers for name in list_xattrs(descriptor)
+ )
+
+
+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, Path.home())
+ return open_trusted_windows_cache(path, trusted_home)
flags = os.O_RDONLY
flags |= getattr(os, "O_CLOEXEC", 0)
@@ -290,15 +345,17 @@ def _open_trusted_cache(path: Path) -> BinaryIO:
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, now: datetime) -> _CacheEntry | None:
+def _read_cache(path: Path, trusted_home: Path, now: datetime) -> _CacheEntry | None:
try:
- with _open_trusted_cache(path) 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")
@@ -376,8 +433,9 @@ def check_release_policy(
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)
- cache_path = home.expanduser().resolve() / CACHE_NAME
- cached = _read_cache(cache_path, checked_at)
+ 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:
age = checked_at - cached.checked_at
if timedelta(0) <= age < CACHE_TTL:
diff --git a/src/wikibrain/windows_cache.py b/src/wikibrain/windows_cache.py
index e39d4ad..bf1aba2 100644
--- a/src/wikibrain/windows_cache.py
+++ b/src/wikibrain/windows_cache.py
@@ -315,7 +315,14 @@ def sid_string(sid: ctypes.c_void_p) -> str:
descriptor = msvcrt.open_osfhandle(handle, os.O_RDONLY)
transferred = True
- return os.fdopen(descriptor, "rb")
+ try:
+ return os.fdopen(descriptor, "rb")
+ except BaseException:
+ try:
+ os.close(descriptor)
+ except OSError:
+ pass
+ raise
finally:
if token:
kernel32.CloseHandle(token)
diff --git a/tests/test_version_policy.py b/tests/test_version_policy.py
index c2b9e17..07af822 100644
--- a/tests/test_version_policy.py
+++ b/tests/test_version_policy.py
@@ -2,6 +2,8 @@
import json
import os
+import subprocess
+import sys
import tempfile
import time
import unittest
@@ -68,11 +70,6 @@ def read(self, limit: int) -> bytes:
return self.payload[:limit]
-def _blocking_policy_worker() -> bytes:
- while True:
- time.sleep(1)
-
-
class VersionPolicyTests(unittest.TestCase):
def test_repository_policy_is_valid_and_does_not_reject_current_release(self) -> None:
policy = parse_release_policy((ROOT / "release-policy.json").read_bytes())
@@ -203,39 +200,101 @@ 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(worker=_blocking_policy_worker)
+ _fetch_remote_policy(child_code="import time; time.sleep(3600)")
self.assertLess(time.monotonic() - started, 0.5)
- self.assertFalse(
- any(
- child.name == "wikibrain-release-policy-fetch"
- for child in __import__("multiprocessing").active_children()
- )
- )
def test_remote_fetch_worker_start_failure_is_an_io_failure(self) -> None:
- class Endpoint:
- def close(self) -> None:
- pass
+ with patch(
+ "wikibrain.version_policy.subprocess.Popen",
+ side_effect=RuntimeError("spawn disabled"),
+ ):
+ with self.assertRaisesRegex(OSError, "could not start"):
+ _fetch_remote_policy()
- class FakeProcess:
- def start(self) -> None:
- raise RuntimeError("spawn disabled")
+ 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:
- pass
+ self.close_calls += 1
+ raise RuntimeError("close failed")
- class Context:
- def Pipe(self, *, duplex: bool) -> tuple[Endpoint, Endpoint]:
- self.duplex = duplex
- return Endpoint(), Endpoint()
-
- def Process(self, **kwargs: object) -> FakeProcess:
- self.process_kwargs = kwargs
- return FakeProcess()
-
- with patch("wikibrain.version_policy.multiprocessing.get_context", return_value=Context()):
- with self.assertRaisesRegex(OSError, "could not start"):
- _fetch_remote_policy()
+ 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.assertGreaterEqual(process.communicate_calls, 2)
+ self.assertEqual(process.stdout.close_calls, 1)
+
+ @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)
def test_schema_rejects_bool_float_and_duplicate_keys(self) -> None:
valid_fields = (
@@ -401,6 +460,19 @@ def test_cache_rejects_symlinks_and_group_or_other_writable_files(self) -> None:
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)
+
def test_windows_cache_policy_requires_a_regular_file_in_user_profile(self) -> None:
with tempfile.TemporaryDirectory() as temporary:
root = Path(temporary)
@@ -431,7 +503,54 @@ 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) as handle:
+ 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")
+ account = subprocess.run(
+ ["whoami"], check=True, capture_output=True, text=True
+ ).stdout.strip()
+ subprocess.run(
+ [
+ "icacls",
+ str(configured_home),
+ "/inheritance:r",
+ "/grant:r",
+ f"{account}:(OI)(CI)F",
+ ],
+ check=True,
+ capture_output=True,
+ text=True,
+ )
+ 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")
@@ -449,7 +568,7 @@ def test_cache_rejects_a_foreign_owner(self) -> None:
return_value=foreign_metadata,
):
with self.assertRaisesRegex(OSError, "not owned"):
- _open_trusted_cache(cache)
+ _open_trusted_cache(cache, cache.parent)
def test_policy_rejects_pre_schema_epoch_and_excessive_future_timestamp(self) -> None:
for updated_at in (
From 75051dd1fc3ac6d799c3df02b53b53902e577278 Mon Sep 17 00:00:00 2001
From: hungrytech <74886812+hungrytech@users.noreply.github.com>
Date: Thu, 23 Jul 2026 00:46:40 +0900
Subject: [PATCH 04/16] docs: refresh hardened policy provenance
---
benchmarks/results/retrieval-quality-v1.json | 6 +++---
benchmarks/results/second-brain-v1.json | 6 +++---
docs/assets/benchmark-retrieval-quality-v1.svg | 2 +-
3 files changed, 7 insertions(+), 7 deletions(-)
diff --git a/benchmarks/results/retrieval-quality-v1.json b/benchmarks/results/retrieval-quality-v1.json
index 5a3c05c..4011fe0 100644
--- a/benchmarks/results/retrieval-quality-v1.json
+++ b/benchmarks/results/retrieval-quality-v1.json
@@ -224,7 +224,7 @@
}
],
"wikimap_version": "wikimap 1.1.0",
- "generated_at": "2026-07-22T15:44:54.984042+00:00",
+ "generated_at": "2026-07-22T15:46:10.673652+00:00",
"environment": {
"machine": "arm64",
"platform": "macOS-26.5.2-arm64-arm-64bit-Mach-O",
@@ -233,7 +233,7 @@
"provenance": {
"corpus_sha256": "adc729bcb6a8a8027d11176601046eaeef022bde135c7f225f8e40b78f7a998c",
"source_manifest_sha256": "84b48e997c8a6b5c3f34a4115834b95257695212a114791d16beecc860d38a28",
- "git_commit": "34e10f1a1c88195f6fce245b94049e4885c94174",
- "git_dirty": true
+ "git_commit": "8673ba090103af7a76144c1e82e31a67933e7002",
+ "git_dirty": false
}
}
diff --git a/benchmarks/results/second-brain-v1.json b/benchmarks/results/second-brain-v1.json
index 5934d01..147d589 100644
--- a/benchmarks/results/second-brain-v1.json
+++ b/benchmarks/results/second-brain-v1.json
@@ -20,12 +20,12 @@
"forbidden_atoms": 4
},
"provenance": {
- "generated_at": "2026-07-22T15:44:52+00:00",
+ "generated_at": "2026-07-22T15:46:07+00:00",
"corpus_version": "second-brain-corpus-v1",
"runner_sha256": "666510b248680357cebe9d70d42f07a7d0619522658fbb0810989aec449ec2aa",
"source_manifest_sha256": "7358680fc2946fe274789b9e0aee63a9b3a6f623f09e28dd0d3014ea99360328",
- "git_commit": "34e10f1a1c88195f6fce245b94049e4885c94174",
- "git_dirty": true,
+ "git_commit": "8673ba090103af7a76144c1e82e31a67933e7002",
+ "git_dirty": false,
"reproduction_command": "uv run python -m benchmarks.second_brain --wikimap wikimap --format json --output benchmarks/results/second-brain-v1.json"
},
"cases": [
diff --git a/docs/assets/benchmark-retrieval-quality-v1.svg b/docs/assets/benchmark-retrieval-quality-v1.svg
index c10e735..da2fc05 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
-
+
From 39b6d53e016798ac9313706f06a3407801f0b367 Mon Sep 17 00:00:00 2001
From: hungrytech <74886812+hungrytech@users.noreply.github.com>
Date: Thu, 23 Jul 2026 00:48:15 +0900
Subject: [PATCH 05/16] fix: fail open on subprocess runtime errors
---
benchmarks/results/retrieval-quality-v1.json | 8 +++----
benchmarks/results/second-brain-v1.json | 8 +++----
.../assets/benchmark-retrieval-quality-v1.svg | 2 +-
src/wikibrain/version_policy.py | 1 +
tests/test_version_policy.py | 22 +++++++++++--------
5 files changed, 23 insertions(+), 18 deletions(-)
diff --git a/benchmarks/results/retrieval-quality-v1.json b/benchmarks/results/retrieval-quality-v1.json
index 4011fe0..34e0cdd 100644
--- a/benchmarks/results/retrieval-quality-v1.json
+++ b/benchmarks/results/retrieval-quality-v1.json
@@ -224,7 +224,7 @@
}
],
"wikimap_version": "wikimap 1.1.0",
- "generated_at": "2026-07-22T15:46:10.673652+00:00",
+ "generated_at": "2026-07-22T15:48:05.929415+00:00",
"environment": {
"machine": "arm64",
"platform": "macOS-26.5.2-arm64-arm-64bit-Mach-O",
@@ -232,8 +232,8 @@
},
"provenance": {
"corpus_sha256": "adc729bcb6a8a8027d11176601046eaeef022bde135c7f225f8e40b78f7a998c",
- "source_manifest_sha256": "84b48e997c8a6b5c3f34a4115834b95257695212a114791d16beecc860d38a28",
- "git_commit": "8673ba090103af7a76144c1e82e31a67933e7002",
- "git_dirty": false
+ "source_manifest_sha256": "9791ba546f2f30daa2a71c8450b1aeb614c8b8a0bf1c14264eeb5cfaeeaf34e2",
+ "git_commit": "75051dd1fc3ac6d799c3df02b53b53902e577278",
+ "git_dirty": true
}
}
diff --git a/benchmarks/results/second-brain-v1.json b/benchmarks/results/second-brain-v1.json
index 147d589..84fdac1 100644
--- a/benchmarks/results/second-brain-v1.json
+++ b/benchmarks/results/second-brain-v1.json
@@ -20,12 +20,12 @@
"forbidden_atoms": 4
},
"provenance": {
- "generated_at": "2026-07-22T15:46:07+00:00",
+ "generated_at": "2026-07-22T15:48:03+00:00",
"corpus_version": "second-brain-corpus-v1",
"runner_sha256": "666510b248680357cebe9d70d42f07a7d0619522658fbb0810989aec449ec2aa",
- "source_manifest_sha256": "7358680fc2946fe274789b9e0aee63a9b3a6f623f09e28dd0d3014ea99360328",
- "git_commit": "8673ba090103af7a76144c1e82e31a67933e7002",
- "git_dirty": false,
+ "source_manifest_sha256": "4534f51ca433448a47851daff646872b59d0b4cd512e12040358d2eaad7867c0",
+ "git_commit": "75051dd1fc3ac6d799c3df02b53b53902e577278",
+ "git_dirty": true,
"reproduction_command": "uv run python -m benchmarks.second_brain --wikimap wikimap --format json --output benchmarks/results/second-brain-v1.json"
},
"cases": [
diff --git a/docs/assets/benchmark-retrieval-quality-v1.svg b/docs/assets/benchmark-retrieval-quality-v1.svg
index da2fc05..5c88632 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
-
+
diff --git a/src/wikibrain/version_policy.py b/src/wikibrain/version_policy.py
index b55ea31..33e7cba 100644
--- a/src/wikibrain/version_policy.py
+++ b/src/wikibrain/version_policy.py
@@ -461,6 +461,7 @@ def check_release_policy(
OSError,
RecursionError,
RuntimeError,
+ subprocess.SubprocessError,
UnicodeError,
ValueError,
):
diff --git a/tests/test_version_policy.py b/tests/test_version_policy.py
index 07af822..38fd64b 100644
--- a/tests/test_version_policy.py
+++ b/tests/test_version_policy.py
@@ -662,15 +662,19 @@ def test_clock_regression_does_not_delete_the_rollback_floor(self) -> None:
self.assertEqual(rejected_old_policy.source, "remote-error")
def test_unexpected_runtime_fetch_failure_fails_open(self) -> None:
- with tempfile.TemporaryDirectory() as temporary:
- decision = check_release_policy(
- Path(temporary),
- "0.1.7",
- now=NOW,
- fetcher=lambda: (_ for _ in ()).throw(RuntimeError("no workers")),
- )
- self.assertEqual(decision.state, "unavailable")
- self.assertEqual(decision.source, "remote-error")
+ 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:
From 0b6d9d1cc64dae2740d6bb0773c071caa3a89f26 Mon Sep 17 00:00:00 2001
From: hungrytech <74886812+hungrytech@users.noreply.github.com>
Date: Thu, 23 Jul 2026 00:48:49 +0900
Subject: [PATCH 06/16] docs: record final v0.1.8 provenance
---
benchmarks/results/retrieval-quality-v1.json | 6 +++---
benchmarks/results/second-brain-v1.json | 6 +++---
docs/assets/benchmark-retrieval-quality-v1.svg | 2 +-
3 files changed, 7 insertions(+), 7 deletions(-)
diff --git a/benchmarks/results/retrieval-quality-v1.json b/benchmarks/results/retrieval-quality-v1.json
index 34e0cdd..bea5fe1 100644
--- a/benchmarks/results/retrieval-quality-v1.json
+++ b/benchmarks/results/retrieval-quality-v1.json
@@ -224,7 +224,7 @@
}
],
"wikimap_version": "wikimap 1.1.0",
- "generated_at": "2026-07-22T15:48:05.929415+00:00",
+ "generated_at": "2026-07-22T15:48:35.515959+00:00",
"environment": {
"machine": "arm64",
"platform": "macOS-26.5.2-arm64-arm-64bit-Mach-O",
@@ -233,7 +233,7 @@
"provenance": {
"corpus_sha256": "adc729bcb6a8a8027d11176601046eaeef022bde135c7f225f8e40b78f7a998c",
"source_manifest_sha256": "9791ba546f2f30daa2a71c8450b1aeb614c8b8a0bf1c14264eeb5cfaeeaf34e2",
- "git_commit": "75051dd1fc3ac6d799c3df02b53b53902e577278",
- "git_dirty": true
+ "git_commit": "39b6d53e016798ac9313706f06a3407801f0b367",
+ "git_dirty": false
}
}
diff --git a/benchmarks/results/second-brain-v1.json b/benchmarks/results/second-brain-v1.json
index 84fdac1..bda2ab6 100644
--- a/benchmarks/results/second-brain-v1.json
+++ b/benchmarks/results/second-brain-v1.json
@@ -20,12 +20,12 @@
"forbidden_atoms": 4
},
"provenance": {
- "generated_at": "2026-07-22T15:48:03+00:00",
+ "generated_at": "2026-07-22T15:48:32+00:00",
"corpus_version": "second-brain-corpus-v1",
"runner_sha256": "666510b248680357cebe9d70d42f07a7d0619522658fbb0810989aec449ec2aa",
"source_manifest_sha256": "4534f51ca433448a47851daff646872b59d0b4cd512e12040358d2eaad7867c0",
- "git_commit": "75051dd1fc3ac6d799c3df02b53b53902e577278",
- "git_dirty": true,
+ "git_commit": "39b6d53e016798ac9313706f06a3407801f0b367",
+ "git_dirty": false,
"reproduction_command": "uv run python -m benchmarks.second_brain --wikimap wikimap --format json --output benchmarks/results/second-brain-v1.json"
},
"cases": [
diff --git a/docs/assets/benchmark-retrieval-quality-v1.svg b/docs/assets/benchmark-retrieval-quality-v1.svg
index 5c88632..8bf5d94 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
-
+
From e2f89320da0971d9c4ed34d4678d54a50d9d43c7 Mon Sep 17 00:00:00 2001
From: hungrytech <74886812+hungrytech@users.noreply.github.com>
Date: Thu, 23 Jul 2026 01:15:36 +0900
Subject: [PATCH 07/16] fix: enforce native policy worker cleanup
---
.github/workflows/ci.yml | 30 +++
CHANGELOG.md | 10 +-
benchmarks/results/retrieval-quality-v1.json | 8 +-
benchmarks/results/second-brain-v1.json | 8 +-
.../assets/benchmark-retrieval-quality-v1.svg | 2 +-
src/wikibrain/version_policy.py | 156 ++++++++++---
tests/test_version_policy.py | 212 ++++++++++++++----
7 files changed, 345 insertions(+), 81 deletions(-)
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index ac63c51..07023e8 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -67,6 +67,19 @@ jobs:
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:
@@ -199,3 +212,20 @@ jobs:
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 c6bb838..ebf9846 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -10,11 +10,11 @@ The project follows [Semantic Versioning](https://semver.org/).
### Security
-- Bound official release-policy retrieval by a monotonic 2.5-second network deadline
- in a directly managed, killable subprocess without a persistent multiprocessing
- helper; deadline expiry kills and reaps the worker and independently closes IPC
- handles so slow-drip responses cannot leave commands, hooks, workers, or sockets
- behind.
+- 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
+ termination/reaping before returning, without a persistent multiprocessing helper,
+ 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
diff --git a/benchmarks/results/retrieval-quality-v1.json b/benchmarks/results/retrieval-quality-v1.json
index bea5fe1..f8a65ed 100644
--- a/benchmarks/results/retrieval-quality-v1.json
+++ b/benchmarks/results/retrieval-quality-v1.json
@@ -224,7 +224,7 @@
}
],
"wikimap_version": "wikimap 1.1.0",
- "generated_at": "2026-07-22T15:48:35.515959+00:00",
+ "generated_at": "2026-07-22T16:13:33.495983+00:00",
"environment": {
"machine": "arm64",
"platform": "macOS-26.5.2-arm64-arm-64bit-Mach-O",
@@ -232,8 +232,8 @@
},
"provenance": {
"corpus_sha256": "adc729bcb6a8a8027d11176601046eaeef022bde135c7f225f8e40b78f7a998c",
- "source_manifest_sha256": "9791ba546f2f30daa2a71c8450b1aeb614c8b8a0bf1c14264eeb5cfaeeaf34e2",
- "git_commit": "39b6d53e016798ac9313706f06a3407801f0b367",
- "git_dirty": false
+ "source_manifest_sha256": "03fc81621c36725a7bc0724c720f0f8c482195f4af8594b143b46fa6e69fcf0e",
+ "git_commit": "0b6d9d1cc64dae2740d6bb0773c071caa3a89f26",
+ "git_dirty": true
}
}
diff --git a/benchmarks/results/second-brain-v1.json b/benchmarks/results/second-brain-v1.json
index bda2ab6..d2e586e 100644
--- a/benchmarks/results/second-brain-v1.json
+++ b/benchmarks/results/second-brain-v1.json
@@ -20,12 +20,12 @@
"forbidden_atoms": 4
},
"provenance": {
- "generated_at": "2026-07-22T15:48:32+00:00",
+ "generated_at": "2026-07-22T16:13:31+00:00",
"corpus_version": "second-brain-corpus-v1",
"runner_sha256": "666510b248680357cebe9d70d42f07a7d0619522658fbb0810989aec449ec2aa",
- "source_manifest_sha256": "4534f51ca433448a47851daff646872b59d0b4cd512e12040358d2eaad7867c0",
- "git_commit": "39b6d53e016798ac9313706f06a3407801f0b367",
- "git_dirty": false,
+ "source_manifest_sha256": "ee0bc015890f3845102240ec3f2d8e4ae515a49cfa464d44a835681a13dabc69",
+ "git_commit": "0b6d9d1cc64dae2740d6bb0773c071caa3a89f26",
+ "git_dirty": true,
"reproduction_command": "uv run python -m benchmarks.second_brain --wikimap wikimap --format json --output benchmarks/results/second-brain-v1.json"
},
"cases": [
diff --git a/docs/assets/benchmark-retrieval-quality-v1.svg b/docs/assets/benchmark-retrieval-quality-v1.svg
index 8bf5d94..be18495 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
-
+
diff --git a/src/wikibrain/version_policy.py b/src/wikibrain/version_policy.py
index 33e7cba..597ad63 100644
--- a/src/wikibrain/version_policy.py
+++ b/src/wikibrain/version_policy.py
@@ -3,6 +3,7 @@
import json
import os
import re
+import signal
import stat
import subprocess
import sys
@@ -26,7 +27,10 @@
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*)$")
@@ -168,7 +172,9 @@ def _download_remote_policy() -> bytes:
def _default_fetch_child_code() -> str:
source_root = str(Path(__file__).resolve().parent.parent)
return (
- "import sys;"
+ "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())"
@@ -182,29 +188,107 @@ def _process_is_running(process: subprocess.Popen[bytes]) -> bool:
return True
-def _cleanup_fetch_process(process: subprocess.Popen[bytes]) -> None:
- # Keep each cleanup operation independent. A broken close/wait method must not
- # prevent later kill, reap, and descriptor-close attempts.
+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):
- break
+ return True
try:
getattr(process, method_name)()
except BaseException:
pass
+ remaining = deadline - time.monotonic()
+ if remaining <= 0:
+ break
try:
- process.wait(timeout=0.1)
+ process.wait(timeout=remaining)
except BaseException:
pass
+ return not _process_is_running(process)
- try:
- process.communicate(timeout=0.2)
- except BaseException:
- pass
- try:
- process.wait(timeout=0.2)
- except BaseException:
- pass
+
+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)
@@ -215,10 +299,17 @@ def _cleanup_fetch_process(process: subprocess.Popen[bytes]) -> None:
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)
@@ -234,8 +325,10 @@ def _fetch_remote_policy(*, child_code: str | None = None) -> bytes:
except Exception as exc:
raise OSError("could not start release policy worker") from exc
+ result: bytes | None = None
+ failure: BaseException | None = None
try:
- remaining = TOTAL_FETCH_DEADLINE - (time.monotonic() - started)
+ remaining = request_deadline - time.monotonic()
if remaining <= 0:
raise TimeoutError("release policy request exceeded its total deadline")
try:
@@ -248,9 +341,17 @@ def _fetch_remote_policy(*, child_code: str | None = None) -> bytes:
raise OSError("release policy worker exited without a result")
if len(stdout) > MAX_POLICY_BYTES:
raise ValueError("release policy response is too large")
- return stdout
- finally:
- _cleanup_fetch_process(process)
+ 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(
@@ -282,14 +383,6 @@ def _unavailable(current_version: str, *, source: str) -> PolicyDecision:
)
-def _windows_cache_path_is_private(path: Path, user_home: Path) -> bool:
- try:
- path.resolve(strict=True).relative_to(user_home.resolve(strict=True))
- except (OSError, ValueError):
- return False
- return not path.is_symlink() and path.is_file()
-
-
def _fd_has_extended_acl(descriptor: int) -> bool:
if sys.platform == "darwin":
import ctypes
@@ -316,15 +409,20 @@ def _fd_has_extended_acl(descriptor: int) -> bool:
list_xattrs = getattr(os, "listxattr", None)
if list_xattrs is None:
- return False
+ 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",
}
- return any(
- str(name).lower() in acl_markers for name in list_xattrs(descriptor)
+ 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:
diff --git a/tests/test_version_policy.py b/tests/test_version_policy.py
index 38fd64b..cc287d6 100644
--- a/tests/test_version_policy.py
+++ b/tests/test_version_policy.py
@@ -23,10 +23,10 @@
MAX_CACHE_BYTES,
POLICY_URL,
PolicyDecision,
+ _cleanup_fetch_process,
_download_remote_policy,
_fetch_remote_policy,
_open_trusted_cache,
- _windows_cache_path_is_private,
check_release_policy,
parse_release_policy,
)
@@ -52,6 +52,71 @@ def _policy(
).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 _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:
+ 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
@@ -261,9 +326,58 @@ def communicate(self, timeout: float) -> tuple[bytes, None]:
self.assertGreaterEqual(process.kill_calls, 1)
self.assertGreaterEqual(process.wait_calls, 1)
- self.assertGreaterEqual(process.communicate_calls, 2)
+ 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)
+
+ @unittest.skipIf(os.name == "nt", "POSIX native reap contract")
+ 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)
+ )
+ 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]:
@@ -473,30 +587,66 @@ def test_cache_rejects_a_macos_extended_acl(self) -> None:
with self.assertRaisesRegex(OSError, "extended ACL"):
_open_trusted_cache(cache, cache.parent)
- def test_windows_cache_policy_requires_a_regular_file_in_user_profile(self) -> None:
- with tempfile.TemporaryDirectory() as temporary:
+ @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)
- profile = root / "profile"
- profile.mkdir()
- private_cache = profile / ".wikibrain" / "release-policy-cache.json"
- private_cache.parent.mkdir()
- private_cache.write_text("{}", encoding="utf-8")
- shared_cache = root / "shared" / "release-policy-cache.json"
- shared_cache.parent.mkdir()
- shared_cache.write_text("{}", encoding="utf-8")
-
- self.assertTrue(
- _windows_cache_path_is_private(private_cache, profile)
+ 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,
)
- self.assertFalse(
- _windows_cache_path_is_private(shared_cache, profile)
+ 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,
)
- if os.name != "nt":
- symlink_cache = profile / ".wikibrain" / "linked-cache.json"
- symlink_cache.symlink_to(shared_cache)
- self.assertFalse(
- _windows_cache_path_is_private(symlink_cache, profile)
- )
+ 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, "not owned"):
+ _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:
@@ -533,21 +683,7 @@ def test_windows_cache_accepts_a_private_configured_home_outside_profile(
pass
else:
self.skipTest("repository is inside the Windows user profile")
- account = subprocess.run(
- ["whoami"], check=True, capture_output=True, text=True
- ).stdout.strip()
- subprocess.run(
- [
- "icacls",
- str(configured_home),
- "/inheritance:r",
- "/grant:r",
- f"{account}:(OI)(CI)F",
- ],
- check=True,
- capture_output=True,
- text=True,
- )
+ _secure_windows_directory(configured_home)
cache = configured_home / CACHE_NAME
cache.write_bytes(b"trusted")
with _open_trusted_cache(cache, configured_home) as handle:
From d2c9b318eebbb9e8a2a4f7f73dfa672673fc978e Mon Sep 17 00:00:00 2001
From: hungrytech <74886812+hungrytech@users.noreply.github.com>
Date: Thu, 23 Jul 2026 01:16:33 +0900
Subject: [PATCH 08/16] docs: attest native cleanup candidate
---
benchmarks/results/retrieval-quality-v1.json | 6 +++---
benchmarks/results/second-brain-v1.json | 6 +++---
docs/assets/benchmark-retrieval-quality-v1.svg | 2 +-
3 files changed, 7 insertions(+), 7 deletions(-)
diff --git a/benchmarks/results/retrieval-quality-v1.json b/benchmarks/results/retrieval-quality-v1.json
index f8a65ed..a97ae01 100644
--- a/benchmarks/results/retrieval-quality-v1.json
+++ b/benchmarks/results/retrieval-quality-v1.json
@@ -224,7 +224,7 @@
}
],
"wikimap_version": "wikimap 1.1.0",
- "generated_at": "2026-07-22T16:13:33.495983+00:00",
+ "generated_at": "2026-07-22T16:16:06.277797+00:00",
"environment": {
"machine": "arm64",
"platform": "macOS-26.5.2-arm64-arm-64bit-Mach-O",
@@ -233,7 +233,7 @@
"provenance": {
"corpus_sha256": "adc729bcb6a8a8027d11176601046eaeef022bde135c7f225f8e40b78f7a998c",
"source_manifest_sha256": "03fc81621c36725a7bc0724c720f0f8c482195f4af8594b143b46fa6e69fcf0e",
- "git_commit": "0b6d9d1cc64dae2740d6bb0773c071caa3a89f26",
- "git_dirty": true
+ "git_commit": "e2f89320da0971d9c4ed34d4678d54a50d9d43c7",
+ "git_dirty": false
}
}
diff --git a/benchmarks/results/second-brain-v1.json b/benchmarks/results/second-brain-v1.json
index d2e586e..e9e367c 100644
--- a/benchmarks/results/second-brain-v1.json
+++ b/benchmarks/results/second-brain-v1.json
@@ -20,12 +20,12 @@
"forbidden_atoms": 4
},
"provenance": {
- "generated_at": "2026-07-22T16:13:31+00:00",
+ "generated_at": "2026-07-22T16:16:02+00:00",
"corpus_version": "second-brain-corpus-v1",
"runner_sha256": "666510b248680357cebe9d70d42f07a7d0619522658fbb0810989aec449ec2aa",
"source_manifest_sha256": "ee0bc015890f3845102240ec3f2d8e4ae515a49cfa464d44a835681a13dabc69",
- "git_commit": "0b6d9d1cc64dae2740d6bb0773c071caa3a89f26",
- "git_dirty": true,
+ "git_commit": "e2f89320da0971d9c4ed34d4678d54a50d9d43c7",
+ "git_dirty": false,
"reproduction_command": "uv run python -m benchmarks.second_brain --wikimap wikimap --format json --output benchmarks/results/second-brain-v1.json"
},
"cases": [
diff --git a/docs/assets/benchmark-retrieval-quality-v1.svg b/docs/assets/benchmark-retrieval-quality-v1.svg
index be18495..6ba752a 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
-
+
From eff3f6862a0e5a1e23faaa77277b946ce18b5a23 Mon Sep 17 00:00:00 2001
From: hungrytech <74886812+hungrytech@users.noreply.github.com>
Date: Thu, 23 Jul 2026 01:27:15 +0900
Subject: [PATCH 09/16] fix: validate elevated Windows cache owners
---
benchmarks/results/retrieval-quality-v1.json | 8 +-
benchmarks/results/second-brain-v1.json | 8 +-
.../assets/benchmark-retrieval-quality-v1.svg | 2 +-
src/wikibrain/windows_cache.py | 19 +++--
tests/test_version_policy.py | 82 ++++++++++++++++++-
5 files changed, 98 insertions(+), 21 deletions(-)
diff --git a/benchmarks/results/retrieval-quality-v1.json b/benchmarks/results/retrieval-quality-v1.json
index a97ae01..ec9d754 100644
--- a/benchmarks/results/retrieval-quality-v1.json
+++ b/benchmarks/results/retrieval-quality-v1.json
@@ -224,7 +224,7 @@
}
],
"wikimap_version": "wikimap 1.1.0",
- "generated_at": "2026-07-22T16:16:06.277797+00:00",
+ "generated_at": "2026-07-22T16:25:42.867545+00:00",
"environment": {
"machine": "arm64",
"platform": "macOS-26.5.2-arm64-arm-64bit-Mach-O",
@@ -232,8 +232,8 @@
},
"provenance": {
"corpus_sha256": "adc729bcb6a8a8027d11176601046eaeef022bde135c7f225f8e40b78f7a998c",
- "source_manifest_sha256": "03fc81621c36725a7bc0724c720f0f8c482195f4af8594b143b46fa6e69fcf0e",
- "git_commit": "e2f89320da0971d9c4ed34d4678d54a50d9d43c7",
- "git_dirty": false
+ "source_manifest_sha256": "564070ab5fe9d795c1b4aea923eaaedc58e1c7f96791727e8676e98c14605d2b",
+ "git_commit": "d2c9b318eebbb9e8a2a4f7f73dfa672673fc978e",
+ "git_dirty": true
}
}
diff --git a/benchmarks/results/second-brain-v1.json b/benchmarks/results/second-brain-v1.json
index e9e367c..d01a8e8 100644
--- a/benchmarks/results/second-brain-v1.json
+++ b/benchmarks/results/second-brain-v1.json
@@ -20,12 +20,12 @@
"forbidden_atoms": 4
},
"provenance": {
- "generated_at": "2026-07-22T16:16:02+00:00",
+ "generated_at": "2026-07-22T16:25:40+00:00",
"corpus_version": "second-brain-corpus-v1",
"runner_sha256": "666510b248680357cebe9d70d42f07a7d0619522658fbb0810989aec449ec2aa",
- "source_manifest_sha256": "ee0bc015890f3845102240ec3f2d8e4ae515a49cfa464d44a835681a13dabc69",
- "git_commit": "e2f89320da0971d9c4ed34d4678d54a50d9d43c7",
- "git_dirty": false,
+ "source_manifest_sha256": "464dddc4a89081e6353ca9a24a69cfe988e410682568fd217b74cc9117409a77",
+ "git_commit": "d2c9b318eebbb9e8a2a4f7f73dfa672673fc978e",
+ "git_dirty": true,
"reproduction_command": "uv run python -m benchmarks.second_brain --wikimap wikimap --format json --output benchmarks/results/second-brain-v1.json"
},
"cases": [
diff --git a/docs/assets/benchmark-retrieval-quality-v1.svg b/docs/assets/benchmark-retrieval-quality-v1.svg
index 6ba752a..7b7ac27 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
-
+
diff --git a/src/wikibrain/windows_cache.py b/src/wikibrain/windows_cache.py
index bf1aba2..f6d1a56 100644
--- a/src/wikibrain/windows_cache.py
+++ b/src/wikibrain/windows_cache.py
@@ -140,8 +140,6 @@ class AclHeader(ctypes.Structure):
ctypes.POINTER(wintypes.DWORD),
]
advapi32.GetTokenInformation.restype = wintypes.BOOL
- advapi32.EqualSid.argtypes = [ctypes.c_void_p, ctypes.c_void_p]
- advapi32.EqualSid.restype = wintypes.BOOL
advapi32.GetAce.argtypes = [
ctypes.c_void_p,
wintypes.DWORD,
@@ -238,11 +236,6 @@ class AclHeader(ctypes.Structure):
current_sid = ctypes.cast(
token_buffer, ctypes.POINTER(TokenUser)
).contents.User.Sid
- if not advapi32.EqualSid(owner_sid, current_sid):
- raise WindowsCacheTrustError(
- "release policy cache is not owned by the current user"
- )
-
def sid_string(sid: ctypes.c_void_p) -> str:
text = wintypes.LPWSTR()
if not advapi32.ConvertSidToStringSidW(sid, ctypes.byref(text)):
@@ -254,8 +247,18 @@ def sid_string(sid: ctypes.c_void_p) -> str:
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 = {
- sid_string(current_sid),
+ current_sid_text,
"S-1-5-18", # LocalSystem
"S-1-5-32-544", # Builtin Administrators
"S-1-3-0", # Creator Owner
diff --git a/tests/test_version_policy.py b/tests/test_version_policy.py
index cc287d6..92d60e5 100644
--- a/tests/test_version_policy.py
+++ b/tests/test_version_policy.py
@@ -70,6 +70,78 @@ def _secure_windows_directory(path: Path) -> None:
)
+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:
@@ -101,6 +173,7 @@ def _set_windows_security(
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
@@ -360,7 +433,6 @@ def communicate(self, timeout: float) -> tuple[bytes, None]:
_fetch_remote_policy()
self.assertLess(time.monotonic() - started, 0.2)
- @unittest.skipIf(os.name == "nt", "POSIX native reap contract")
def test_native_reap_bypasses_broken_popen_lifecycle_methods(self) -> None:
process = subprocess.Popen(
[sys.executable, "-c", "import time; time.sleep(3600)"],
@@ -375,8 +447,10 @@ def test_native_reap_bypasses_broken_popen_lifecycle_methods(self) -> None:
self.assertTrue(
_cleanup_fetch_process(process, time.monotonic() + 0.5)
)
- with self.assertRaises(ChildProcessError):
- os.waitpid(process_id, os.WNOHANG)
+ 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:
@@ -645,7 +719,7 @@ def test_windows_cache_rejects_foreign_owner(self) -> None:
cache = Path(temporary) / CACHE_NAME
cache.write_bytes(b"unsafe")
_set_windows_security(cache, owner_sid="S-1-5-18")
- with self.assertRaisesRegex(OSError, "not owned"):
+ with self.assertRaisesRegex(OSError, "untrusted owner"):
_open_trusted_cache(cache, cache.parent)
@unittest.skipUnless(os.name == "nt", "Windows handle and DACL contract")
From dac2913307d21b9512c0dabc277bb77e34bb54c5 Mon Sep 17 00:00:00 2001
From: hungrytech <74886812+hungrytech@users.noreply.github.com>
Date: Thu, 23 Jul 2026 01:27:48 +0900
Subject: [PATCH 10/16] chore: refresh release benchmark provenance
---
benchmarks/results/retrieval-quality-v1.json | 6 +++---
benchmarks/results/second-brain-v1.json | 6 +++---
docs/assets/benchmark-retrieval-quality-v1.svg | 2 +-
3 files changed, 7 insertions(+), 7 deletions(-)
diff --git a/benchmarks/results/retrieval-quality-v1.json b/benchmarks/results/retrieval-quality-v1.json
index ec9d754..90ffd3e 100644
--- a/benchmarks/results/retrieval-quality-v1.json
+++ b/benchmarks/results/retrieval-quality-v1.json
@@ -224,7 +224,7 @@
}
],
"wikimap_version": "wikimap 1.1.0",
- "generated_at": "2026-07-22T16:25:42.867545+00:00",
+ "generated_at": "2026-07-22T16:27:42.573186+00:00",
"environment": {
"machine": "arm64",
"platform": "macOS-26.5.2-arm64-arm-64bit-Mach-O",
@@ -233,7 +233,7 @@
"provenance": {
"corpus_sha256": "adc729bcb6a8a8027d11176601046eaeef022bde135c7f225f8e40b78f7a998c",
"source_manifest_sha256": "564070ab5fe9d795c1b4aea923eaaedc58e1c7f96791727e8676e98c14605d2b",
- "git_commit": "d2c9b318eebbb9e8a2a4f7f73dfa672673fc978e",
- "git_dirty": true
+ "git_commit": "eff3f6862a0e5a1e23faaa77277b946ce18b5a23",
+ "git_dirty": false
}
}
diff --git a/benchmarks/results/second-brain-v1.json b/benchmarks/results/second-brain-v1.json
index d01a8e8..af000e9 100644
--- a/benchmarks/results/second-brain-v1.json
+++ b/benchmarks/results/second-brain-v1.json
@@ -20,12 +20,12 @@
"forbidden_atoms": 4
},
"provenance": {
- "generated_at": "2026-07-22T16:25:40+00:00",
+ "generated_at": "2026-07-22T16:27:39+00:00",
"corpus_version": "second-brain-corpus-v1",
"runner_sha256": "666510b248680357cebe9d70d42f07a7d0619522658fbb0810989aec449ec2aa",
"source_manifest_sha256": "464dddc4a89081e6353ca9a24a69cfe988e410682568fd217b74cc9117409a77",
- "git_commit": "d2c9b318eebbb9e8a2a4f7f73dfa672673fc978e",
- "git_dirty": true,
+ "git_commit": "eff3f6862a0e5a1e23faaa77277b946ce18b5a23",
+ "git_dirty": false,
"reproduction_command": "uv run python -m benchmarks.second_brain --wikimap wikimap --format json --output benchmarks/results/second-brain-v1.json"
},
"cases": [
diff --git a/docs/assets/benchmark-retrieval-quality-v1.svg b/docs/assets/benchmark-retrieval-quality-v1.svg
index 7b7ac27..c4aa24a 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
-
+
From 285eb6525847330c3eac178dba9d1bce3c39790e Mon Sep 17 00:00:00 2001
From: hungrytech <74886812+hungrytech@users.noreply.github.com>
Date: Thu, 23 Jul 2026 01:33:31 +0900
Subject: [PATCH 11/16] fix: reject policy redirects before follow
---
CHANGELOG.md | 5 +-
benchmarks/results/retrieval-quality-v1.json | 8 +--
benchmarks/results/second-brain-v1.json | 8 +--
.../assets/benchmark-retrieval-quality-v1.svg | 2 +-
src/wikibrain/version_policy.py | 23 ++++++-
tests/test_version_policy.py | 60 +++++++++++++++++--
6 files changed, 87 insertions(+), 19 deletions(-)
diff --git a/CHANGELOG.md b/CHANGELOG.md
index ebf9846..83caf48 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -13,8 +13,9 @@ The project follows [Semantic Versioning](https://semver.org/).
- 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
- termination/reaping before returning, without a persistent multiprocessing helper,
- while retaining the socket timeout and response-size cap.
+ 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
diff --git a/benchmarks/results/retrieval-quality-v1.json b/benchmarks/results/retrieval-quality-v1.json
index 90ffd3e..42b41db 100644
--- a/benchmarks/results/retrieval-quality-v1.json
+++ b/benchmarks/results/retrieval-quality-v1.json
@@ -224,7 +224,7 @@
}
],
"wikimap_version": "wikimap 1.1.0",
- "generated_at": "2026-07-22T16:27:42.573186+00:00",
+ "generated_at": "2026-07-22T16:33:16.347553+00:00",
"environment": {
"machine": "arm64",
"platform": "macOS-26.5.2-arm64-arm-64bit-Mach-O",
@@ -232,8 +232,8 @@
},
"provenance": {
"corpus_sha256": "adc729bcb6a8a8027d11176601046eaeef022bde135c7f225f8e40b78f7a998c",
- "source_manifest_sha256": "564070ab5fe9d795c1b4aea923eaaedc58e1c7f96791727e8676e98c14605d2b",
- "git_commit": "eff3f6862a0e5a1e23faaa77277b946ce18b5a23",
- "git_dirty": false
+ "source_manifest_sha256": "e115feec5e6dafbc7718b6170b4695e96bf622d128d50d52673d1de6b8b5e5a8",
+ "git_commit": "dac2913307d21b9512c0dabc277bb77e34bb54c5",
+ "git_dirty": true
}
}
diff --git a/benchmarks/results/second-brain-v1.json b/benchmarks/results/second-brain-v1.json
index af000e9..dbd7d14 100644
--- a/benchmarks/results/second-brain-v1.json
+++ b/benchmarks/results/second-brain-v1.json
@@ -20,12 +20,12 @@
"forbidden_atoms": 4
},
"provenance": {
- "generated_at": "2026-07-22T16:27:39+00:00",
+ "generated_at": "2026-07-22T16:33:14+00:00",
"corpus_version": "second-brain-corpus-v1",
"runner_sha256": "666510b248680357cebe9d70d42f07a7d0619522658fbb0810989aec449ec2aa",
- "source_manifest_sha256": "464dddc4a89081e6353ca9a24a69cfe988e410682568fd217b74cc9117409a77",
- "git_commit": "eff3f6862a0e5a1e23faaa77277b946ce18b5a23",
- "git_dirty": false,
+ "source_manifest_sha256": "a4f58b6f522e22515ffc20cf75aabd39bc6dcb52759c717fe635dbdce8befcab",
+ "git_commit": "dac2913307d21b9512c0dabc277bb77e34bb54c5",
+ "git_dirty": true,
"reproduction_command": "uv run python -m benchmarks.second_brain --wikimap wikimap --format json --output benchmarks/results/second-brain-v1.json"
},
"cases": [
diff --git a/docs/assets/benchmark-retrieval-quality-v1.svg b/docs/assets/benchmark-retrieval-quality-v1.svg
index c4aa24a..c1e1315 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
-
+
diff --git a/src/wikibrain/version_policy.py b/src/wikibrain/version_policy.py
index 597ad63..7db4c14 100644
--- a/src/wikibrain/version_policy.py
+++ b/src/wikibrain/version_policy.py
@@ -12,8 +12,8 @@
from datetime import UTC, datetime, timedelta
from http.client import HTTPException
from pathlib import Path
-from typing import BinaryIO, 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
@@ -152,6 +152,23 @@ def parse_release_policy(
)
+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,
@@ -160,7 +177,7 @@ def _download_remote_policy() -> bytes:
"User-Agent": "wikibrain-version-policy",
},
)
- with urlopen(request, timeout=SOCKET_TIMEOUT) 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)
diff --git a/tests/test_version_policy.py b/tests/test_version_policy.py
index 92d60e5..2a22369 100644
--- a/tests/test_version_policy.py
+++ b/tests/test_version_policy.py
@@ -265,7 +265,7 @@ 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):
+ with patch("wikibrain.version_policy._open_policy_url", side_effect=open_trusted):
self.assertEqual(_download_remote_policy(), _policy())
request, timeout = requests[0]
@@ -273,7 +273,7 @@ def open_trusted(request: object, *, timeout: float) -> _Response:
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"):
@@ -285,7 +285,7 @@ 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"):
@@ -293,6 +293,56 @@ def open_trusted(request: object, *, timeout: float) -> _Response:
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:
@@ -310,7 +360,7 @@ 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(
@@ -320,7 +370,7 @@ def read(self, _limit: int) -> bytes:
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(
From a545b48865fd7acac979b5867a8fcfef46a76b51 Mon Sep 17 00:00:00 2001
From: hungrytech <74886812+hungrytech@users.noreply.github.com>
Date: Thu, 23 Jul 2026 01:34:03 +0900
Subject: [PATCH 12/16] chore: refresh release benchmark provenance
---
benchmarks/results/retrieval-quality-v1.json | 6 +++---
benchmarks/results/second-brain-v1.json | 6 +++---
docs/assets/benchmark-retrieval-quality-v1.svg | 2 +-
3 files changed, 7 insertions(+), 7 deletions(-)
diff --git a/benchmarks/results/retrieval-quality-v1.json b/benchmarks/results/retrieval-quality-v1.json
index 42b41db..9d3defd 100644
--- a/benchmarks/results/retrieval-quality-v1.json
+++ b/benchmarks/results/retrieval-quality-v1.json
@@ -224,7 +224,7 @@
}
],
"wikimap_version": "wikimap 1.1.0",
- "generated_at": "2026-07-22T16:33:16.347553+00:00",
+ "generated_at": "2026-07-22T16:33:54.361431+00:00",
"environment": {
"machine": "arm64",
"platform": "macOS-26.5.2-arm64-arm-64bit-Mach-O",
@@ -233,7 +233,7 @@
"provenance": {
"corpus_sha256": "adc729bcb6a8a8027d11176601046eaeef022bde135c7f225f8e40b78f7a998c",
"source_manifest_sha256": "e115feec5e6dafbc7718b6170b4695e96bf622d128d50d52673d1de6b8b5e5a8",
- "git_commit": "dac2913307d21b9512c0dabc277bb77e34bb54c5",
- "git_dirty": true
+ "git_commit": "285eb6525847330c3eac178dba9d1bce3c39790e",
+ "git_dirty": false
}
}
diff --git a/benchmarks/results/second-brain-v1.json b/benchmarks/results/second-brain-v1.json
index dbd7d14..dd81ded 100644
--- a/benchmarks/results/second-brain-v1.json
+++ b/benchmarks/results/second-brain-v1.json
@@ -20,12 +20,12 @@
"forbidden_atoms": 4
},
"provenance": {
- "generated_at": "2026-07-22T16:33:14+00:00",
+ "generated_at": "2026-07-22T16:33:51+00:00",
"corpus_version": "second-brain-corpus-v1",
"runner_sha256": "666510b248680357cebe9d70d42f07a7d0619522658fbb0810989aec449ec2aa",
"source_manifest_sha256": "a4f58b6f522e22515ffc20cf75aabd39bc6dcb52759c717fe635dbdce8befcab",
- "git_commit": "dac2913307d21b9512c0dabc277bb77e34bb54c5",
- "git_dirty": true,
+ "git_commit": "285eb6525847330c3eac178dba9d1bce3c39790e",
+ "git_dirty": false,
"reproduction_command": "uv run python -m benchmarks.second_brain --wikimap wikimap --format json --output benchmarks/results/second-brain-v1.json"
},
"cases": [
diff --git a/docs/assets/benchmark-retrieval-quality-v1.svg b/docs/assets/benchmark-retrieval-quality-v1.svg
index c1e1315..d688d10 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
-
+
From 746f015f6823629f2ab53b1794edec2e0963b33d Mon Sep 17 00:00:00 2001
From: hungrytech <74886812+hungrytech@users.noreply.github.com>
Date: Thu, 23 Jul 2026 01:54:57 +0900
Subject: [PATCH 13/16] test: verify native ACL and Windows handle cleanup
---
.github/workflows/ci.yml | 5 +
benchmarks/results/retrieval-quality-v1.json | 6 +-
benchmarks/results/second-brain-v1.json | 6 +-
.../assets/benchmark-retrieval-quality-v1.svg | 2 +-
tests/test_version_policy.py | 97 +++++++++++++++++++
5 files changed, 109 insertions(+), 7 deletions(-)
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 07023e8..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
diff --git a/benchmarks/results/retrieval-quality-v1.json b/benchmarks/results/retrieval-quality-v1.json
index 9d3defd..b44a139 100644
--- a/benchmarks/results/retrieval-quality-v1.json
+++ b/benchmarks/results/retrieval-quality-v1.json
@@ -224,7 +224,7 @@
}
],
"wikimap_version": "wikimap 1.1.0",
- "generated_at": "2026-07-22T16:33:54.361431+00:00",
+ "generated_at": "2026-07-22T16:54:52.500674+00:00",
"environment": {
"machine": "arm64",
"platform": "macOS-26.5.2-arm64-arm-64bit-Mach-O",
@@ -233,7 +233,7 @@
"provenance": {
"corpus_sha256": "adc729bcb6a8a8027d11176601046eaeef022bde135c7f225f8e40b78f7a998c",
"source_manifest_sha256": "e115feec5e6dafbc7718b6170b4695e96bf622d128d50d52673d1de6b8b5e5a8",
- "git_commit": "285eb6525847330c3eac178dba9d1bce3c39790e",
- "git_dirty": false
+ "git_commit": "a545b48865fd7acac979b5867a8fcfef46a76b51",
+ "git_dirty": true
}
}
diff --git a/benchmarks/results/second-brain-v1.json b/benchmarks/results/second-brain-v1.json
index dd81ded..26c72d1 100644
--- a/benchmarks/results/second-brain-v1.json
+++ b/benchmarks/results/second-brain-v1.json
@@ -20,12 +20,12 @@
"forbidden_atoms": 4
},
"provenance": {
- "generated_at": "2026-07-22T16:33:51+00:00",
+ "generated_at": "2026-07-22T16:54:50+00:00",
"corpus_version": "second-brain-corpus-v1",
"runner_sha256": "666510b248680357cebe9d70d42f07a7d0619522658fbb0810989aec449ec2aa",
"source_manifest_sha256": "a4f58b6f522e22515ffc20cf75aabd39bc6dcb52759c717fe635dbdce8befcab",
- "git_commit": "285eb6525847330c3eac178dba9d1bce3c39790e",
- "git_dirty": false,
+ "git_commit": "a545b48865fd7acac979b5867a8fcfef46a76b51",
+ "git_dirty": true,
"reproduction_command": "uv run python -m benchmarks.second_brain --wikimap wikimap --format json --output benchmarks/results/second-brain-v1.json"
},
"cases": [
diff --git a/docs/assets/benchmark-retrieval-quality-v1.svg b/docs/assets/benchmark-retrieval-quality-v1.svg
index d688d10..bb4e36d 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
-
+
diff --git a/tests/test_version_policy.py b/tests/test_version_policy.py
index 2a22369..a8b9b76 100644
--- a/tests/test_version_policy.py
+++ b/tests/test_version_policy.py
@@ -25,6 +25,7 @@
PolicyDecision,
_cleanup_fetch_process,
_download_remote_policy,
+ _fd_has_extended_acl,
_fetch_remote_policy,
_open_trusted_cache,
check_release_policy,
@@ -534,6 +535,62 @@ def children() -> set[int]:
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",'
@@ -711,6 +768,46 @@ def test_cache_rejects_a_macos_extended_acl(self) -> None:
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",
+ "trusted.richacl",
+ ):
+ 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,
From 3073f2df18196a70a0415283298864d88f9bdbba Mon Sep 17 00:00:00 2001
From: hungrytech <74886812+hungrytech@users.noreply.github.com>
Date: Thu, 23 Jul 2026 02:00:09 +0900
Subject: [PATCH 14/16] chore: refresh release benchmark provenance
---
benchmarks/results/second-brain-v1.json | 6 +++---
1 file changed, 3 insertions(+), 3 deletions(-)
diff --git a/benchmarks/results/second-brain-v1.json b/benchmarks/results/second-brain-v1.json
index 26c72d1..d75b91b 100644
--- a/benchmarks/results/second-brain-v1.json
+++ b/benchmarks/results/second-brain-v1.json
@@ -20,12 +20,12 @@
"forbidden_atoms": 4
},
"provenance": {
- "generated_at": "2026-07-22T16:54:50+00:00",
+ "generated_at": "2026-07-22T16:58:24+00:00",
"corpus_version": "second-brain-corpus-v1",
"runner_sha256": "666510b248680357cebe9d70d42f07a7d0619522658fbb0810989aec449ec2aa",
"source_manifest_sha256": "a4f58b6f522e22515ffc20cf75aabd39bc6dcb52759c717fe635dbdce8befcab",
- "git_commit": "a545b48865fd7acac979b5867a8fcfef46a76b51",
- "git_dirty": true,
+ "git_commit": "746f015f6823629f2ab53b1794edec2e0963b33d",
+ "git_dirty": false,
"reproduction_command": "uv run python -m benchmarks.second_brain --wikimap wikimap --format json --output benchmarks/results/second-brain-v1.json"
},
"cases": [
From f5bea451e99b1729ad2aa9f6755e7bbfd1ace557 Mon Sep 17 00:00:00 2001
From: hungrytech <74886812+hungrytech@users.noreply.github.com>
Date: Thu, 23 Jul 2026 02:03:10 +0900
Subject: [PATCH 15/16] test: cover supported Linux ACL markers
---
benchmarks/results/retrieval-quality-v1.json | 4 ++--
benchmarks/results/second-brain-v1.json | 6 +++---
docs/assets/benchmark-retrieval-quality-v1.svg | 2 +-
tests/test_version_policy.py | 5 ++++-
4 files changed, 10 insertions(+), 7 deletions(-)
diff --git a/benchmarks/results/retrieval-quality-v1.json b/benchmarks/results/retrieval-quality-v1.json
index b44a139..c37c751 100644
--- a/benchmarks/results/retrieval-quality-v1.json
+++ b/benchmarks/results/retrieval-quality-v1.json
@@ -224,7 +224,7 @@
}
],
"wikimap_version": "wikimap 1.1.0",
- "generated_at": "2026-07-22T16:54:52.500674+00:00",
+ "generated_at": "2026-07-22T17:03:04.844936+00:00",
"environment": {
"machine": "arm64",
"platform": "macOS-26.5.2-arm64-arm-64bit-Mach-O",
@@ -233,7 +233,7 @@
"provenance": {
"corpus_sha256": "adc729bcb6a8a8027d11176601046eaeef022bde135c7f225f8e40b78f7a998c",
"source_manifest_sha256": "e115feec5e6dafbc7718b6170b4695e96bf622d128d50d52673d1de6b8b5e5a8",
- "git_commit": "a545b48865fd7acac979b5867a8fcfef46a76b51",
+ "git_commit": "3073f2df18196a70a0415283298864d88f9bdbba",
"git_dirty": true
}
}
diff --git a/benchmarks/results/second-brain-v1.json b/benchmarks/results/second-brain-v1.json
index d75b91b..ad1b78d 100644
--- a/benchmarks/results/second-brain-v1.json
+++ b/benchmarks/results/second-brain-v1.json
@@ -20,12 +20,12 @@
"forbidden_atoms": 4
},
"provenance": {
- "generated_at": "2026-07-22T16:58:24+00:00",
+ "generated_at": "2026-07-22T17:03:02+00:00",
"corpus_version": "second-brain-corpus-v1",
"runner_sha256": "666510b248680357cebe9d70d42f07a7d0619522658fbb0810989aec449ec2aa",
"source_manifest_sha256": "a4f58b6f522e22515ffc20cf75aabd39bc6dcb52759c717fe635dbdce8befcab",
- "git_commit": "746f015f6823629f2ab53b1794edec2e0963b33d",
- "git_dirty": false,
+ "git_commit": "3073f2df18196a70a0415283298864d88f9bdbba",
+ "git_dirty": true,
"reproduction_command": "uv run python -m benchmarks.second_brain --wikimap wikimap --format json --output benchmarks/results/second-brain-v1.json"
},
"cases": [
diff --git a/docs/assets/benchmark-retrieval-quality-v1.svg b/docs/assets/benchmark-retrieval-quality-v1.svg
index bb4e36d..52670e5 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
-
+
diff --git a/tests/test_version_policy.py b/tests/test_version_policy.py
index a8b9b76..e1ebc3e 100644
--- a/tests/test_version_policy.py
+++ b/tests/test_version_policy.py
@@ -791,7 +791,10 @@ def test_linux_acl_markers_and_inspection_errors_fail_closed(self) -> None:
for marker in (
"system.posix_acl_access",
"system.nfs4_acl",
- "trusted.richacl",
+ "security.nfs4_acl",
+ "trusted.nfs4_acl",
+ "system.richacl",
+ "trusted.sgi_acl_file",
):
with self.subTest(marker=marker), patch(
"wikibrain.version_policy.os.listxattr",
From 8e4a26cd16c34e3a715a0490a8a962ad5ebdbdc4 Mon Sep 17 00:00:00 2001
From: hungrytech <74886812+hungrytech@users.noreply.github.com>
Date: Thu, 23 Jul 2026 02:04:00 +0900
Subject: [PATCH 16/16] chore: refresh release benchmark provenance
---
benchmarks/results/retrieval-quality-v1.json | 6 +++---
benchmarks/results/second-brain-v1.json | 6 +++---
docs/assets/benchmark-retrieval-quality-v1.svg | 2 +-
3 files changed, 7 insertions(+), 7 deletions(-)
diff --git a/benchmarks/results/retrieval-quality-v1.json b/benchmarks/results/retrieval-quality-v1.json
index c37c751..93c0489 100644
--- a/benchmarks/results/retrieval-quality-v1.json
+++ b/benchmarks/results/retrieval-quality-v1.json
@@ -224,7 +224,7 @@
}
],
"wikimap_version": "wikimap 1.1.0",
- "generated_at": "2026-07-22T17:03:04.844936+00:00",
+ "generated_at": "2026-07-22T17:03:28.453686+00:00",
"environment": {
"machine": "arm64",
"platform": "macOS-26.5.2-arm64-arm-64bit-Mach-O",
@@ -233,7 +233,7 @@
"provenance": {
"corpus_sha256": "adc729bcb6a8a8027d11176601046eaeef022bde135c7f225f8e40b78f7a998c",
"source_manifest_sha256": "e115feec5e6dafbc7718b6170b4695e96bf622d128d50d52673d1de6b8b5e5a8",
- "git_commit": "3073f2df18196a70a0415283298864d88f9bdbba",
- "git_dirty": true
+ "git_commit": "f5bea451e99b1729ad2aa9f6755e7bbfd1ace557",
+ "git_dirty": false
}
}
diff --git a/benchmarks/results/second-brain-v1.json b/benchmarks/results/second-brain-v1.json
index ad1b78d..3e2c2a9 100644
--- a/benchmarks/results/second-brain-v1.json
+++ b/benchmarks/results/second-brain-v1.json
@@ -20,12 +20,12 @@
"forbidden_atoms": 4
},
"provenance": {
- "generated_at": "2026-07-22T17:03:02+00:00",
+ "generated_at": "2026-07-22T17:03:25+00:00",
"corpus_version": "second-brain-corpus-v1",
"runner_sha256": "666510b248680357cebe9d70d42f07a7d0619522658fbb0810989aec449ec2aa",
"source_manifest_sha256": "a4f58b6f522e22515ffc20cf75aabd39bc6dcb52759c717fe635dbdce8befcab",
- "git_commit": "3073f2df18196a70a0415283298864d88f9bdbba",
- "git_dirty": true,
+ "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"
},
"cases": [
diff --git a/docs/assets/benchmark-retrieval-quality-v1.svg b/docs/assets/benchmark-retrieval-quality-v1.svg
index 52670e5..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
-
+