From 042e315368b5e9b30b985132353daa450bfc234d Mon Sep 17 00:00:00 2001 From: xiaobaifly7 <196511160+xiaobaifly7@users.noreply.github.com> Date: Thu, 12 Nov 2026 02:47:21 +0800 Subject: [PATCH 1/2] fix(jev): capture scoped evidence safely on native Windows Signed-off-by: xiaobaifly7 <196511160+xiaobaifly7@users.noreply.github.com> --- .../loopx-jev/src/loopx_jev/drift_capture.py | 141 +++++++- .../loopx-jev/tests/test_drift_capture.py | 307 ++++++++++++++++++ packages/loopx-jev/tests/test_protocol.py | 21 +- 3 files changed, 452 insertions(+), 17 deletions(-) create mode 100644 packages/loopx-jev/tests/test_drift_capture.py diff --git a/packages/loopx-jev/src/loopx_jev/drift_capture.py b/packages/loopx-jev/src/loopx_jev/drift_capture.py index 5199ea224d..b19193ff38 100644 --- a/packages/loopx-jev/src/loopx_jev/drift_capture.py +++ b/packages/loopx-jev/src/loopx_jev/drift_capture.py @@ -5,10 +5,12 @@ import difflib import hashlib import os -from pathlib import Path +import stat import subprocess import tempfile -from typing import Any +from contextlib import ExitStack +from pathlib import Path +from typing import Any, BinaryIO from .protocol import request_bytes @@ -43,11 +45,16 @@ def validate_paths(repo: Path, paths: list[str]) -> list[str]: raise ValueError("scope_requires_one_to_32_unique_files") for name in paths: path = Path(name) + parts = ( + tuple(part.casefold() for part in path.parts) + if os.name == "nt" + else path.parts + ) if ( not name or path.is_absolute() or ".." in path.parts - or ".git" in path.parts + or ".git" in parts or name != path.as_posix() or any(ord(char) < 32 for char in name) ): @@ -58,8 +65,107 @@ def validate_paths(repo: Path, paths: list[str]) -> list[str]: return sorted(paths) +def _windows_identity( + info: os.stat_result, + *, + directory: bool = False, +) -> tuple[int, int, int, int]: + attributes = getattr(info, "st_file_attributes", None) + if attributes is None: + raise ValueError("unsupported_capture_platform") + if stat.S_ISLNK(info.st_mode) or attributes & stat.FILE_ATTRIBUTE_REPARSE_POINT: + raise ValueError("scope_escapes_workspace") + if not (stat.S_ISDIR if directory else stat.S_ISREG)(info.st_mode): + raise ValueError("scope_requires_regular_files") + if not info.st_dev or not info.st_ino: + raise ValueError("capture_identity_unavailable") + return info.st_dev, info.st_ino, info.st_size, info.st_mtime_ns + + +def _windows_descriptor(target: Path, *, directory: bool = False) -> int: + import ctypes + import msvcrt + from ctypes import wintypes + + kernel = ctypes.WinDLL("kernel32", use_last_error=True) + create = kernel.CreateFileW + create.argtypes = ( + wintypes.LPCWSTR, + wintypes.DWORD, + wintypes.DWORD, + wintypes.LPVOID, + wintypes.DWORD, + wintypes.DWORD, + wintypes.HANDLE, + ) + create.restype = wintypes.HANDLE + kernel.CloseHandle.argtypes = (wintypes.HANDLE,) + kernel.CloseHandle.restype = wintypes.BOOL + filename = os.path.abspath(target) + if not filename.startswith("\\\\?\\"): + filename = ( + "\\\\?\\UNC\\" + filename[2:] + if filename.startswith("\\\\") + else "\\\\?\\" + filename + ) + # Directories deny write/delete sharing to pin traversal; OPEN_REPARSE_POINT + # opens a raced-in link itself. BACKUP_SEMANTICS permits directory handles. + flags = 0x00200000 | (0x02000000 if directory else 0) + handle = create( + filename, 0x80000000, 0x1 if directory else 0x7, None, 3, flags, None + ) + if handle == wintypes.HANDLE(-1).value: + raise ctypes.WinError(ctypes.get_last_error()) + try: + return msvcrt.open_osfhandle(handle, os.O_RDONLY | os.O_BINARY | os.O_NOINHERIT) + except BaseException: + kernel.CloseHandle(handle) + raise + + +def _windows_open(target: Path) -> BinaryIO: + descriptor = _windows_descriptor(target) + try: + return os.fdopen(descriptor, "rb") + except BaseException: + os.close(descriptor) + raise + + +def _windows_capture(repo: Path, target: Path) -> tuple[bytes, int] | None: + with ExitStack() as parents: + current = repo + for part in ("", *target.relative_to(repo).parts[:-1]): + current = current / part + try: + expected = _windows_identity(os.lstat(current), directory=True)[:2] + except FileNotFoundError: + return None + descriptor = _windows_descriptor(current, directory=True) + parents.callback(os.close, descriptor) + if _windows_identity(os.fstat(descriptor), directory=True)[:2] != expected: + raise ValueError("workspace_changed_during_capture") + try: + observed = os.lstat(target) + except FileNotFoundError: + return None + expected_file = _windows_identity(observed) + with _windows_open(target) as stream: + if _windows_identity(os.fstat(stream.fileno())) != expected_file: + raise ValueError("workspace_changed_during_capture") + raw = stream.read(MAX_BYTES + 1) + opened = os.fstat(stream.fileno()) + if ( + _windows_identity(opened) != expected_file + or _windows_identity(os.lstat(target)) != expected_file + ): + raise ValueError("workspace_changed_during_capture") + return raw, opened.st_mode & 0o111 + + def capture(repo: Path, paths: list[str]) -> dict[str, Any]: - if not hasattr(os, "O_NOFOLLOW"): + windows = os.name == "nt" and not hasattr(os, "O_NOFOLLOW") + if not windows and not hasattr(os, "O_NOFOLLOW"): raise ValueError("unsupported_capture_platform") repo = repo.resolve() paths = validate_paths(repo, paths) @@ -73,15 +179,24 @@ def capture(repo: Path, paths: list[str]) -> dict[str, Any]: target = repo / name if target.is_symlink() or not target.resolve().is_relative_to(repo): raise ValueError("scope_escapes_workspace") - if not target.exists(): - files[name] = None - continue - if not target.is_file(): - raise ValueError("scope_requires_regular_files") - # O_NOFOLLOW also rejects a final-component symlink introduced after check. - with os.fdopen(os.open(target, os.O_RDONLY | os.O_NOFOLLOW), "rb") as stream: - raw = stream.read(MAX_BYTES + 1) - mode = os.fstat(stream.fileno()).st_mode & 0o111 + if windows: + captured = _windows_capture(repo, target) + if captured is None: + files[name] = None + continue + raw, mode = captured + else: + if not target.exists(): + files[name] = None + continue + if not target.is_file(): + raise ValueError("scope_requires_regular_files") + # O_NOFOLLOW also rejects a final-component symlink introduced after check. + with os.fdopen( + os.open(target, os.O_RDONLY | os.O_NOFOLLOW), "rb" + ) as stream: + raw = stream.read(MAX_BYTES + 1) + mode = os.fstat(stream.fileno()).st_mode & 0o111 size += len(raw) if size > MAX_BYTES or b"\0" in raw: raise ValueError("oversized_or_binary_scope") diff --git a/packages/loopx-jev/tests/test_drift_capture.py b/packages/loopx-jev/tests/test_drift_capture.py new file mode 100644 index 0000000000..961e44b22b --- /dev/null +++ b/packages/loopx-jev/tests/test_drift_capture.py @@ -0,0 +1,307 @@ +"""File identity and no-follow boundaries of scoped workspace capture.""" + +import os +import stat +import subprocess +from types import SimpleNamespace + +import pytest +from loopx_jev import drift_capture + + +@pytest.fixture +def repo(tmp_path): + for args in ( + ("init", "-q"), + ( + "-c", + "user.name=Fixture", + "-c", + "user.email=fixture@example.invalid", + "commit", + "--allow-empty", + "-qm", + "baseline", + ), + ): + subprocess.run( + ["git", "-C", str(tmp_path), *args], check=True, capture_output=True + ) + return tmp_path + + +def stat_fields(info, **changes): + return SimpleNamespace( + **{ + **{ + key: getattr(info, key) + for key in ( + "st_mode", + "st_dev", + "st_ino", + "st_size", + "st_mtime_ns", + ) + }, + "st_file_attributes": getattr(info, "st_file_attributes", 0), + **changes, + } + ) + + +@pytest.fixture +def windows(monkeypatch): + platform = SimpleNamespace(**vars(os)) + platform.name = "nt" + if hasattr(platform, "O_NOFOLLOW"): + del platform.O_NOFOLLOW + platform.lstat = lambda path: stat_fields(os.lstat(path)) + platform.fstat = lambda fd: stat_fields(os.fstat(fd)) + monkeypatch.setattr(drift_capture, "os", platform) + if os.name != "nt": + # Native Windows exercises CreateFileW; other hosts simulate its IO boundary. + monkeypatch.setattr( + drift_capture, + "_windows_descriptor", + lambda path, directory=False: os.open(path, os.O_RDONLY), + ) + return platform + + +def test_windows_regular_file_without_nofollow(repo, windows): + (repo / "file.txt").write_bytes(b"before\r\nafter\n") + captured = drift_capture.stable_capture(repo, ["file.txt", "missing.txt"]) + assert captured["files"]["file.txt"]["text"] == "before\r\nafter\n" + assert captured["files"]["missing.txt"] is None + + +@pytest.mark.parametrize("name", [".GIT/config", ".gIt/config"]) +def test_windows_rejects_case_insensitive_git_metadata(repo, windows, name): + with pytest.raises(ValueError, match="invalid_scope_path"): + drift_capture.capture(repo, [name]) + + +@pytest.mark.parametrize("directory", [False, True]) +def test_windows_rejects_symlink(repo, windows, directory): + target = repo / "original" + target.mkdir() if directory else target.write_text("original") + link = repo / "link" + try: + link.symlink_to(target, target_is_directory=directory) + except OSError: + # Symlink creation can require a Windows privilege; still test lstat policy. + link.mkdir() if directory else link.write_text("original") + original = windows.lstat + windows.lstat = lambda path: ( + stat_fields(original(path), st_mode=stat.S_IFLNK) + if path == link + else original(path) + ) + name = "link/missing.txt" if directory else "link" + with pytest.raises(ValueError, match="scope_escapes_workspace"): + drift_capture.capture(repo, [name]) + + +@pytest.mark.parametrize("component", ["file.txt", "nested"]) +def test_windows_rejects_reparse_point(repo, windows, component): + (repo / "nested").mkdir() + target = repo / "nested/file.txt" + target.write_text("original") + original = windows.lstat + flagged = target if component == "file.txt" else repo / component + windows.lstat = lambda path: ( + stat_fields( + original(path), st_file_attributes=stat.FILE_ATTRIBUTE_REPARSE_POINT + ) + if path == flagged + else original(path) + ) + with pytest.raises(ValueError, match="scope_escapes_workspace"): + drift_capture.capture(repo, ["nested/file.txt"]) + + +@pytest.mark.skipif(os.name != "nt", reason="native Windows junction") +def test_windows_rejects_real_junction(repo, windows): + target = repo / "directory" + target.mkdir() + (target / "file.txt").write_text("original") + link = repo / "junction" + # Junctions do not require the symbolic-link privilege. + subprocess.run( + ["cmd", "/d", "/c", "mklink", "/J", str(link), str(target)], + check=True, + capture_output=True, + ) + try: + assert link.lstat().st_file_attributes & stat.FILE_ATTRIBUTE_REPARSE_POINT + with pytest.raises(ValueError, match="scope_escapes_workspace"): + drift_capture.capture(repo, ["junction/file.txt"]) + finally: + link.rmdir() + + +@pytest.mark.parametrize("field", ["st_dev", "st_ino", "st_size", "st_mtime_ns"]) +def test_windows_rejects_opened_identity_mismatch(repo, windows, monkeypatch, field): + (repo / "file.txt").write_text("original") + original = windows.fstat + windows.fstat = lambda fd: stat_fields( + original(fd), **{field: getattr(original(fd), field) + 1} + ) + opened = drift_capture._windows_open + + def unread(path): + stream = opened(path) + monkeypatch.setattr( + stream, "read", lambda *args: pytest.fail("read unverified bytes") + ) + return stream + + monkeypatch.setattr(drift_capture, "_windows_open", unread) + with pytest.raises(ValueError, match="workspace_changed_during_capture"): + drift_capture.capture(repo, ["file.txt"]) + + +@pytest.mark.parametrize("field", ["st_dev", "st_ino"]) +@pytest.mark.parametrize("boundary", ["lstat", "fstat"]) +def test_windows_fails_closed_without_identity(repo, windows, field, boundary): + (repo / "file.txt").write_text("original") + original = getattr(windows, boundary) + setattr(windows, boundary, lambda value: stat_fields(original(value), **{field: 0})) + with pytest.raises(ValueError, match="capture_identity_unavailable"): + drift_capture.capture(repo, ["file.txt"]) + + +def test_windows_rejects_mutation_during_read(repo, windows, monkeypatch): + target = repo / "file.txt" + target.write_text("original") + opened = drift_capture._windows_open + + def replacing(path): + stream = opened(path) + original = stream.read + + def read(*args): + raw = original(*args) + target.write_text("changed during read") + return raw + + monkeypatch.setattr(stream, "read", read) + return stream + + monkeypatch.setattr(drift_capture, "_windows_open", replacing) + with pytest.raises(ValueError, match="workspace_changed_during_capture"): + drift_capture.capture(repo, ["file.txt"]) + + +@pytest.mark.skipif(os.name != "nt", reason="native Windows no-follow handle") +def test_windows_does_not_follow_raced_in_link_to_same_file(repo, windows, monkeypatch): + target = repo / "file.txt" + target.write_text("original") + saved = repo / "saved.txt" + opened = drift_capture._windows_open + + def replacing(path): + target.rename(saved) + try: + target.symlink_to(saved) + except OSError: + saved.rename(target) + pytest.skip("symbolic-link creation requires Windows privilege") + stream = opened(path) + monkeypatch.setattr( + stream, "read", lambda *args: pytest.fail("followed raced-in link") + ) + return stream + + monkeypatch.setattr(drift_capture, "_windows_open", replacing) + with pytest.raises((ValueError, OSError)): + drift_capture.capture(repo, ["file.txt"]) + + +@pytest.mark.skipif(os.name != "nt", reason="native Windows directory sharing") +def test_windows_pins_parent_until_file_read_finishes(repo, windows, monkeypatch): + parent = repo / "nested" + parent.mkdir() + (parent / "file.txt").write_bytes(b"original") + opened = drift_capture._windows_open + + def check_parent(path): + with pytest.raises(PermissionError): + parent.rename(repo / "moved") + return opened(path) + + monkeypatch.setattr(drift_capture, "_windows_open", check_parent) + assert ( + drift_capture.capture(repo, ["nested/file.txt"])["files"]["nested/file.txt"][ + "text" + ] + == "original" + ) + parent.rename(repo / "moved") + + +@pytest.mark.skipif(os.name != "nt", reason="native Windows junction race") +def test_windows_rejects_workspace_swap_between_lstat_and_open(repo, windows, monkeypatch): + parent = repo / "nested" + parent.mkdir() + (parent / "file.txt").write_text("inside") + outside = repo.parent / (repo.name + "-outside") + outside.mkdir() + (outside / "nested").mkdir() + (outside / "nested/file.txt").write_text("outside") + saved = repo.parent / (repo.name + "-moved") + original = windows.lstat + + def swapped(path): + observed = original(path) + if path == repo: + repo.rename(saved) + subprocess.run( + ["cmd", "/d", "/c", "mklink", "/J", str(repo), str(outside)], + check=True, + capture_output=True, + ) + return observed + + windows.lstat = swapped + monkeypatch.setattr( + drift_capture, "_windows_open", lambda path: pytest.fail("opened outside file") + ) + try: + with pytest.raises( + ValueError, match="scope_escapes_workspace|workspace_changed_during_capture" + ): + drift_capture.capture(repo, ["nested/file.txt"]) + finally: + if repo.is_junction(): + repo.rmdir() + if saved.exists(): + saved.rename(repo) + + +def test_posix_keeps_nofollow_open(repo, monkeypatch): + (repo / "file.txt").write_bytes(b"original\n") + platform = SimpleNamespace(**vars(os)) + platform.name = "posix" + platform.O_NOFOLLOW = getattr(os, "O_NOFOLLOW", 0x40000000) + calls = [] + + def opened(path, flags): + calls.append(flags) + actual = ( + flags if os.name != "nt" else (flags & ~platform.O_NOFOLLOW) | os.O_BINARY + ) + return os.open(path, actual) + + platform.open = opened + monkeypatch.setattr(drift_capture, "os", platform) + monkeypatch.setattr( + drift_capture, + "_windows_open", + lambda path: pytest.fail("Windows open on POSIX"), + ) + assert ( + drift_capture.stable_capture(repo, ["file.txt"])["files"]["file.txt"]["text"] + == "original\n" + ) + assert len(calls) == 2 and all(flags & platform.O_NOFOLLOW for flags in calls) diff --git a/packages/loopx-jev/tests/test_protocol.py b/packages/loopx-jev/tests/test_protocol.py index a5176f315b..a600d2efd6 100644 --- a/packages/loopx-jev/tests/test_protocol.py +++ b/packages/loopx-jev/tests/test_protocol.py @@ -2,22 +2,27 @@ import json import os -from pathlib import Path import subprocess import sys +from pathlib import Path import pytest from loopx_jev.config import load_config, strict_json from loopx_jev.progress import DOMAINS, decode_assessment from loopx_jev.protocol import validate_choice -from loopx_jev.transport import send, TransportFailure +from loopx_jev.transport import TransportFailure, send @pytest.mark.parametrize("code", ["unsupported_method", "invalid_request", "io_permission_denied"]) def test_manual_basis_preserves_only_the_typed_legacy_method_boundary(tmp_path, monkeypatch, code): - from loopx.control_plane.effect_runtime import EffectRuntimeRemoteError, EffectRuntimeRejected, EffectRuntimePermanentIOError from loopx_jev.runner import read_basis + from loopx.control_plane.effect_runtime import ( + EffectRuntimePermanentIOError, + EffectRuntimeRejected, + EffectRuntimeRemoteError, + ) + path = tmp_path / "basis.json" manifest = {"goal_id": "study", "objective": "Study a declared file", "acceptance": ["Observe its net change"], "evidence": [{"ref": "code.py"}]} @@ -134,6 +139,7 @@ def test_source_only_off_command_loads_no_transport(tmp_path): """ process = subprocess.run( [sys.executable, "-c", code], + check=False, cwd=tmp_path, capture_output=True, text=True, @@ -180,8 +186,15 @@ def test_only_actual_refresh_command_is_observed(args, expected): def test_unavailable_platform_file_primitives_are_explicit(tmp_path, monkeypatch): + from types import SimpleNamespace + + from loopx_jev import drift_capture from loopx_jev.drift_capture import capture - monkeypatch.delattr(os, "O_NOFOLLOW") + platform = SimpleNamespace(**vars(os)) + platform.name = "unsupported" + if hasattr(platform, "O_NOFOLLOW"): + del platform.O_NOFOLLOW + monkeypatch.setattr(drift_capture, "os", platform) with pytest.raises(ValueError, match="unsupported_capture_platform"): capture(tmp_path, ["file.txt"]) From be915c2b2466a7a52c4b3ab91c0c04d3b3aad477 Mon Sep 17 00:00:00 2001 From: xiaobaifly7 <196511160+xiaobaifly7@users.noreply.github.com> Date: Thu, 12 Nov 2026 10:34:30 +0800 Subject: [PATCH 2/2] fix(jev): enforce canonical Windows capture scope Signed-off-by: xiaobaifly7 <196511160+xiaobaifly7@users.noreply.github.com> --- packages/loopx-jev/DRIFT_SHADOW.md | 18 +++- packages/loopx-jev/DRIFT_SHADOW.zh-CN.md | 6 +- .../loopx-jev/src/loopx_jev/drift_capture.py | 31 +++++++ .../loopx-jev/tests/test_drift_capture.py | 88 ++++++++++++++++++- 4 files changed, 138 insertions(+), 5 deletions(-) diff --git a/packages/loopx-jev/DRIFT_SHADOW.md b/packages/loopx-jev/DRIFT_SHADOW.md index e70578b8f9..2cff77d9ec 100644 --- a/packages/loopx-jev/DRIFT_SHADOW.md +++ b/packages/loopx-jev/DRIFT_SHADOW.md @@ -59,6 +59,11 @@ baseline. Changes outside the list may still be valid Agent work. `no_delta` means no change in the observed material, not no progress on the whole task. Reading a test report does not run the test or independently certify its claim. +Git metadata (`.git` at any path component) is excluded. On Windows this rule +also applies to the normalized, workspace-relative path obtained from the opened +file handle, so case variants and 8.3 aliases cannot bypass it. Capture fails +closed if the canonical handle path cannot be obtained. + For example, selecting only a function's file may omit the helper it calls and the test that exercises it. A judgment from that packet cannot certify the full behavior. Include relevant tests, results and dependencies deliberately; if the @@ -112,9 +117,13 @@ certify model correctness or grant mutation authority. ## Run it -Use Python 3.11+ and the Node runtime required by the LoopX checkout. This -scoped collector targets POSIX file handling on Linux/macOS; Windows capture is -not qualified and unsupported file primitives produce an unavailable observation. +Use Python 3.11+ and the Node runtime required by the LoopX checkout. The +scoped collector uses POSIX no-follow file handling on Linux/macOS and native +handle, reparse-point, identity and canonical-path checks on Windows. Native +Windows tests cover local NTFS capture; ACL-restricted and network-share +behavior is not qualified. Unavailable file primitives or canonical paths +produce an unavailable observation. Directory handles stay pinned during capture +and can conflict with concurrent write/rename operations. The optional distribution is not part of the default LoopX wheel. Install from the source root into a fresh environment, then run the no-key checks: @@ -125,6 +134,9 @@ uv pip install --python .venv-jev/bin/python -e '.[test]' -e packages/loopx-jev .venv-jev/bin/loopx-jev drift --help ``` +On Windows, use `.venv-jev/Scripts/python.exe` and +`.venv-jev/Scripts/loopx-jev.exe` in place of the `bin` entrypoints above. + This implementation only observes task progress; other assessment directions and ranking code are not provided. Use `loopx_jev_drift_config_v0` and `minimum_label_probability`; old multi-direction pilot profiles are rejected rather than silently promoted. diff --git a/packages/loopx-jev/DRIFT_SHADOW.zh-CN.md b/packages/loopx-jev/DRIFT_SHADOW.zh-CN.md index 879654072b..8537e58055 100644 --- a/packages/loopx-jev/DRIFT_SHADOW.zh-CN.md +++ b/packages/loopx-jev/DRIFT_SHADOW.zh-CN.md @@ -25,6 +25,8 @@ 路径必须是文件,不是目录或通配符;最多选 32 个,并受下文的字节上限约束。初始化后清单固定;要变更范围,需显式新建观察器/预算并建立新基线。Agent 在清单外做的工作可能完全有效,`no_delta` 只表示所观察材料没有变化,不表示整个任务没有进展。读取测试报告也不等于执行测试或独立认证其中的声明。 +路径任何层级的 Git 元数据目录(`.git`)均被排除。在 Windows 上,同一规则也检查从已打开文件句柄取得的、相对工作区的规范路径,大小写变体和 8.3 别名不能绕过。无法取得规范句柄路径时,采集直接拒绝。 + 例如只选择函数所在文件,而漏掉被调用的 helper 和对应测试,评估就不能证明完整行为。应主动纳入相关测试、结果和依赖;必要材料装不下时,不能把部分材料包装成完整证据。 ## 绑定规范任务 criterion @@ -64,7 +66,7 @@ binding 固定 Goal/Agent/Todo 身份、contract 修订/摘要、任务语义 ## 操作方法 -使用 Python 3.11+ 和 LoopX 检出要求的 Node 运行时。采集器目前面向 Linux/macOS 的 POSIX 文件处理;Windows 采集未验证,缺少所需文件原语时记录观察不可用。此可选发行包不进入 LoopX 默认 wheel;从源码根目录在新环境中安装,再执行不需要 key 的集成测试: +使用 Python 3.11+ 和 LoopX 检出要求的 Node 运行时。采集器在 Linux/macOS 上沿用 POSIX no-follow 文件处理,在 Windows 上使用原生句柄、reparse point、身份和规范路径检查。原生 Windows 测试覆盖本地 NTFS 采集;受限 ACL 和网络共享场景尚未验证。缺少所需文件原语或无法取得规范路径时,记录观察不可用。采集期间目录句柄保持固定,可能与并发写入或重命名冲突。此可选发行包不进入 LoopX 默认 wheel;从源码根目录在新环境中安装,再执行不需要 key 的集成测试: ```bash uv venv .venv-jev @@ -73,6 +75,8 @@ uv pip install --python .venv-jev/bin/python -e '.[test]' -e packages/loopx-jev .venv-jev/bin/loopx-jev drift --help ``` +Windows 上将上述 `bin` 入口替换为 `.venv-jev/Scripts/python.exe` 和 `.venv-jev/Scripts/loopx-jev.exe`。 + 本切片只观察任务进展,不提供其他评估方向或排序代码。使用 `loopx_jev_drift_config_v0` 和 `minimum_label_probability`;旧多方向试点配置会被拒绝,不会静默提升。配置 key 本身不启用 shadow 或允许出站。失败时原 Agent 工作继续,不会自动启动独立 Agent 裁判。 为已有 Goal 创建被 Git 忽略的本地目录,复制 [config.shadow.json](examples/drift/config.shadow.json) 和 [basis.json](examples/drift/basis.json)。将示例 Goal id、目标、验收条件改为本次契约。可选 `evidence` 引用交付工作区中的常规文件,例如独立产生的测试报告;不要在配置或契约中填写密钥。默认 `allow_egress: false`,确认指定材料允许出站后再设为 true,并在**消费者的环境变量**中配置 `TYPESAFE_API_KEY`。 diff --git a/packages/loopx-jev/src/loopx_jev/drift_capture.py b/packages/loopx-jev/src/loopx_jev/drift_capture.py index b19193ff38..f88e2b77d5 100644 --- a/packages/loopx-jev/src/loopx_jev/drift_capture.py +++ b/packages/loopx-jev/src/loopx_jev/drift_capture.py @@ -132,9 +132,32 @@ def _windows_open(target: Path) -> BinaryIO: raise +def _windows_final_path(descriptor: int) -> Path: + import ctypes + import msvcrt + from ctypes import wintypes + + kernel = ctypes.WinDLL("kernel32", use_last_error=True) + final_path = kernel.GetFinalPathNameByHandleW + final_path.argtypes = ( + wintypes.HANDLE, + wintypes.LPWSTR, + wintypes.DWORD, + wintypes.DWORD, + ) + final_path.restype = wintypes.DWORD + buffer = ctypes.create_unicode_buffer(32768) + # FILE_NAME_NORMALIZED | VOLUME_NAME_DOS keeps root and file names comparable. + length = final_path(msvcrt.get_osfhandle(descriptor), buffer, len(buffer), 0) + if not 0 < length < len(buffer): + raise ValueError("capture_path_unavailable") + return Path(buffer.value) + + def _windows_capture(repo: Path, target: Path) -> tuple[bytes, int] | None: with ExitStack() as parents: current = repo + canonical_repo = repo for part in ("", *target.relative_to(repo).parts[:-1]): current = current / part try: @@ -145,6 +168,8 @@ def _windows_capture(repo: Path, target: Path) -> tuple[bytes, int] | None: parents.callback(os.close, descriptor) if _windows_identity(os.fstat(descriptor), directory=True)[:2] != expected: raise ValueError("workspace_changed_during_capture") + if not part: + canonical_repo = _windows_final_path(descriptor) try: observed = os.lstat(target) except FileNotFoundError: @@ -153,6 +178,12 @@ def _windows_capture(repo: Path, target: Path) -> tuple[bytes, int] | None: with _windows_open(target) as stream: if _windows_identity(os.fstat(stream.fileno())) != expected_file: raise ValueError("workspace_changed_during_capture") + canonical = _windows_final_path(stream.fileno()) + if not canonical.is_relative_to(canonical_repo): + raise ValueError("scope_escapes_workspace") + validate_paths( + canonical_repo, [canonical.relative_to(canonical_repo).as_posix()] + ) raw = stream.read(MAX_BYTES + 1) opened = os.fstat(stream.fileno()) if ( diff --git a/packages/loopx-jev/tests/test_drift_capture.py b/packages/loopx-jev/tests/test_drift_capture.py index 961e44b22b..4892959e5a 100644 --- a/packages/loopx-jev/tests/test_drift_capture.py +++ b/packages/loopx-jev/tests/test_drift_capture.py @@ -60,10 +60,20 @@ def windows(monkeypatch): monkeypatch.setattr(drift_capture, "os", platform) if os.name != "nt": # Native Windows exercises CreateFileW; other hosts simulate its IO boundary. + opened_paths = {} + + def descriptor(path, directory=False): + fd = os.open(path, os.O_RDONLY) + opened_paths[fd] = path.resolve() + return fd + monkeypatch.setattr( drift_capture, "_windows_descriptor", - lambda path, directory=False: os.open(path, os.O_RDONLY), + descriptor, + ) + monkeypatch.setattr( + drift_capture, "_windows_final_path", lambda fd: opened_paths[fd] ) return platform @@ -75,12 +85,88 @@ def test_windows_regular_file_without_nofollow(repo, windows): assert captured["files"]["missing.txt"] is None +def test_windows_stable_capture_allows_ctime_skew(repo, windows): + (repo / "file.txt").write_bytes(b"ordinary file\n") + lstat, fstat = windows.lstat, windows.fstat + # A stable file's lstat/fstat ctime can differ; identity must not depend on it. + windows.lstat = lambda path: stat_fields(lstat(path), st_ctime_ns=1) + windows.fstat = lambda fd: stat_fields(fstat(fd), st_ctime_ns=2) + assert ( + drift_capture.stable_capture(repo, ["file.txt"])["files"]["file.txt"]["text"] + == "ordinary file\n" + ) + + @pytest.mark.parametrize("name", [".GIT/config", ".gIt/config"]) def test_windows_rejects_case_insensitive_git_metadata(repo, windows, name): with pytest.raises(ValueError, match="invalid_scope_path"): drift_capture.capture(repo, [name]) +@pytest.fixture +def unread_windows_file(monkeypatch): + opened = drift_capture._windows_open + + def unread(path): + stream = opened(path) + monkeypatch.setattr( + stream, "read", lambda *args: pytest.fail("read excluded or unverified bytes") + ) + return stream + + monkeypatch.setattr(drift_capture, "_windows_open", unread) + + +@pytest.mark.skipif(os.name != "nt", reason="native Windows path representations") +@pytest.mark.parametrize( + "representation", ["long", "case", "extended", "device", "short", "short_case"] +) +def test_windows_rejects_native_git_path_representations( + repo, windows, unread_windows_file, representation +): + if representation.startswith("short"): + result = subprocess.run( + ["fsutil", "file", "setshortname", str(repo / ".git"), "GIT~1"], + capture_output=True, + ) + if result.returncode: + pytest.skip( + "8.3 alias creation unavailable: " + + (result.stdout + result.stderr).decode(errors="replace").strip() + ) + name = { + "long": ".git/config", + "case": ".GiT/config", + "extended": f"//?/{repo.as_posix()}/.git/config", + "device": f"//./{repo.as_posix()}/.git/config", + "short": "GIT~1/config", + "short_case": "git~1/CONFIG", + }[representation] + assert os.path.samefile(repo / name, repo / ".git/config") + with pytest.raises(ValueError, match="invalid_scope_path"): + drift_capture.capture(repo, [name]) + + +@pytest.mark.skipif(os.name != "nt", reason="native Windows final-path API") +@pytest.mark.parametrize("result", [0, 32768]) +def test_windows_fails_closed_without_final_path( + repo, windows, unread_windows_file, monkeypatch, result +): + import ctypes + + (repo / "file.txt").write_bytes(b"ordinary file") + kernel = ctypes.WinDLL("kernel32", use_last_error=True) + + def unavailable(*args): + ctypes.set_last_error(5) + return result + + monkeypatch.setattr(kernel, "GetFinalPathNameByHandleW", unavailable) + monkeypatch.setattr(ctypes, "WinDLL", lambda *args, **kwargs: kernel) + with pytest.raises((ValueError, OSError)): + drift_capture.capture(repo, ["file.txt"]) + + @pytest.mark.parametrize("directory", [False, True]) def test_windows_rejects_symlink(repo, windows, directory): target = repo / "original"