Skip to content
4 changes: 2 additions & 2 deletions .github/workflows/release-please.yml
Original file line number Diff line number Diff line change
Expand Up @@ -661,10 +661,10 @@ jobs:
cargo test --manifest-path Cargo.toml

- name: Lint standalone package with Ruff
run: uv run --with ruff ruff check src tests
run: uv run --with 'ruff==0.15.10' ruff check src tests

- name: Check standalone package formatting with Ruff
run: uv run --with ruff ruff format --check src tests
run: uv run --with 'ruff==0.15.10' ruff format --check src tests

- name: Type check standalone package with mypy
run: uv run --with mypy mypy src tests
Expand Down
6 changes: 3 additions & 3 deletions .github/workflows/test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -1015,15 +1015,15 @@ jobs:

- name: Lint standalone package with Ruff
run: |
uv run --with ruff ruff check src tests
uv run --with 'ruff==0.15.10' ruff check src tests

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Pin the documented standalone Ruff commands

uv help run describes --with as layering the requested package into a separate ephemeral environment. On a fresh package checkout, however, the required standalone commands remain uv run --with ruff ..., so they can resolve a newer Ruff instead of 0.15.10—the exact condition these workflow changes avoid—while CI passes with the pin. Update the package guide and docs/agents/picklescan-package-split.md:127-134, or make those commands invoke the root-locked tool, so canonical local validation matches CI.

AGENTS.md reference: packages/modelaudit-picklescan/AGENTS.md:L39-L45

Useful? React with 👍 / 👎.


- name: Check standalone package import organization with Ruff
run: |
uv run --with ruff ruff check --select I src tests
uv run --with 'ruff==0.15.10' ruff check --select I src tests

- name: Check standalone package formatting with Ruff
run: |
uv run --with ruff ruff format --check src tests
uv run --with 'ruff==0.15.10' ruff format --check src tests

- name: Type check standalone package with mypy
run: |
Expand Down
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

### Bug Fixes

- Prevent Windows cache identity probes from creating locked temporary files inside scanned directories.

## [0.2.52](https://github.com/promptfoo/modelaudit/compare/v0.2.51...v0.2.52) (2026-07-22)

### Bug Fixes
Expand Down
22 changes: 13 additions & 9 deletions modelaudit/cache/scan_results_cache.py
Original file line number Diff line number Diff line change
Expand Up @@ -986,17 +986,16 @@ def _get_change_clock_probe(self, file_path: str, file_device: int) -> BinaryIO:
return existing[0]

if os.name == "nt":
candidates = [Path(tempfile.gettempdir())]
# Windows keeps TemporaryFile names visible and locked until close.
candidates = [Path(tempfile.gettempdir()), self.cache_dir]
Comment thread
mldangelo-oai marked this conversation as resolved.
else:
candidates = [self.cache_dir, Path(tempfile.gettempdir())]
ancestor = Path(os.path.abspath(file_path)).parent
while True:
candidates.append(ancestor)
if ancestor.parent == ancestor:
break
ancestor = ancestor.parent
if os.name == "nt":
candidates.append(self.cache_dir)
ancestor = Path(os.path.abspath(file_path)).parent
while True:
candidates.append(ancestor)
if ancestor.parent == ancestor:
break
ancestor = ancestor.parent

checked: set[Path] = set()
for candidate in candidates:
Expand Down Expand Up @@ -2079,6 +2078,11 @@ def clear_cache(self) -> None:

logger.debug("Clearing entire scan results cache")

with self._change_clock_probe_lock:
for probe, _directory in self._change_clock_probes.values():
probe.close()
self._change_clock_probes.clear()

# Remove all cache files except metadata
for item in self.cache_dir.iterdir():
if item.name != "cache_metadata.json":
Expand Down
23 changes: 18 additions & 5 deletions packages/modelaudit-picklescan/tests/test_api.py
Original file line number Diff line number Diff line change
Expand Up @@ -9062,26 +9062,39 @@ def test_scan_bytes_warns_when_allowlisted_module_is_unresolved(monkeypatch: pyt


@pytest.mark.parametrize(
("module", "name"),
("module", "name", "source_changes"),
[
("joblib.numpy_pickle", "NumpyArrayWrapper"),
("numpy._core.multiarray", "_reconstruct"),
("torch._utils", "_rebuild_tensor_v2"),
("joblib.numpy_pickle", "NumpyArrayWrapper", False),
("joblib.numpy_pickle", "NumpyArrayWrapper", True),
Comment on lines +9065 to +9068

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P3 Badge Keep test stabilization out of the releasing fix commit

Because this fix(cache): ... commit also touches the standalone package tree, release-please will treat it as a patch-worthy modelaudit-picklescan change and initiate a separate package version bump and publish, even though the standalone package's shipped code is unchanged. Move these test-only edits into a non-releasing test: commit so the cache fix releases only the root package.

AGENTS.md reference: packages/modelaudit-picklescan/AGENTS.md:L69-L75

Useful? React with 👍 / 👎.

("numpy._core.multiarray", "_reconstruct", False),
("torch._utils", "_rebuild_tensor_v2", False),
],
)
def test_scan_bytes_warns_on_unresolved_framework_reconstruction_global(
module: str,
name: str,
source_changes: bool,
monkeypatch: pytest.MonkeyPatch,
) -> None:
monkeypatch.setattr(
"modelaudit_picklescan.call_graph._trusted_module_origin_kind",
lambda _module_name: "unresolved",
)
if source_changes:

def raise_source_stability_error(_report_generation: int | None) -> None:
Comment thread
mldangelo-oai marked this conversation as resolved.
raise _CallGraphAnalysisLimitError("source changed during shared call-graph analysis")

monkeypatch.setattr(package_api, "_ensure_shared_source_snapshot_stable", raise_source_stability_error)

report = scan_bytes(f"c{module}\n{name}\n.".encode(), source="unresolved-framework-global.pkl")

assert report.status == ScanStatus.COMPLETE
if report.status == ScanStatus.INCONCLUSIVE:
_assert_call_graph_source_stability_error(report)
else:
assert report.status == ScanStatus.COMPLETE
if source_changes:
assert report.status == ScanStatus.INCONCLUSIVE
Comment on lines +9092 to +9097
assert report.verdict == SafetyVerdict.SUSPICIOUS
assert any(
finding.rule_code == "NON_ALLOWLISTED_GLOBAL" and finding.details.get("import_reference") == f"{module}.{name}"
Expand Down
65 changes: 61 additions & 4 deletions tests/cache/test_cache_correctness.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@
)
from pathlib import Path
from types import FunctionType, ModuleType
from typing import Any
from typing import Any, BinaryIO, cast
from zipimport import zipimporter

import pytest
Expand All @@ -37,6 +37,7 @@
from modelaudit_picklescan.call_graph import _source_resolution_context as _picklescan_source_resolution_context

from modelaudit.cache import get_cache_manager, reset_cache_manager
from modelaudit.cache import scan_results_cache as scan_results_cache_module
from modelaudit.cache.batch_operations import BatchCacheOperations
from modelaudit.cache.optimized_config import (
ConfigurationExtractor,
Expand Down Expand Up @@ -176,7 +177,8 @@ def test_cache_config_hash_preserves_128_bits(tmp_path: Path) -> None:
assert len(config_hash) == 32


def test_capture_file_identity_uses_target_filesystem_probe(
@pytest.mark.skipif(os.name == "nt", reason="Windows probes must remain outside scanned content")
def test_posix_capture_file_identity_uses_target_filesystem_probe(
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
Expand All @@ -200,15 +202,70 @@ def simulate_cache_and_system_temp_on_other_devices(directory: Path, device: int
cache._change_clock_probes.clear()


def test_windows_change_clock_probe_avoids_scanned_ancestors(
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
file_path = _make_cacheable_file(tmp_path)
cache = ScanResultsCache(str(tmp_path / "cache"))
attempted_probe_dirs: list[str] = []

def record_probe_attempt(*_args: Any, dir: str | Path, **_kwargs: Any) -> BinaryIO:
attempted_probe_dirs.append(str(dir))
raise OSError("simulated probe creation failure")

with monkeypatch.context() as patch:
patch.setattr(os, "name", "nt")
patch.setattr(scan_results_cache_module, "Path", type(file_path))
patch.setattr(
cache,
"_directory_is_on_device",
lambda directory, _device: directory == file_path.parent,
)
patch.setattr(tempfile, "TemporaryFile", record_probe_attempt)

with pytest.raises(ValueError, match="No writable cache identity probe directory"):
cache._get_change_clock_probe(str(file_path), file_path.stat().st_dev)

assert attempted_probe_dirs == []


def test_change_clock_probe_prefers_isolated_directory(tmp_path: Path) -> None:
file_path = _make_cacheable_file(tmp_path)
cache = ScanResultsCache(str(tmp_path / "cache"))
Comment thread
mldangelo-oai marked this conversation as resolved.

file_stat, _file_hash, _change_token, ancestor_identity = cache.capture_file_identity(str(file_path))

assert ancestor_identity
expected_probe_dir = Path(tempfile.gettempdir()) if os.name == "nt" else cache.cache_dir
assert cache._change_clock_probes[file_stat.st_dev][1] == expected_probe_dir
expected_probe_dirs = {Path(tempfile.gettempdir()), cache.cache_dir} if os.name == "nt" else {cache.cache_dir}
assert cache._change_clock_probes[file_stat.st_dev][1] in expected_probe_dirs


def test_clear_cache_closes_reusable_change_clock_probes(
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
cache = ScanResultsCache(str(tmp_path / "cache"))
original_unlink = Path.unlink

with tempfile.NamedTemporaryFile(mode="w+b", dir=cache.cache_dir, delete=False) as probe:
probe_path = Path(probe.name)
cache._change_clock_probes[probe_path.stat().st_dev] = (cast(BinaryIO, probe), cache.cache_dir)

def reject_locked_probe(path: Path, *, missing_ok: bool = False) -> None:
if path == probe_path and not probe.closed:
raise PermissionError("Windows cannot unlink an open cache clock probe")
original_unlink(path, missing_ok=missing_ok)

monkeypatch.setattr(Path, "unlink", reject_locked_probe)

cache.clear_cache()

assert probe.closed is True

assert cache._change_clock_probes == {}
assert not probe_path.exists()
assert cache.metadata_file.exists()


def test_windows_change_clock_probe_uses_existing_handle(
Expand Down
18 changes: 18 additions & 0 deletions tests/test_release_workflow.py
Original file line number Diff line number Diff line change
Expand Up @@ -98,6 +98,24 @@ def test_root_release_accepts_current_picklescan_version() -> None:
assert Requirement(picklescan_requirements[0]).specifier.contains(picklescan_project["version"])


def test_standalone_package_lint_uses_locked_root_ruff_version() -> None:
root_dir = Path(__file__).resolve().parents[1]
root_lock = tomllib.loads((root_dir / "uv.lock").read_text(encoding="utf-8"))
ruff_package = next(package for package in root_lock["package"] if package["name"] == "ruff")
expected_requirement = f"ruff=={ruff_package['version']}"
python_workflow = yaml.safe_load((root_dir / ".github" / "workflows" / "test.yml").read_text(encoding="utf-8"))
assert isinstance(python_workflow, dict)

for workflow, job_name in (
(_load_release_workflow(), "build-picklescan-package"),
(python_workflow, "picklescan-package"),
):
for step in _job_steps(workflow, job_name):
run = step.get("run", "")
if "ruff check" in run or "ruff format" in run:
assert expected_requirement in run

Comment on lines +109 to +133

def test_release_workflow_manual_dispatch_inputs_and_guardrails() -> None:
workflow = _load_release_workflow()

Expand Down
Loading