From ad60bbc4873cfc6b95e0bf7bd33cb5635de0385f Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 13 Aug 2026 16:43:16 +0900 Subject: [PATCH 01/12] fix(strix): install hashed locks without pip re-resolution Required Strix is pull_request_target, so --no-deps must land on main before a 1.5.3 + cryptography 50 lock can install. pip-audit now audits hashed complete locks with --disable-pip instead of labeling ResolutionImpossible as a known vulnerability. Refs ContextualWisdomLab/.github#952 Refs ContextualWisdomLab/.github#961 --- .github/workflows/python-security.yml | 26 +- .github/workflows/strix.yml | 8 +- .gitignore | 1 + AGENTS.md | 2 + ARCHITECTURE.md | 53 +++ CHANGELOG.md | 1 + CLAUDE.md | 4 + docs/doctoring/strix-hashed-lock-no-deps.md | 76 +++++ scripts/ci/pip_audit_requirements.py | 173 ++++++++++ ...st_materialize_base_python_requirements.py | 16 + tests/test_pip_audit_requirements.py | 301 ++++++++++++++++++ 11 files changed, 638 insertions(+), 23 deletions(-) create mode 100644 ARCHITECTURE.md create mode 100644 docs/doctoring/strix-hashed-lock-no-deps.md create mode 100644 scripts/ci/pip_audit_requirements.py create mode 100644 tests/test_pip_audit_requirements.py diff --git a/.github/workflows/python-security.yml b/.github/workflows/python-security.yml index 9d2c2e965..af0b2d1a8 100644 --- a/.github/workflows/python-security.yml +++ b/.github/workflows/python-security.yml @@ -232,25 +232,7 @@ jobs: - name: Run pip-audit (hard gate on any known vulnerability) run: | set -euo pipefail - status=0 - - # Audit every discovered requirements file. - while IFS= read -r req; do - echo "::group::pip-audit -r ${req}" - pip-audit --strict --desc=on -r "${req}" || status=1 - echo "::endgroup::" - done < <(find . -type f -name 'requirements*.txt' -not -path './.git/*') - - # Audit the project itself when a PEP 621 / lock manifest exists. - if find . -maxdepth 2 -type f \ - \( -name 'pyproject.toml' -o -name 'pylock.*.toml' \) \ - -not -path './.git/*' | head -1 | grep -q .; then - echo "::group::pip-audit . (project manifest)" - pip-audit --strict --desc=on . || status=1 - echo "::endgroup::" - fi - - if [ "${status}" != "0" ]; then - echo "::error::pip-audit reported known-vulnerable Python dependencies." - exit 1 - fi + # Hashed complete locks are audited with --disable-pip so pip cannot + # re-apply a stale Requires-Dist bound and label ResolutionImpossible + # as a known vulnerability (ContextualWisdomLab/.github#961). + python3 scripts/ci/pip_audit_requirements.py diff --git a/.github/workflows/strix.yml b/.github/workflows/strix.yml index 03ec23257..330039a42 100644 --- a/.github/workflows/strix.yml +++ b/.github/workflows/strix.yml @@ -543,7 +543,13 @@ jobs: # private install umask before creating the credential-bearing Strix # entry point; the runtime gate still rejects any later relaxation. umask 022 - python3 -m pip install --disable-pip-version-check --no-cache-dir --require-hashes -r requirements-strix-ci-hashes.txt + # The compiled lock is the complete closed set. --no-deps is required + # so pip does not re-apply a stale Requires-Dist bound (strix-agent + # still declares cryptography<49) after a CVE-fixed cryptography pin + # was resolved at compile time. pull_request_target runs this file + # from the base branch, so this flag must land on main before a + # lock that needs it can install. + python3 -m pip install --disable-pip-version-check --no-cache-dir --require-hashes --no-deps -r requirements-strix-ci-hashes.txt strix_executable="$(command -v strix || true)" if [ -z "$strix_executable" ] || [[ "$strix_executable" != /* ]] \ || [ ! -f "$strix_executable" ] || [ -L "$strix_executable" ] \ diff --git a/.gitignore b/.gitignore index b98cb1f1d..d123b43e3 100644 --- a/.gitignore +++ b/.gitignore @@ -3,3 +3,4 @@ __pycache__/ .coverage .pytest_cache/ .codegraph/ +.venv/ diff --git a/AGENTS.md b/AGENTS.md index 688b33035..0588d66e4 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -2,3 +2,5 @@ > **Agents: read the master context FIRST.** Before any work, read [`docs/CWL-MASTER-CONTEXT.md`](docs/CWL-MASTER-CONTEXT.md) (mission · naruon-as-platform + inter-component UML · cross-cutting disciplines · conventions · roadmap · current state), the live **GitHub Project #1** (work/roadmap source of truth), the full spec **ContextualWisdomLab/naruon#974**, and operate the Project per [`docs/agent-github-project-protocol.md`](docs/agent-github-project-protocol.md). The repo/Project — not any private agent memory — is the source of truth. +> +> Required Strix (`pull_request_target`) installs the hashed lock with `--require-hashes --no-deps`. Hashed-lock pip-audit uses `--disable-pip` via `scripts/ci/pip_audit_requirements.py`. Do not drop `cryptography==50.0.0` to satisfy strix-agent's stale `<49` bound. See [`docs/doctoring/strix-hashed-lock-no-deps.md`](docs/doctoring/strix-hashed-lock-no-deps.md) and [`ARCHITECTURE.md`](ARCHITECTURE.md). diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md new file mode 100644 index 000000000..cb0dd8115 --- /dev/null +++ b/ARCHITECTURE.md @@ -0,0 +1,53 @@ +# Architecture — org-central `.github` control plane + +This repository is the ContextualWisdomLab organization profile, the central +PR-governance / required-workflow hub, and the Cloudflare DNS/Pages +declaration. The live work tracker is GitHub Project #1. + +## Trust boundary: required Strix vs head-tree locks + +```mermaid +flowchart LR + PR["PR head tree
requirements-strix-ci-hashes.txt"] + BASE["Protected main
.github/workflows/strix.yml"] + PIP["pip install --require-hashes --no-deps"] + AUDIT["python-security.yml
pip_audit_requirements.py --disable-pip"] + GATE["strix_quick_gate.sh
artifact fail-closed"] + + PR -->|"pull_request_target checks out head"| PIP + BASE -->|"workflow YAML comes from base"| PIP + PIP --> GATE + PR -->|"pull_request uses the PR workflow"| AUDIT +``` + +A `pull_request_target` job cannot honor an installer flag that exists only on +the pull request. `--no-deps` must therefore merge to `main` before a lock +that needs it (strix-agent 1.5.3 + cryptography 50.0.0) can install. pip-audit +is `pull_request`, so hashed-lock `--disable-pip` takes effect on the same +head. Decision record: +[`docs/doctoring/strix-hashed-lock-no-deps.md`](docs/doctoring/strix-hashed-lock-no-deps.md). + +## Trusted-uv installer platform gate + +```mermaid +flowchart TD + Host["Developer or CI host"] + Gate{"sys.platform is linux and machine is x86_64?"} + Install["Verify pinned archive, version, and cache"] + Reject["Fail closed: unsupported runner"] + + Host --> Gate + Gate -->|"no"| Reject + Gate -->|"yes"| Install +``` + +Installer verification tests simulate Linux x86_64 and clear the process cache +(CWE-670) so they measure verification rather than the host-architecture gate. + +## Other control-plane surfaces + +- OpenCode / Noema are reviewers (`edit: deny`); GitHub Actions performs + mechanical updates and merges. +- Review JSON embedded in Markdown is escaped by + `scripts/ci/opencode_review_normalize_output.py`. +- Cloudflare reconcile is dry-run by default; PRs never see the API token. diff --git a/CHANGELOG.md b/CHANGELOG.md index bf30091dd..54ba6d336 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,7 @@ Semantic Versioning where the repository publishes a release. ### Fixed +- Landed `--require-hashes --no-deps` on the required Strix installer and taught pip-audit to audit hashed complete locks with `--disable-pip`, so a later strix-agent 1.5.3 + cryptography 50.0.0 lock can install under `pull_request_target` and pip-audit no longer labels pip `ResolutionImpossible` as a known vulnerability (ContextualWisdomLab/.github#961, #952). - Bounded the Strix quality self-test's deterministic timeout fixtures to 3-second process and 5-second fake-sleep budgets so exact-head policy evidence completes inside the existing job limit without changing production Strix scanner timeouts, providers, credentials, or review semantics. - Allowed commas and ASCII parentheses in the bounded Strix changed-file path policy so legal tracked Packrat fixtures can receive exact-head security analysis, while rejecting raw `..` components before normalization and keeping controls, backslashes, whitespace ambiguity, and shell punctuation fail-closed. - Bound each review-agent invocation key to the wrapper's complete canonical payload, including the base branch and requesting actor; altered fields with a valid-format key now fail before durable-leader election or forwarding, and wrapper write permission is job-scoped. diff --git a/CLAUDE.md b/CLAUDE.md index 1c7bdb2f6..31fc50403 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -87,6 +87,10 @@ interrogate ## Hash-pinned requirements discipline CI installs Python tools only with `pip install --require-hashes` from the `*-hashes.txt` files. +The required Strix installer adds `--no-deps` so pip cannot re-apply a stale +`Requires-Dist` bound after a compile-time cryptography override. `python-security.yml` +audits those hashed locks through `scripts/ci/pip_audit_requirements.py` (`pip-audit +--disable-pip`). See `docs/doctoring/strix-hashed-lock-no-deps.md`. Never hand-edit a `-hashes.txt` file: edit the top-level `requirements--ci.txt` input, then regenerate with the exact `uv pip compile` command recorded in the hashes file's header comment, e.g.: diff --git a/docs/doctoring/strix-hashed-lock-no-deps.md b/docs/doctoring/strix-hashed-lock-no-deps.md new file mode 100644 index 000000000..a058ef987 --- /dev/null +++ b/docs/doctoring/strix-hashed-lock-no-deps.md @@ -0,0 +1,76 @@ +# Strix hashed-lock install and pip-audit without pip re-resolution + +검토 기준일: **2026-08-13** + +## Incident + +The required Strix workflow is `pull_request_target`: GitHub runs the **base +branch** copy of `.github/workflows/strix.yml` against the pull-request head +tree. ContextualWisdomLab/.github#961 therefore compiled a complete lock of +`strix-agent==1.5.3` plus `cryptography==50.0.0` (override for CVE-2026-39892 +and CVE-2026-69247) and added `--no-deps` to the PR copy of the installer, but +the live required job still executed main's installer: + +```text +pip install --require-hashes -r requirements-strix-ci-hashes.txt +``` + +pip re-applied `strix-agent 1.5.3 depends on cryptography<49 and >=48.0.1` +and exited `ResolutionImpossible`. The same resolver path is what +`python-security.yml` used for every `requirements*.txt` file. pip-audit then +printed `::error::pip-audit reported known-vulnerable Python dependencies` +even though no advisory was returned. A buyer watching the required security +dashboard saw two red X marks on an honest lock. + +## Decision + +1. Land `--require-hashes --no-deps` on **main's** Strix installer first, with + no lock change. The current main lock (`strix-agent==1.0.4` + + `cryptography==50.0.0`) is already a complete hashed set, so `--no-deps` + does not widen the install. After this lands, a later 1.5.3 lock can + install under the required base-branch workflow. +2. Audit hashed locks with `pip-audit --disable-pip` via + `scripts/ci/pip_audit_requirements.py`. Compile-time `*-overrides.txt` + files and unhashed inputs that already have a `*-hashes.txt` sibling are + not separate install sets. +3. Do not drop `cryptography==50.0.0` to satisfy the stale `<49` metadata + bound. Do not treat `ResolutionImpossible` as a vulnerability. + +`python-security.yml` is `pull_request` (not `_target`), so the helper takes +effect on the same head that introduces it. Strix still needs this installer +line on the protected base before #961 can go green. + +## Trust boundary + +- `--no-deps` is not an unhashed install: every wheel remains hash-pinned. +- `--disable-pip` still queries the advisory database for every pinned name + and version; it only skips pip's metadata resolver. +- NVIDIA NIM / OpenCode review-agent credentials are untouched. +- No operational PII is masked. + +## Verification contract + +`tests/test_pip_audit_requirements.py` reconstructs the #961 lock shape and +requires `--disable-pip` for that file, a skip for the override/input pair, +and the `--no-deps` installer line on `strix.yml`. + +## References (APA 7th) + +GitHub. (2026). *Cryptography vulnerable to buffer overflow if +non-contiguous buffers were passed to APIs (CVE-2026-39892, +GHSA-p423-j2cm-9vmq)*. GitHub Advisory Database. +https://github.com/advisories/GHSA-p423-j2cm-9vmq + +GitHub. (2026). *PKCS#7 decryption timing oracle in pyca/cryptography +(CVE-2026-69247, GHSA-g6cj-pr64-35w5)*. GitHub Advisory Database. +https://github.com/advisories/GHSA-g6cj-pr64-35w5 + +National Institute of Standards and Technology. (2026). +*CVE-2026-69247*. National Vulnerability Database. +https://nvd.nist.gov/vuln/detail/CVE-2026-69247 + +pypa. (2025). *pip-audit: ``--disable-pip`` (hashed requirements / ``--no-deps`` +only)*. https://github.com/pypa/pip-audit + +Python Packaging Authority. (n.d.). *Hash-checking mode*. pip documentation. +https://pip.pypa.io/en/stable/topics/secure-installs/#hash-checking-mode diff --git a/scripts/ci/pip_audit_requirements.py b/scripts/ci/pip_audit_requirements.py new file mode 100644 index 000000000..29a6e10bd --- /dev/null +++ b/scripts/ci/pip_audit_requirements.py @@ -0,0 +1,173 @@ +"""Audit Python requirement files without re-resolving hashed override locks. + +pip-audit 2.10's default path invokes pip to resolve each ``requirements*.txt`` +file. That lies about a complete hashed lock compiled with a documented +override: pip reports ``ResolutionImpossible`` (strix-agent ``cryptography<49`` +versus the CVE-fixed ``cryptography==50.0.0`` pin) and the workflow labels the +failure ``known-vulnerable``. This helper audits hashed locks with +``--disable-pip`` so the pinned set is checked against the advisory database +without re-applying stale metadata bounds. +""" + +from __future__ import annotations + +import argparse +import pathlib +import subprocess +import sys +from collections.abc import Callable, Sequence + + +Runner = Callable[[Sequence[str]], int] + + +def _requirement_lines(path: pathlib.Path) -> list[str]: + """Return non-empty, non-comment requirement lines.""" + + lines: list[str] = [] + for raw_line in path.read_text(encoding="utf-8", errors="ignore").splitlines(): + line = raw_line.strip() + if not line or line.startswith("#"): + continue + lines.append(line) + return lines + + +def is_override_file(path: pathlib.Path) -> bool: + """Return whether *path* is a compile-time override input, not an install set.""" + + return path.name.endswith("-overrides.txt") + + +def is_hashed_lock(path: pathlib.Path) -> bool: + """Return whether *path* is a pip hash-checking lock.""" + + if path.name.endswith("-hashes.txt"): + return True + lines = _requirement_lines(path) + if not lines: + return False + return any(line == "--require-hashes" or "--hash=" in line for line in lines) + + +def hashed_sibling(path: pathlib.Path) -> pathlib.Path | None: + """Return the compiled ``*-hashes.txt`` sibling when *path* is its input.""" + + if is_hashed_lock(path) or is_override_file(path): + return None + if not path.name.endswith(".txt"): + return None + sibling = path.with_name(f"{path.name[:-4]}-hashes.txt") + if sibling.is_file(): + return sibling + return None + + +def audit_command(path: pathlib.Path) -> list[str] | None: + """Return the pip-audit argv for *path*, or ``None`` to skip the file. + + Hashed locks are audited with ``--disable-pip`` so pip cannot re-apply a + stale ``Requires-Dist`` upper bound. Compile-time override files and + unhashed inputs that already have a hashed sibling are skipped because the + lock is the install set a buyer actually receives. + """ + + if is_override_file(path): + return None + if is_hashed_lock(path): + return [ + "pip-audit", + "--strict", + "--desc=on", + "--disable-pip", + "-r", + str(path), + ] + if hashed_sibling(path) is not None: + return None + return ["pip-audit", "--strict", "--desc=on", "-r", str(path)] + + +def discover_requirement_files(root: pathlib.Path) -> list[pathlib.Path]: + """Return ``requirements*.txt`` files under *root*, skipping ``.git``.""" + + found: list[pathlib.Path] = [] + for path in sorted(root.rglob("requirements*.txt")): + if ".git" in path.parts: + continue + if not path.is_file(): + continue + found.append(path) + return found + + +def should_audit_project_manifest(root: pathlib.Path) -> bool: + """Return whether a PEP 621 or pylock manifest exists within two levels.""" + + for current in (root, *list(root.glob("*"))): + if not current.is_dir(): + continue + if (current / "pyproject.toml").is_file(): + return True + if any(current.glob("pylock.*.toml")): + return True + return False + + +def run_audits( + root: pathlib.Path, + *, + runner: Runner | None = None, +) -> int: + """Audit discovered requirement files and an optional project manifest.""" + + execute: Runner = runner or ( + lambda command: subprocess.run(command, check=False).returncode + ) + status = 0 + for path in discover_requirement_files(root): + command = audit_command(path) + if command is None: + print(f"skip {path} (hashed lock is the audited install set)", flush=True) + continue + print(f"::group::pip-audit {' '.join(command[1:])}", flush=True) + if execute(command) != 0: + status = 1 + print("::endgroup::", flush=True) + if should_audit_project_manifest(root): + command = ["pip-audit", "--strict", "--desc=on", "."] + print("::group::pip-audit . (project manifest)", flush=True) + if execute(command) != 0: + status = 1 + print("::endgroup::", flush=True) + if status != 0: + print( + "::error::pip-audit reported known-vulnerable Python dependencies.", + file=sys.stderr, + ) + return status + + +def main(argv: list[str] | None = None) -> int: + """CLI entry: audit requirement files under an optional root directory.""" + + parser = argparse.ArgumentParser( + description="Audit requirements without re-resolving hashed override locks." + ) + parser.add_argument( + "root", + nargs="?", + type=pathlib.Path, + default=pathlib.Path("."), + help="Repository root to search (default: current directory)", + ) + args = parser.parse_args(argv) + root = args.root.resolve() + if not root.is_dir(): + print(f"::error::audit root is not a directory: {root}", file=sys.stderr) + return 2 + return run_audits(root) + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tests/test_materialize_base_python_requirements.py b/tests/test_materialize_base_python_requirements.py index 8a383f0c2..a9e974d12 100644 --- a/tests/test_materialize_base_python_requirements.py +++ b/tests/test_materialize_base_python_requirements.py @@ -30,6 +30,19 @@ def _created_tool_directory(path: Path) -> str: return str(path) +def _simulate_linux_x86_64_runner(monkeypatch: pytest.MonkeyPatch) -> None: + """Let installer verification tests run on a non-Linux developer host. + + Production still fail-closes unless ``sys.platform`` is Linux and + ``platform.machine()`` is ``x86_64``. These unit tests pin both values so + they measure version verification, caching, and cleanup instead of the + host architecture gate already covered by the portability contract. + """ + monkeypatch.setattr(materializer.sys, "platform", "linux") + monkeypatch.setattr(materializer.platform, "machine", lambda: "x86_64") + materializer._install_trusted_uv.cache_clear() + + def test_materializes_only_regular_hash_locks_from_exact_base(tmp_path: Path) -> None: """A PR-modified lock cannot enter the networked coverage image build context.""" repo = tmp_path / "repo" @@ -644,6 +657,7 @@ def test_install_trusted_uv_verifies_version_and_caches_path( tmp_path: Path, monkeypatch: pytest.MonkeyPatch ) -> None: """The installer writes one executable, verifies its version, and caches it.""" + _simulate_linux_x86_64_runner(monkeypatch) tool_dir = tmp_path / "uv" monkeypatch.setattr( materializer.tempfile, @@ -690,6 +704,7 @@ def test_install_trusted_uv_rejects_version_process_failures( failure: OSError | subprocess.TimeoutExpired, ) -> None: """A missing or hung downloaded executable is removed and rejected.""" + _simulate_linux_x86_64_runner(monkeypatch) tool_dir = tmp_path / "uv" monkeypatch.setattr( materializer.tempfile, @@ -721,6 +736,7 @@ def test_install_trusted_uv_rejects_wrong_version_or_exit_status( completed: subprocess.CompletedProcess[bytes], ) -> None: """Unexpected version output or a nonzero status cannot satisfy the pin.""" + _simulate_linux_x86_64_runner(monkeypatch) tool_dir = tmp_path / f"uv-{completed.returncode}-{len(completed.stdout)}" monkeypatch.setattr( materializer.tempfile, diff --git a/tests/test_pip_audit_requirements.py b/tests/test_pip_audit_requirements.py new file mode 100644 index 000000000..529ab58a2 --- /dev/null +++ b/tests/test_pip_audit_requirements.py @@ -0,0 +1,301 @@ +"""Tests for hashed-lock pip-audit selection. + +The reality-shaped case is the live #961 failure: pip-audit invoked pip on +``requirements-strix-ci-hashes.txt`` containing both ``strix-agent==1.5.3`` +and ``cryptography==50.0.0``, pip raised ``ResolutionImpossible``, and the +workflow reported that as a known vulnerability. The helper must audit that +lock with ``--disable-pip`` instead of asking pip to re-resolve metadata. +""" + +from __future__ import annotations + +import importlib.util +import pathlib +import runpy +import sys +from typing import Any + + +MODULE_PATH = ( + pathlib.Path(__file__).resolve().parents[1] + / "scripts" + / "ci" + / "pip_audit_requirements.py" +) +WORKFLOW = ( + pathlib.Path(__file__).resolve().parents[1] + / ".github" + / "workflows" + / "python-security.yml" +) +STRIX_WORKFLOW = ( + pathlib.Path(__file__).resolve().parents[1] + / ".github" + / "workflows" + / "strix.yml" +) + + +def load_module() -> Any: + """Load the helper from its script path.""" + + spec = importlib.util.spec_from_file_location("pip_audit_requirements", MODULE_PATH) + assert spec is not None + assert spec.loader is not None + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + return module + + +def _conflicting_strix_lock(path: pathlib.Path) -> None: + """Write the buyer-shaped hashed lock that pip cannot re-resolve.""" + + digest = "a" * 64 + path.write_text( + "strix-agent==1.5.3 \\\n" + f" --hash=sha256:{digest}\n" + "cryptography==50.0.0 \\\n" + f" --hash=sha256:{digest}\n", + encoding="utf-8", + ) + + +def test_conflicting_hashed_strix_lock_uses_disable_pip(tmp_path: pathlib.Path) -> None: + """A 1.5.3 + cryptography 50 lock must not be handed to pip's resolver.""" + + module = load_module() + lock = tmp_path / "requirements-strix-ci-hashes.txt" + _conflicting_strix_lock(lock) + + command = module.audit_command(lock) + + assert command is not None + assert command[:5] == [ + "pip-audit", + "--strict", + "--desc=on", + "--disable-pip", + "-r", + ] + assert command[-1] == str(lock) + assert "--disable-pip" in command + + +def test_override_file_and_unhashed_input_with_lock_are_skipped( + tmp_path: pathlib.Path, +) -> None: + """Compile inputs are not install sets once a hashed sibling exists.""" + + module = load_module() + source = tmp_path / "requirements-strix-ci.txt" + source.write_text("strix-agent==1.5.3\ncryptography==50.0.0\n", encoding="utf-8") + lock = tmp_path / "requirements-strix-ci-hashes.txt" + _conflicting_strix_lock(lock) + override = tmp_path / "requirements-strix-ci-overrides.txt" + override.write_text("cryptography==50.0.0\n", encoding="utf-8") + + assert module.audit_command(source) is None + assert module.audit_command(override) is None + assert module.hashed_sibling(source) == lock + assert module.hashed_sibling(lock) is None + assert module.hashed_sibling(override) is None + assert module.is_override_file(override) is True + + +def test_unhashed_requirements_without_lock_keep_resolver_audit( + tmp_path: pathlib.Path, +) -> None: + """A standalone unpinned file still uses pip-audit's default resolution.""" + + module = load_module() + requirements = tmp_path / "requirements-demo.txt" + requirements.write_text("demo==1.0.0\n", encoding="utf-8") + + command = module.audit_command(requirements) + + assert command == [ + "pip-audit", + "--strict", + "--desc=on", + "-r", + str(requirements), + ] + + +def test_discover_skips_git_and_audits_manifest( + tmp_path: pathlib.Path, capsys: Any +) -> None: + """``.git`` copies are ignored; a nearby pyproject.toml is audited.""" + + module = load_module() + (tmp_path / ".git").mkdir() + git_copy = tmp_path / ".git" / "requirements-hidden.txt" + git_copy.write_text("hidden==1.0.0\n", encoding="utf-8") + visible = tmp_path / "requirements-visible-hashes.txt" + _conflicting_strix_lock(visible) + (tmp_path / "pyproject.toml").write_text("[project]\nname='demo'\n", encoding="utf-8") + calls: list[list[str]] = [] + + def fake_runner(command: list[str]) -> int: + calls.append(list(command)) + return 0 + + rc = module.run_audits(tmp_path, runner=fake_runner) + + assert rc == 0 + assert [path.name for path in module.discover_requirement_files(tmp_path)] == [ + "requirements-visible-hashes.txt" + ] + assert calls[0][3] == "--disable-pip" + assert calls[1] == ["pip-audit", "--strict", "--desc=on", "."] + logged = capsys.readouterr().out + assert "::group::pip-audit" in logged + assert module.should_audit_project_manifest(tmp_path) is True + + +def test_failed_hashed_audit_is_not_relabeled_as_missing_input( + tmp_path: pathlib.Path, capsys: Any +) -> None: + """A real advisory hit still fails closed with the existing error phrase.""" + + module = load_module() + lock = tmp_path / "requirements-strix-ci-hashes.txt" + _conflicting_strix_lock(lock) + + rc = module.run_audits(tmp_path, runner=lambda _command: 1) + + assert rc == 1 + err = capsys.readouterr().err + assert "known-vulnerable Python dependencies" in err + + +def test_missing_root_fails_closed(tmp_path: pathlib.Path, capsys: Any) -> None: + """A non-directory root is rejected before any audit command runs.""" + + module = load_module() + rc = module.main([str(tmp_path / "missing-root")]) + + assert rc == 2 + assert "audit root is not a directory" in capsys.readouterr().err + + +def test_empty_tree_without_manifest_is_clean(tmp_path: pathlib.Path) -> None: + """No requirement files and no PEP 621 manifest is a successful no-op.""" + + module = load_module() + (tmp_path / "notes").mkdir() + (tmp_path / "notes" / "readme.txt").write_text("not a lock\n", encoding="utf-8") + (tmp_path / "requirements-not-a-file.txt").mkdir() + (tmp_path / "stray.txt").write_text("file next to root\n", encoding="utf-8") + + assert module.should_audit_project_manifest(tmp_path) is False + assert module.discover_requirement_files(tmp_path) == [] + assert module.run_audits(tmp_path, runner=lambda _command: 1) == 0 + + +def test_run_audits_skips_compile_inputs_and_fails_manifest( + tmp_path: pathlib.Path, capsys: Any +) -> None: + """Override/input files are skipped; a failing project audit still fails closed.""" + + module = load_module() + source = tmp_path / "requirements-strix-ci.txt" + source.write_text("strix-agent==1.5.3\n", encoding="utf-8") + (tmp_path / "requirements-strix-ci-hashes.txt").write_text( + "strix-agent==1.5.3 --hash=sha256:" + ("b" * 64) + "\n", + encoding="utf-8", + ) + (tmp_path / "requirements-strix-ci-overrides.txt").write_text( + "cryptography==50.0.0\n", + encoding="utf-8", + ) + (tmp_path / "pyproject.toml").write_text("[project]\nname='demo'\n", encoding="utf-8") + (tmp_path / "requirements-strix-ci-hashes-dir").mkdir() + hashed_dir_sibling = tmp_path / "requirements-plain.txt" + hashed_dir_sibling.write_text("plain==1.0.0\n", encoding="utf-8") + (tmp_path / "requirements-plain-hashes.txt").mkdir() + + def fake_runner(command: list[str]) -> int: + return 1 if command[-1] == "." else 0 + + rc = module.run_audits(tmp_path, runner=fake_runner) + + assert rc == 1 + logged = capsys.readouterr().out + assert "skip" in logged + assert module.hashed_sibling(hashed_dir_sibling) is None + + +def test_pylock_manifest_and_require_hashes_directive( + tmp_path: pathlib.Path, +) -> None: + """A pylock file and an explicit ``--require-hashes`` line are recognized.""" + + module = load_module() + nested = tmp_path / "svc" + nested.mkdir() + (nested / "pylock.svc.toml").write_text("lock = true\n", encoding="utf-8") + directed = tmp_path / "requirements-directed.txt" + directed.write_text("--require-hashes\ndemo==1.0.0\n", encoding="utf-8") + comment_only = tmp_path / "requirements-comments.txt" + comment_only.write_text("# only a comment\n", encoding="utf-8") + odd = tmp_path / "requirements-odd" + odd.write_text("demo==1.0.0\n", encoding="utf-8") + + assert module.should_audit_project_manifest(tmp_path) is True + assert module.is_hashed_lock(directed) is True + assert module.is_hashed_lock(comment_only) is False + assert module.hashed_sibling(odd) is None + assert module.audit_command(directed) is not None + assert "--disable-pip" in module.audit_command(directed) + + +def test_workflow_invokes_helper_and_strix_installs_without_resolving() -> None: + """The live workflows must not keep the resolver-only install or audit loop.""" + + security = WORKFLOW.read_text(encoding="utf-8") + strix = STRIX_WORKFLOW.read_text(encoding="utf-8") + assert "scripts/ci/pip_audit_requirements.py" in security + assert "find . -type f -name 'requirements*.txt'" not in security + assert "--require-hashes --no-deps -r requirements-strix-ci-hashes.txt" in strix + assert ( + "python3 -m pip install --disable-pip-version-check --no-cache-dir " + "--require-hashes -r requirements-strix-ci-hashes.txt" + not in strix + ) + + +def test_default_runner_invokes_subprocess( + tmp_path: pathlib.Path, monkeypatch: Any +) -> None: + """The production runner is ``subprocess.run`` when no test double is passed.""" + + module = load_module() + lock = tmp_path / "requirements-strix-ci-hashes.txt" + _conflicting_strix_lock(lock) + seen: list[list[str]] = [] + + class Result: + returncode = 0 + + def fake_run(command: list[str], check: bool = False) -> Result: + seen.append(list(command)) + assert check is False + return Result() + + monkeypatch.setattr(module.subprocess, "run", fake_run) + assert module.run_audits(tmp_path) == 0 + assert seen[0][0] == "pip-audit" + assert "--disable-pip" in seen[0] + + +def test_module_main_guard_executes(tmp_path: pathlib.Path, monkeypatch: Any) -> None: + """Running the file as a script exits through ``main``.""" + + monkeypatch.setattr(sys, "argv", ["pip_audit_requirements.py", str(tmp_path)]) + try: + runpy.run_path(str(MODULE_PATH), run_name="__main__") + except SystemExit as exc: + assert exc.code == 0 + else: + raise AssertionError("expected SystemExit from the module main guard") From 5e8ac95638133381d9af09d1fb1c952abd8fa7cb Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 13 Aug 2026 16:57:18 +0900 Subject: [PATCH 02/12] fix(strix): require hash evidence before disable-pip A *-hashes.txt name is not a complete lock. Audit with --disable-pip only when the file contains --hash= or --require-hashes, and skip virtualenv trees during discovery. --- CHANGELOG.md | 2 +- docs/doctoring/strix-hashed-lock-no-deps.md | 4 +++- scripts/ci/pip_audit_requirements.py | 16 +++++++++++----- tests/test_pip_audit_requirements.py | 7 +++++++ 4 files changed, 22 insertions(+), 7 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 54ba6d336..9a1f4d44a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,7 +13,7 @@ Semantic Versioning where the repository publishes a release. ### Fixed -- Landed `--require-hashes --no-deps` on the required Strix installer and taught pip-audit to audit hashed complete locks with `--disable-pip`, so a later strix-agent 1.5.3 + cryptography 50.0.0 lock can install under `pull_request_target` and pip-audit no longer labels pip `ResolutionImpossible` as a known vulnerability (ContextualWisdomLab/.github#961, #952). +- Landed `--require-hashes --no-deps` on the required Strix installer and taught pip-audit to audit hashed complete locks with `--disable-pip`, so a later strix-agent 1.5.3 + cryptography 50.0.0 lock can install under `pull_request_target` and pip-audit no longer labels pip `ResolutionImpossible` as a known vulnerability (ContextualWisdomLab/.github#961, #952). A `*-hashes.txt` name without hash evidence no longer receives `--disable-pip`, and discovery skips virtualenv trees. - Bounded the Strix quality self-test's deterministic timeout fixtures to 3-second process and 5-second fake-sleep budgets so exact-head policy evidence completes inside the existing job limit without changing production Strix scanner timeouts, providers, credentials, or review semantics. - Allowed commas and ASCII parentheses in the bounded Strix changed-file path policy so legal tracked Packrat fixtures can receive exact-head security analysis, while rejecting raw `..` components before normalization and keeping controls, backslashes, whitespace ambiguity, and shell punctuation fail-closed. - Bound each review-agent invocation key to the wrapper's complete canonical payload, including the base branch and requesting actor; altered fields with a valid-format key now fail before durable-leader election or forwarding, and wrapper write permission is job-scoped. diff --git a/docs/doctoring/strix-hashed-lock-no-deps.md b/docs/doctoring/strix-hashed-lock-no-deps.md index a058ef987..4ce6776a5 100644 --- a/docs/doctoring/strix-hashed-lock-no-deps.md +++ b/docs/doctoring/strix-hashed-lock-no-deps.md @@ -52,7 +52,9 @@ line on the protected base before #961 can go green. `tests/test_pip_audit_requirements.py` reconstructs the #961 lock shape and requires `--disable-pip` for that file, a skip for the override/input pair, -and the `--no-deps` installer line on `strix.yml`. +and the `--no-deps` installer line on `strix.yml`. A `*-hashes.txt` name +without `--hash=` / `--require-hashes` is not treated as a complete lock, +and discovery skips `.venv` trees. ## References (APA 7th) diff --git a/scripts/ci/pip_audit_requirements.py b/scripts/ci/pip_audit_requirements.py index 29a6e10bd..c480d328d 100644 --- a/scripts/ci/pip_audit_requirements.py +++ b/scripts/ci/pip_audit_requirements.py @@ -19,6 +19,9 @@ Runner = Callable[[Sequence[str]], int] +SKIP_DISCOVERY_PARTS = frozenset( + {".git", ".venv", "venv", "node_modules", "__pycache__"} +) def _requirement_lines(path: pathlib.Path) -> list[str]: @@ -40,10 +43,13 @@ def is_override_file(path: pathlib.Path) -> bool: def is_hashed_lock(path: pathlib.Path) -> bool: - """Return whether *path* is a pip hash-checking lock.""" + """Return whether *path* actually contains pip hash-checking evidence. + + A ``*-hashes.txt`` name alone is not enough: an empty or pin-only file + would otherwise be audited with ``--disable-pip`` and report a clean + empty set instead of a missing complete lock. + """ - if path.name.endswith("-hashes.txt"): - return True lines = _requirement_lines(path) if not lines: return False @@ -89,11 +95,11 @@ def audit_command(path: pathlib.Path) -> list[str] | None: def discover_requirement_files(root: pathlib.Path) -> list[pathlib.Path]: - """Return ``requirements*.txt`` files under *root*, skipping ``.git``.""" + """Return ``requirements*.txt`` files under *root*, skipping VCS and venvs.""" found: list[pathlib.Path] = [] for path in sorted(root.rglob("requirements*.txt")): - if ".git" in path.parts: + if SKIP_DISCOVERY_PARTS.intersection(path.parts): continue if not path.is_file(): continue diff --git a/tests/test_pip_audit_requirements.py b/tests/test_pip_audit_requirements.py index 529ab58a2..4f6fcd839 100644 --- a/tests/test_pip_audit_requirements.py +++ b/tests/test_pip_audit_requirements.py @@ -110,6 +110,8 @@ def test_unhashed_requirements_without_lock_keep_resolver_audit( module = load_module() requirements = tmp_path / "requirements-demo.txt" requirements.write_text("demo==1.0.0\n", encoding="utf-8") + named_lock = tmp_path / "requirements-strix-ci-hashes.txt" + named_lock.write_text("strix-agent==1.5.3\n", encoding="utf-8") command = module.audit_command(requirements) @@ -120,6 +122,8 @@ def test_unhashed_requirements_without_lock_keep_resolver_audit( "-r", str(requirements), ] + assert module.is_hashed_lock(named_lock) is False + assert "--disable-pip" not in (module.audit_command(named_lock) or []) def test_discover_skips_git_and_audits_manifest( @@ -131,6 +135,9 @@ def test_discover_skips_git_and_audits_manifest( (tmp_path / ".git").mkdir() git_copy = tmp_path / ".git" / "requirements-hidden.txt" git_copy.write_text("hidden==1.0.0\n", encoding="utf-8") + venv = tmp_path / ".venv" / "lib" + venv.mkdir(parents=True) + (venv / "requirements-venv.txt").write_text("venv==1.0.0\n", encoding="utf-8") visible = tmp_path / "requirements-visible-hashes.txt" _conflicting_strix_lock(visible) (tmp_path / "pyproject.toml").write_text("[project]\nname='demo'\n", encoding="utf-8") From 89d0b64f882fb7b1f75a38bf32ae39869dd5f538 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 13 Aug 2026 19:30:51 +0900 Subject: [PATCH 03/12] fix(strix): require --hash= pins before disable-pip A lone --require-hashes directive without hash pins is an incomplete lock. pip-audit must not --disable-pip that file, or an empty/pin-only set is reported clean. --- AGENTS.md | 2 +- CHANGELOG.md | 1 + docs/doctoring/strix-hashed-lock-no-deps.md | 4 ++-- scripts/ci/pip_audit_requirements.py | 8 ++++---- tests/test_pip_audit_requirements.py | 14 ++++++++++---- 5 files changed, 18 insertions(+), 11 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 0588d66e4..e424df626 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -3,4 +3,4 @@ > **Agents: read the master context FIRST.** Before any work, read [`docs/CWL-MASTER-CONTEXT.md`](docs/CWL-MASTER-CONTEXT.md) (mission · naruon-as-platform + inter-component UML · cross-cutting disciplines · conventions · roadmap · current state), the live **GitHub Project #1** (work/roadmap source of truth), the full spec **ContextualWisdomLab/naruon#974**, and operate the Project per [`docs/agent-github-project-protocol.md`](docs/agent-github-project-protocol.md). The repo/Project — not any private agent memory — is the source of truth. > -> Required Strix (`pull_request_target`) installs the hashed lock with `--require-hashes --no-deps`. Hashed-lock pip-audit uses `--disable-pip` via `scripts/ci/pip_audit_requirements.py`. Do not drop `cryptography==50.0.0` to satisfy strix-agent's stale `<49` bound. See [`docs/doctoring/strix-hashed-lock-no-deps.md`](docs/doctoring/strix-hashed-lock-no-deps.md) and [`ARCHITECTURE.md`](ARCHITECTURE.md). +> Required Strix (`pull_request_target`) installs the hashed lock with `--require-hashes --no-deps`. Hashed-lock pip-audit uses `--disable-pip` via `scripts/ci/pip_audit_requirements.py` only when the file contains at least one `--hash=` pin. A lone `--require-hashes` directive is not a complete lock. Do not drop `cryptography==50.0.0` to satisfy strix-agent's stale `<49` bound. See [`docs/doctoring/strix-hashed-lock-no-deps.md`](docs/doctoring/strix-hashed-lock-no-deps.md) and [`ARCHITECTURE.md`](ARCHITECTURE.md). diff --git a/CHANGELOG.md b/CHANGELOG.md index 9a1f4d44a..39334994b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,7 @@ Semantic Versioning where the repository publishes a release. ### Fixed +- Required at least one `--hash=` pin before pip-audit treats a requirements file as a complete hashed lock. A lone `--require-hashes` directive without hashes no longer receives `--disable-pip`, which would otherwise audit an incomplete set as clean. - Landed `--require-hashes --no-deps` on the required Strix installer and taught pip-audit to audit hashed complete locks with `--disable-pip`, so a later strix-agent 1.5.3 + cryptography 50.0.0 lock can install under `pull_request_target` and pip-audit no longer labels pip `ResolutionImpossible` as a known vulnerability (ContextualWisdomLab/.github#961, #952). A `*-hashes.txt` name without hash evidence no longer receives `--disable-pip`, and discovery skips virtualenv trees. - Bounded the Strix quality self-test's deterministic timeout fixtures to 3-second process and 5-second fake-sleep budgets so exact-head policy evidence completes inside the existing job limit without changing production Strix scanner timeouts, providers, credentials, or review semantics. - Allowed commas and ASCII parentheses in the bounded Strix changed-file path policy so legal tracked Packrat fixtures can receive exact-head security analysis, while rejecting raw `..` components before normalization and keeping controls, backslashes, whitespace ambiguity, and shell punctuation fail-closed. diff --git a/docs/doctoring/strix-hashed-lock-no-deps.md b/docs/doctoring/strix-hashed-lock-no-deps.md index 4ce6776a5..b9be7b53b 100644 --- a/docs/doctoring/strix-hashed-lock-no-deps.md +++ b/docs/doctoring/strix-hashed-lock-no-deps.md @@ -53,8 +53,8 @@ line on the protected base before #961 can go green. `tests/test_pip_audit_requirements.py` reconstructs the #961 lock shape and requires `--disable-pip` for that file, a skip for the override/input pair, and the `--no-deps` installer line on `strix.yml`. A `*-hashes.txt` name -without `--hash=` / `--require-hashes` is not treated as a complete lock, -and discovery skips `.venv` trees. +or a lone `--require-hashes` directive without `--hash=` is not treated +as a complete lock, and discovery skips `.venv` trees. ## References (APA 7th) diff --git a/scripts/ci/pip_audit_requirements.py b/scripts/ci/pip_audit_requirements.py index c480d328d..e0ba20268 100644 --- a/scripts/ci/pip_audit_requirements.py +++ b/scripts/ci/pip_audit_requirements.py @@ -45,15 +45,15 @@ def is_override_file(path: pathlib.Path) -> bool: def is_hashed_lock(path: pathlib.Path) -> bool: """Return whether *path* actually contains pip hash-checking evidence. - A ``*-hashes.txt`` name alone is not enough: an empty or pin-only file - would otherwise be audited with ``--disable-pip`` and report a clean - empty set instead of a missing complete lock. + A ``*-hashes.txt`` name or a lone ``--require-hashes`` directive is not + enough: an empty, pin-only, or directive-only file would otherwise be + audited with ``--disable-pip`` and report a clean incomplete set. """ lines = _requirement_lines(path) if not lines: return False - return any(line == "--require-hashes" or "--hash=" in line for line in lines) + return any("--hash=" in line for line in lines) def hashed_sibling(path: pathlib.Path) -> pathlib.Path | None: diff --git a/tests/test_pip_audit_requirements.py b/tests/test_pip_audit_requirements.py index 4f6fcd839..8cc0880d5 100644 --- a/tests/test_pip_audit_requirements.py +++ b/tests/test_pip_audit_requirements.py @@ -236,7 +236,7 @@ def fake_runner(command: list[str]) -> int: def test_pylock_manifest_and_require_hashes_directive( tmp_path: pathlib.Path, ) -> None: - """A pylock file and an explicit ``--require-hashes`` line are recognized.""" + """A pylock file is recognized; ``--require-hashes`` without hashes is not.""" module = load_module() nested = tmp_path / "svc" @@ -244,17 +244,23 @@ def test_pylock_manifest_and_require_hashes_directive( (nested / "pylock.svc.toml").write_text("lock = true\n", encoding="utf-8") directed = tmp_path / "requirements-directed.txt" directed.write_text("--require-hashes\ndemo==1.0.0\n", encoding="utf-8") + hashed = tmp_path / "requirements-hashed.txt" + hashed.write_text( + "--require-hashes\ndemo==1.0.0 --hash=sha256:" + ("a" * 64) + "\n", + encoding="utf-8", + ) comment_only = tmp_path / "requirements-comments.txt" comment_only.write_text("# only a comment\n", encoding="utf-8") odd = tmp_path / "requirements-odd" odd.write_text("demo==1.0.0\n", encoding="utf-8") assert module.should_audit_project_manifest(tmp_path) is True - assert module.is_hashed_lock(directed) is True + assert module.is_hashed_lock(directed) is False + assert "--disable-pip" not in (module.audit_command(directed) or []) + assert module.is_hashed_lock(hashed) is True + assert "--disable-pip" in (module.audit_command(hashed) or []) assert module.is_hashed_lock(comment_only) is False assert module.hashed_sibling(odd) is None - assert module.audit_command(directed) is not None - assert "--disable-pip" in module.audit_command(directed) def test_workflow_invokes_helper_and_strix_installs_without_resolving() -> None: From 1cf61170ee8ef8c74206330738d6723fdd25fa73 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 13 Aug 2026 19:43:34 +0900 Subject: [PATCH 04/12] fix(strix): require every package line hashed before disable-pip A mixed lock with one --hash= pin beside unhashed packages must not skip pip's resolver. Join continuation lines first, then require every package line to carry --hash=. --- AGENTS.md | 2 +- CHANGELOG.md | 2 +- docs/doctoring/strix-hashed-lock-no-deps.md | 3 ++- scripts/ci/pip_audit_requirements.py | 18 ++++++++++-------- tests/test_pip_audit_requirements.py | 9 +++++++++ 5 files changed, 23 insertions(+), 11 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index e424df626..5d3dea049 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -3,4 +3,4 @@ > **Agents: read the master context FIRST.** Before any work, read [`docs/CWL-MASTER-CONTEXT.md`](docs/CWL-MASTER-CONTEXT.md) (mission · naruon-as-platform + inter-component UML · cross-cutting disciplines · conventions · roadmap · current state), the live **GitHub Project #1** (work/roadmap source of truth), the full spec **ContextualWisdomLab/naruon#974**, and operate the Project per [`docs/agent-github-project-protocol.md`](docs/agent-github-project-protocol.md). The repo/Project — not any private agent memory — is the source of truth. > -> Required Strix (`pull_request_target`) installs the hashed lock with `--require-hashes --no-deps`. Hashed-lock pip-audit uses `--disable-pip` via `scripts/ci/pip_audit_requirements.py` only when the file contains at least one `--hash=` pin. A lone `--require-hashes` directive is not a complete lock. Do not drop `cryptography==50.0.0` to satisfy strix-agent's stale `<49` bound. See [`docs/doctoring/strix-hashed-lock-no-deps.md`](docs/doctoring/strix-hashed-lock-no-deps.md) and [`ARCHITECTURE.md`](ARCHITECTURE.md). +> Required Strix (`pull_request_target`) installs the hashed lock with `--require-hashes --no-deps`. Hashed-lock pip-audit uses `--disable-pip` via `scripts/ci/pip_audit_requirements.py` only when every package line carries a `--hash=` pin. A lone `--require-hashes` directive or a mixed hashed-plus-unhashed file is not a complete lock. Do not drop `cryptography==50.0.0` to satisfy strix-agent's stale `<49` bound. See [`docs/doctoring/strix-hashed-lock-no-deps.md`](docs/doctoring/strix-hashed-lock-no-deps.md) and [`ARCHITECTURE.md`](ARCHITECTURE.md). diff --git a/CHANGELOG.md b/CHANGELOG.md index 39334994b..41685d8fe 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,7 +13,7 @@ Semantic Versioning where the repository publishes a release. ### Fixed -- Required at least one `--hash=` pin before pip-audit treats a requirements file as a complete hashed lock. A lone `--require-hashes` directive without hashes no longer receives `--disable-pip`, which would otherwise audit an incomplete set as clean. +- Required every package line to carry a `--hash=` pin before pip-audit treats a requirements file as a complete hashed lock. A lone `--require-hashes` directive or a mixed hashed-plus-unhashed file no longer receives `--disable-pip`, which would otherwise audit an incomplete set as clean. - Landed `--require-hashes --no-deps` on the required Strix installer and taught pip-audit to audit hashed complete locks with `--disable-pip`, so a later strix-agent 1.5.3 + cryptography 50.0.0 lock can install under `pull_request_target` and pip-audit no longer labels pip `ResolutionImpossible` as a known vulnerability (ContextualWisdomLab/.github#961, #952). A `*-hashes.txt` name without hash evidence no longer receives `--disable-pip`, and discovery skips virtualenv trees. - Bounded the Strix quality self-test's deterministic timeout fixtures to 3-second process and 5-second fake-sleep budgets so exact-head policy evidence completes inside the existing job limit without changing production Strix scanner timeouts, providers, credentials, or review semantics. - Allowed commas and ASCII parentheses in the bounded Strix changed-file path policy so legal tracked Packrat fixtures can receive exact-head security analysis, while rejecting raw `..` components before normalization and keeping controls, backslashes, whitespace ambiguity, and shell punctuation fail-closed. diff --git a/docs/doctoring/strix-hashed-lock-no-deps.md b/docs/doctoring/strix-hashed-lock-no-deps.md index b9be7b53b..431ce272b 100644 --- a/docs/doctoring/strix-hashed-lock-no-deps.md +++ b/docs/doctoring/strix-hashed-lock-no-deps.md @@ -54,7 +54,8 @@ line on the protected base before #961 can go green. requires `--disable-pip` for that file, a skip for the override/input pair, and the `--no-deps` installer line on `strix.yml`. A `*-hashes.txt` name or a lone `--require-hashes` directive without `--hash=` is not treated -as a complete lock, and discovery skips `.venv` trees. +as a complete lock. A mixed file with one hashed line beside unhashed +packages also stays on the resolver path. Discovery skips `.venv` trees. ## References (APA 7th) diff --git a/scripts/ci/pip_audit_requirements.py b/scripts/ci/pip_audit_requirements.py index e0ba20268..784611e93 100644 --- a/scripts/ci/pip_audit_requirements.py +++ b/scripts/ci/pip_audit_requirements.py @@ -25,10 +25,12 @@ def _requirement_lines(path: pathlib.Path) -> list[str]: - """Return non-empty, non-comment requirement lines.""" + """Return logical requirement lines, joining backslash continuations.""" + text = path.read_text(encoding="utf-8", errors="ignore").replace("\r\n", "\n") + joined = text.replace("\\\n", " ") lines: list[str] = [] - for raw_line in path.read_text(encoding="utf-8", errors="ignore").splitlines(): + for raw_line in joined.splitlines(): line = raw_line.strip() if not line or line.startswith("#"): continue @@ -45,15 +47,15 @@ def is_override_file(path: pathlib.Path) -> bool: def is_hashed_lock(path: pathlib.Path) -> bool: """Return whether *path* actually contains pip hash-checking evidence. - A ``*-hashes.txt`` name or a lone ``--require-hashes`` directive is not - enough: an empty, pin-only, or directive-only file would otherwise be - audited with ``--disable-pip`` and report a clean incomplete set. + A ``*-hashes.txt`` name, a lone ``--require-hashes`` directive, or a + mixed file with one hashed line beside unhashed packages is not + enough: those would otherwise be audited with ``--disable-pip`` and + report a clean incomplete set. """ lines = _requirement_lines(path) - if not lines: - return False - return any("--hash=" in line for line in lines) + package_lines = [line for line in lines if line != "--require-hashes"] + return bool(package_lines) and all("--hash=" in line for line in package_lines) def hashed_sibling(path: pathlib.Path) -> pathlib.Path | None: diff --git a/tests/test_pip_audit_requirements.py b/tests/test_pip_audit_requirements.py index 8cc0880d5..9ea584e64 100644 --- a/tests/test_pip_audit_requirements.py +++ b/tests/test_pip_audit_requirements.py @@ -261,6 +261,15 @@ def test_pylock_manifest_and_require_hashes_directive( assert "--disable-pip" in (module.audit_command(hashed) or []) assert module.is_hashed_lock(comment_only) is False assert module.hashed_sibling(odd) is None + mixed = tmp_path / "requirements-mixed.txt" + mixed.write_text( + "strix-agent==1.5.3 \\\n" + " --hash=sha256:" + ("c" * 64) + "\n" + "unhashed-demo==1.0.0\n", + encoding="utf-8", + ) + assert module.is_hashed_lock(mixed) is False + assert "--disable-pip" not in (module.audit_command(mixed) or []) def test_workflow_invokes_helper_and_strix_installs_without_resolving() -> None: From 3caf51ffe625f0a6f2c9b945215175b12dc88973 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 13 Aug 2026 23:20:30 +0900 Subject: [PATCH 05/12] fix(strix): reject dotted requirement includes from materialize Materialize a base Python lock only when every package line is an exact SHA-256 pin or a bounded relative -r include. A lone --require-hashes directive, ./lock.txt, or -r other-hashes.txt cannot enter the trusted build context (CWE-22). --- AGENTS.md | 2 +- CHANGELOG.md | 1 + docs/doctoring/strix-hashed-lock-no-deps.md | 7 ++ .../materialize_base_python_requirements.py | 82 +++++++++++++++---- ...st_materialize_base_python_requirements.py | 11 ++- 5 files changed, 86 insertions(+), 17 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 5d3dea049..680c446c6 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -3,4 +3,4 @@ > **Agents: read the master context FIRST.** Before any work, read [`docs/CWL-MASTER-CONTEXT.md`](docs/CWL-MASTER-CONTEXT.md) (mission · naruon-as-platform + inter-component UML · cross-cutting disciplines · conventions · roadmap · current state), the live **GitHub Project #1** (work/roadmap source of truth), the full spec **ContextualWisdomLab/naruon#974**, and operate the Project per [`docs/agent-github-project-protocol.md`](docs/agent-github-project-protocol.md). The repo/Project — not any private agent memory — is the source of truth. > -> Required Strix (`pull_request_target`) installs the hashed lock with `--require-hashes --no-deps`. Hashed-lock pip-audit uses `--disable-pip` via `scripts/ci/pip_audit_requirements.py` only when every package line carries a `--hash=` pin. A lone `--require-hashes` directive or a mixed hashed-plus-unhashed file is not a complete lock. Do not drop `cryptography==50.0.0` to satisfy strix-agent's stale `<49` bound. See [`docs/doctoring/strix-hashed-lock-no-deps.md`](docs/doctoring/strix-hashed-lock-no-deps.md) and [`ARCHITECTURE.md`](ARCHITECTURE.md). +> Required Strix (`pull_request_target`) installs the hashed lock with `--require-hashes --no-deps`. Hashed-lock pip-audit uses `--disable-pip` via `scripts/ci/pip_audit_requirements.py` only when every package line carries a `--hash=` pin. A lone `--require-hashes` directive or a mixed hashed-plus-unhashed file is not a complete lock. Materialize accepts only exact SHA-256 pins or a bounded relative `-r` include (no `.`/`..`). Do not drop `cryptography==50.0.0` to satisfy strix-agent's stale `<49` bound. See [`docs/doctoring/strix-hashed-lock-no-deps.md`](docs/doctoring/strix-hashed-lock-no-deps.md) and [`ARCHITECTURE.md`](ARCHITECTURE.md). diff --git a/CHANGELOG.md b/CHANGELOG.md index 41685d8fe..4b4ede02c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,7 @@ Semantic Versioning where the repository publishes a release. ### Fixed +- Materialized base Python locks only when every package line is an exact SHA-256 pin or a bounded relative `-r`/`--requirement` include. A lone `--require-hashes` directive, a dotted include such as `./lock.txt`, or `-r other-hashes.txt` no longer enters the trusted build context. - Required every package line to carry a `--hash=` pin before pip-audit treats a requirements file as a complete hashed lock. A lone `--require-hashes` directive or a mixed hashed-plus-unhashed file no longer receives `--disable-pip`, which would otherwise audit an incomplete set as clean. - Landed `--require-hashes --no-deps` on the required Strix installer and taught pip-audit to audit hashed complete locks with `--disable-pip`, so a later strix-agent 1.5.3 + cryptography 50.0.0 lock can install under `pull_request_target` and pip-audit no longer labels pip `ResolutionImpossible` as a known vulnerability (ContextualWisdomLab/.github#961, #952). A `*-hashes.txt` name without hash evidence no longer receives `--disable-pip`, and discovery skips virtualenv trees. - Bounded the Strix quality self-test's deterministic timeout fixtures to 3-second process and 5-second fake-sleep budgets so exact-head policy evidence completes inside the existing job limit without changing production Strix scanner timeouts, providers, credentials, or review semantics. diff --git a/docs/doctoring/strix-hashed-lock-no-deps.md b/docs/doctoring/strix-hashed-lock-no-deps.md index 431ce272b..c01efed6b 100644 --- a/docs/doctoring/strix-hashed-lock-no-deps.md +++ b/docs/doctoring/strix-hashed-lock-no-deps.md @@ -56,9 +56,16 @@ and the `--no-deps` installer line on `strix.yml`. A `*-hashes.txt` name or a lone `--require-hashes` directive without `--hash=` is not treated as a complete lock. A mixed file with one hashed line beside unhashed packages also stays on the resolver path. Discovery skips `.venv` trees. +Materialize accepts a requirements include only as a two-token +``-r``/``--requirement`` form whose target is a normalized relative POSIX +lock path with no ``.`` or ``..`` components, so a dotted include cannot +enter the trusted build context (CWE-22; MITRE, 2026). ## References (APA 7th) +MITRE. (2026). *CWE-22: Improper limitation of a pathname to a restricted +directory ('Path Traversal')*. https://cwe.mitre.org/data/definitions/22.html + GitHub. (2026). *Cryptography vulnerable to buffer overflow if non-contiguous buffers were passed to APIs (CVE-2026-39892, GHSA-p423-j2cm-9vmq)*. GitHub Advisory Database. diff --git a/scripts/ci/materialize_base_python_requirements.py b/scripts/ci/materialize_base_python_requirements.py index 98cdad459..9848c3ff6 100755 --- a/scripts/ci/materialize_base_python_requirements.py +++ b/scripts/ci/materialize_base_python_requirements.py @@ -87,6 +87,57 @@ def _is_candidate_lock_name(name: str) -> bool: ) +def _is_candidate_lock_path(path: pathlib.PurePosixPath) -> bool: + """Return whether one safe tracked path can name a pip requirements lock. + + In addition to conventional ``requirements*.txt`` names, repositories often + keep concrete environment closures as direct children such as + ``requirements/ci.txt`` or ``service/requirements/package.txt``. Only direct + ``.txt`` children of a directory named ``requirements`` gain this path-based + eligibility; content must still pass the independent complete hash-pin + validation before it reaches the trusted image build context. + """ + return _is_candidate_lock_name(path.name) or ( + path.suffix == ".txt" and path.parent.name == "requirements" + ) + + +def _is_bounded_requirement_include(line: str) -> bool: + """Return whether one requirements include names a bounded relative file. + + Includes are accepted only as a two-token ``-r``/``--requirement`` form + whose target is itself a candidate lock path written as a normalized + relative POSIX path. Absolute paths, ``.`` or ``..`` components, double + slashes, URLs, option-like targets, shell/Windows path separators, + fragments, queries, extra inline options or hashes, and includes of + non-lock files are rejected before a base-owned file can enter the + trusted build context. + The downstream installer still proves that the candidate is an independently + complete hash closure; this predicate grants syntax eligibility only. + """ + fields = line.split() + if len(fields) != 2 or fields[0] not in {"-r", "--requirement"}: + return False + target = fields[1] + if ( + target.startswith(("-", "~")) + or "\\" in target + or ":" in target + or "?" in target + or "#" in target + ): + return False + include_path = pathlib.PurePosixPath(target) + return ( + bool(include_path.parts) + and target == include_path.as_posix() + and not include_path.is_absolute() + and "." not in include_path.parts + and ".." not in include_path.parts + and _is_candidate_lock_path(include_path) + ) + + def _requirement_lines(content: bytes) -> list[str]: """Return logical requirement lines, joining backslash line-continuations. @@ -107,23 +158,26 @@ def _requirement_lines(content: bytes) -> list[str]: def _is_hash_pinned(content: bytes) -> bool: - """Return whether content carries hash pins and is safe to preflight. - - Discovery is content-based rather than name-based so hash-pinned locks in any - location (a service subdirectory, ``requirements-dev.txt``, - ``requirements-test.txt``) can be considered for offline coverage, while an - unpinned or PR-mutable requirements file is still excluded from the networked - build context. Hash syntax cannot prove that a file includes every transitive - dependency, so the trusted image installer separately preflights every - candidate as an independent ``--require-hashes`` closure. An empty file - carries no installable dependency and is not materialized. + """Return whether content carries only trusted pins or bounded includes. + + Discovery is content-based rather than name-based so exact hash-pinned locks + in service subdirectories and role-specific requirements files can be + considered for offline coverage. Candidate syntax is deliberately stricter + than a substring search: each package line must be an exact ``==`` pin with + one or more complete SHA-256 hashes, or a bounded relative requirements + include. A global ``--require-hashes`` directive is not trust evidence by + itself. The downstream installer separately preflights every candidate as an + independent ``pip --require-hashes`` closure, so syntax eligibility never + substitutes for dependency-closure proof. """ lines = _requirement_lines(content) - if not lines: + requirement_lines = [line for line in lines if line != "--require-hashes"] + if not requirement_lines: return False - return any(line == "--require-hashes" for line in lines) or all( - "--hash=" in line or line.startswith(("-r ", "--requirement ")) - for line in lines + return all( + _is_fully_hash_pinned_requirement(line) + or _is_bounded_requirement_include(line) + for line in requirement_lines ) diff --git a/tests/test_materialize_base_python_requirements.py b/tests/test_materialize_base_python_requirements.py index a9e974d12..2230d8dab 100644 --- a/tests/test_materialize_base_python_requirements.py +++ b/tests/test_materialize_base_python_requirements.py @@ -163,9 +163,16 @@ def test_lock_name_candidates_are_pip_requirements_files() -> None: def test_hash_pin_detection_includes_pinned_and_excludes_unpinned_or_empty() -> None: """Only fully hash-pinned, non-empty lock content is materialized.""" assert not materializer._is_hash_pinned(b"# comment only\n\n") - assert materializer._is_hash_pinned(b"--require-hashes\ndemo==1\n") + assert not materializer._is_hash_pinned(b"--require-hashes\ndemo==1\n") assert materializer._is_hash_pinned(b"demo==1 --hash=sha256:" + b"a" * 64 + b"\n") - assert materializer._is_hash_pinned(b"-r other-hashes.txt\n") + assert materializer._is_hash_pinned(b"-r requirements-other.txt\n") + assert not materializer._is_hash_pinned(b"-r other-hashes.txt\n") + assert not materializer._is_hash_pinned(b"-r ./requirements-other.txt\n") + assert not materializer._is_hash_pinned(b"-r ../escape.txt\n") + assert not materializer._is_bounded_requirement_include("-r .") + assert not materializer._is_bounded_requirement_include("-r -evil.txt") + assert not materializer._is_bounded_requirement_include("-r C:foo.txt") + assert not materializer._is_bounded_requirement_include("-r foo?bar.txt") assert not materializer._is_hash_pinned(b"untrusted==1\n") # uv export / pip-compile multi-line continuation format (spec, then --hash= lines). assert materializer._is_hash_pinned( From 77b380aad4a8ea89f79664cf7ae66a13ccb14ba6 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 14 Aug 2026 23:01:11 +0900 Subject: [PATCH 06/12] chore(strix): drop overlapping materializer delta --- AGENTS.md | 2 +- CHANGELOG.md | 1 - docs/doctoring/strix-hashed-lock-no-deps.md | 7 -- .../materialize_base_python_requirements.py | 82 ++++--------------- ...st_materialize_base_python_requirements.py | 11 +-- 5 files changed, 17 insertions(+), 86 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 680c446c6..5d3dea049 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -3,4 +3,4 @@ > **Agents: read the master context FIRST.** Before any work, read [`docs/CWL-MASTER-CONTEXT.md`](docs/CWL-MASTER-CONTEXT.md) (mission · naruon-as-platform + inter-component UML · cross-cutting disciplines · conventions · roadmap · current state), the live **GitHub Project #1** (work/roadmap source of truth), the full spec **ContextualWisdomLab/naruon#974**, and operate the Project per [`docs/agent-github-project-protocol.md`](docs/agent-github-project-protocol.md). The repo/Project — not any private agent memory — is the source of truth. > -> Required Strix (`pull_request_target`) installs the hashed lock with `--require-hashes --no-deps`. Hashed-lock pip-audit uses `--disable-pip` via `scripts/ci/pip_audit_requirements.py` only when every package line carries a `--hash=` pin. A lone `--require-hashes` directive or a mixed hashed-plus-unhashed file is not a complete lock. Materialize accepts only exact SHA-256 pins or a bounded relative `-r` include (no `.`/`..`). Do not drop `cryptography==50.0.0` to satisfy strix-agent's stale `<49` bound. See [`docs/doctoring/strix-hashed-lock-no-deps.md`](docs/doctoring/strix-hashed-lock-no-deps.md) and [`ARCHITECTURE.md`](ARCHITECTURE.md). +> Required Strix (`pull_request_target`) installs the hashed lock with `--require-hashes --no-deps`. Hashed-lock pip-audit uses `--disable-pip` via `scripts/ci/pip_audit_requirements.py` only when every package line carries a `--hash=` pin. A lone `--require-hashes` directive or a mixed hashed-plus-unhashed file is not a complete lock. Do not drop `cryptography==50.0.0` to satisfy strix-agent's stale `<49` bound. See [`docs/doctoring/strix-hashed-lock-no-deps.md`](docs/doctoring/strix-hashed-lock-no-deps.md) and [`ARCHITECTURE.md`](ARCHITECTURE.md). diff --git a/CHANGELOG.md b/CHANGELOG.md index 4b4ede02c..41685d8fe 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,7 +13,6 @@ Semantic Versioning where the repository publishes a release. ### Fixed -- Materialized base Python locks only when every package line is an exact SHA-256 pin or a bounded relative `-r`/`--requirement` include. A lone `--require-hashes` directive, a dotted include such as `./lock.txt`, or `-r other-hashes.txt` no longer enters the trusted build context. - Required every package line to carry a `--hash=` pin before pip-audit treats a requirements file as a complete hashed lock. A lone `--require-hashes` directive or a mixed hashed-plus-unhashed file no longer receives `--disable-pip`, which would otherwise audit an incomplete set as clean. - Landed `--require-hashes --no-deps` on the required Strix installer and taught pip-audit to audit hashed complete locks with `--disable-pip`, so a later strix-agent 1.5.3 + cryptography 50.0.0 lock can install under `pull_request_target` and pip-audit no longer labels pip `ResolutionImpossible` as a known vulnerability (ContextualWisdomLab/.github#961, #952). A `*-hashes.txt` name without hash evidence no longer receives `--disable-pip`, and discovery skips virtualenv trees. - Bounded the Strix quality self-test's deterministic timeout fixtures to 3-second process and 5-second fake-sleep budgets so exact-head policy evidence completes inside the existing job limit without changing production Strix scanner timeouts, providers, credentials, or review semantics. diff --git a/docs/doctoring/strix-hashed-lock-no-deps.md b/docs/doctoring/strix-hashed-lock-no-deps.md index c01efed6b..431ce272b 100644 --- a/docs/doctoring/strix-hashed-lock-no-deps.md +++ b/docs/doctoring/strix-hashed-lock-no-deps.md @@ -56,16 +56,9 @@ and the `--no-deps` installer line on `strix.yml`. A `*-hashes.txt` name or a lone `--require-hashes` directive without `--hash=` is not treated as a complete lock. A mixed file with one hashed line beside unhashed packages also stays on the resolver path. Discovery skips `.venv` trees. -Materialize accepts a requirements include only as a two-token -``-r``/``--requirement`` form whose target is a normalized relative POSIX -lock path with no ``.`` or ``..`` components, so a dotted include cannot -enter the trusted build context (CWE-22; MITRE, 2026). ## References (APA 7th) -MITRE. (2026). *CWE-22: Improper limitation of a pathname to a restricted -directory ('Path Traversal')*. https://cwe.mitre.org/data/definitions/22.html - GitHub. (2026). *Cryptography vulnerable to buffer overflow if non-contiguous buffers were passed to APIs (CVE-2026-39892, GHSA-p423-j2cm-9vmq)*. GitHub Advisory Database. diff --git a/scripts/ci/materialize_base_python_requirements.py b/scripts/ci/materialize_base_python_requirements.py index 9848c3ff6..98cdad459 100755 --- a/scripts/ci/materialize_base_python_requirements.py +++ b/scripts/ci/materialize_base_python_requirements.py @@ -87,57 +87,6 @@ def _is_candidate_lock_name(name: str) -> bool: ) -def _is_candidate_lock_path(path: pathlib.PurePosixPath) -> bool: - """Return whether one safe tracked path can name a pip requirements lock. - - In addition to conventional ``requirements*.txt`` names, repositories often - keep concrete environment closures as direct children such as - ``requirements/ci.txt`` or ``service/requirements/package.txt``. Only direct - ``.txt`` children of a directory named ``requirements`` gain this path-based - eligibility; content must still pass the independent complete hash-pin - validation before it reaches the trusted image build context. - """ - return _is_candidate_lock_name(path.name) or ( - path.suffix == ".txt" and path.parent.name == "requirements" - ) - - -def _is_bounded_requirement_include(line: str) -> bool: - """Return whether one requirements include names a bounded relative file. - - Includes are accepted only as a two-token ``-r``/``--requirement`` form - whose target is itself a candidate lock path written as a normalized - relative POSIX path. Absolute paths, ``.`` or ``..`` components, double - slashes, URLs, option-like targets, shell/Windows path separators, - fragments, queries, extra inline options or hashes, and includes of - non-lock files are rejected before a base-owned file can enter the - trusted build context. - The downstream installer still proves that the candidate is an independently - complete hash closure; this predicate grants syntax eligibility only. - """ - fields = line.split() - if len(fields) != 2 or fields[0] not in {"-r", "--requirement"}: - return False - target = fields[1] - if ( - target.startswith(("-", "~")) - or "\\" in target - or ":" in target - or "?" in target - or "#" in target - ): - return False - include_path = pathlib.PurePosixPath(target) - return ( - bool(include_path.parts) - and target == include_path.as_posix() - and not include_path.is_absolute() - and "." not in include_path.parts - and ".." not in include_path.parts - and _is_candidate_lock_path(include_path) - ) - - def _requirement_lines(content: bytes) -> list[str]: """Return logical requirement lines, joining backslash line-continuations. @@ -158,26 +107,23 @@ def _requirement_lines(content: bytes) -> list[str]: def _is_hash_pinned(content: bytes) -> bool: - """Return whether content carries only trusted pins or bounded includes. - - Discovery is content-based rather than name-based so exact hash-pinned locks - in service subdirectories and role-specific requirements files can be - considered for offline coverage. Candidate syntax is deliberately stricter - than a substring search: each package line must be an exact ``==`` pin with - one or more complete SHA-256 hashes, or a bounded relative requirements - include. A global ``--require-hashes`` directive is not trust evidence by - itself. The downstream installer separately preflights every candidate as an - independent ``pip --require-hashes`` closure, so syntax eligibility never - substitutes for dependency-closure proof. + """Return whether content carries hash pins and is safe to preflight. + + Discovery is content-based rather than name-based so hash-pinned locks in any + location (a service subdirectory, ``requirements-dev.txt``, + ``requirements-test.txt``) can be considered for offline coverage, while an + unpinned or PR-mutable requirements file is still excluded from the networked + build context. Hash syntax cannot prove that a file includes every transitive + dependency, so the trusted image installer separately preflights every + candidate as an independent ``--require-hashes`` closure. An empty file + carries no installable dependency and is not materialized. """ lines = _requirement_lines(content) - requirement_lines = [line for line in lines if line != "--require-hashes"] - if not requirement_lines: + if not lines: return False - return all( - _is_fully_hash_pinned_requirement(line) - or _is_bounded_requirement_include(line) - for line in requirement_lines + return any(line == "--require-hashes" for line in lines) or all( + "--hash=" in line or line.startswith(("-r ", "--requirement ")) + for line in lines ) diff --git a/tests/test_materialize_base_python_requirements.py b/tests/test_materialize_base_python_requirements.py index 2230d8dab..a9e974d12 100644 --- a/tests/test_materialize_base_python_requirements.py +++ b/tests/test_materialize_base_python_requirements.py @@ -163,16 +163,9 @@ def test_lock_name_candidates_are_pip_requirements_files() -> None: def test_hash_pin_detection_includes_pinned_and_excludes_unpinned_or_empty() -> None: """Only fully hash-pinned, non-empty lock content is materialized.""" assert not materializer._is_hash_pinned(b"# comment only\n\n") - assert not materializer._is_hash_pinned(b"--require-hashes\ndemo==1\n") + assert materializer._is_hash_pinned(b"--require-hashes\ndemo==1\n") assert materializer._is_hash_pinned(b"demo==1 --hash=sha256:" + b"a" * 64 + b"\n") - assert materializer._is_hash_pinned(b"-r requirements-other.txt\n") - assert not materializer._is_hash_pinned(b"-r other-hashes.txt\n") - assert not materializer._is_hash_pinned(b"-r ./requirements-other.txt\n") - assert not materializer._is_hash_pinned(b"-r ../escape.txt\n") - assert not materializer._is_bounded_requirement_include("-r .") - assert not materializer._is_bounded_requirement_include("-r -evil.txt") - assert not materializer._is_bounded_requirement_include("-r C:foo.txt") - assert not materializer._is_bounded_requirement_include("-r foo?bar.txt") + assert materializer._is_hash_pinned(b"-r other-hashes.txt\n") assert not materializer._is_hash_pinned(b"untrusted==1\n") # uv export / pip-compile multi-line continuation format (spec, then --hash= lines). assert materializer._is_hash_pinned( From 800902559b3cf5a0efcd09200020c15d62756997 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 16 Aug 2026 18:28:19 +0900 Subject: [PATCH 07/12] test(pip-audit): specify fail-closed lock discovery --- ...test_pip_audit_requirements_fail_closed.py | 150 ++++++++++++++++++ 1 file changed, 150 insertions(+) create mode 100644 tests/test_pip_audit_requirements_fail_closed.py diff --git a/tests/test_pip_audit_requirements_fail_closed.py b/tests/test_pip_audit_requirements_fail_closed.py new file mode 100644 index 000000000..8e0ae5e04 --- /dev/null +++ b/tests/test_pip_audit_requirements_fail_closed.py @@ -0,0 +1,150 @@ +"""Fail-closed regressions for Python requirement audit discovery. + +These tests protect the pull-request security boundary: a repository-controlled +filename, sibling file, encoding error, or pip option must not suppress the +install set that ``pip-audit`` evaluates or forge GitHub Actions log commands. +""" + +from __future__ import annotations + +import importlib.util +import pathlib +from typing import Any + +import pytest + + +MODULE_PATH = ( + pathlib.Path(__file__).resolve().parents[1] + / "scripts" + / "ci" + / "pip_audit_requirements.py" +) + + +def load_module() -> Any: + """Load the production helper from its script path.""" + + spec = importlib.util.spec_from_file_location( + "pip_audit_requirements_fail_closed", MODULE_PATH + ) + assert spec is not None + assert spec.loader is not None + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + return module + + +def _write_valid_lock(path: pathlib.Path) -> None: + """Write one exact package pin with a complete SHA-256 hash.""" + + path.write_text( + "demo==1.0.0 --hash=sha256:" + ("a" * 64) + "\n", + encoding="utf-8", + ) + + +def test_invalid_hash_sibling_cannot_suppress_source_audit( + tmp_path: pathlib.Path, +) -> None: + """An empty or directive-only sibling is not an audited install set.""" + + module = load_module() + source = tmp_path / "requirements-demo.txt" + source.write_text("demo==1.0.0\n", encoding="utf-8") + sibling = tmp_path / "requirements-demo-hashes.txt" + sibling.write_text("--require-hashes\n", encoding="utf-8") + + assert module.hashed_sibling(source) is None + assert module.audit_command(source) == [ + "pip-audit", + "--strict", + "--desc=on", + "-r", + str(source), + ] + + +def test_pip_option_with_hash_is_not_a_complete_lock(tmp_path: pathlib.Path) -> None: + """A hash-shaped pip option cannot earn ``--disable-pip`` treatment.""" + + module = load_module() + requirements = tmp_path / "requirements-option.txt" + requirements.write_text( + "--index-url https://example.invalid/simple --hash=sha256:" + + ("b" * 64) + + "\n", + encoding="utf-8", + ) + + assert module.is_hashed_lock(requirements) is False + assert "--disable-pip" not in (module.audit_command(requirements) or []) + + +def test_invalid_utf8_fails_before_any_audit_command( + tmp_path: pathlib.Path, capsys: pytest.CaptureFixture[str] +) -> None: + """Undecodable requirement bytes cannot disappear from the audited set.""" + + module = load_module() + requirements = tmp_path / "requirements-invalid.txt" + requirements.write_bytes(b"demo==1.0.0\xff\n") + calls: list[list[str]] = [] + + result = module.run_audits( + tmp_path, + runner=lambda command: calls.append(list(command)) or 0, + ) + + assert result == 2 + assert calls == [] + error = capsys.readouterr().err + assert "invalid UTF-8 requirements input" in error + assert "0xff" not in error + + +def test_requirement_symlink_is_rejected_instead_of_followed( + tmp_path: pathlib.Path, +) -> None: + """A tracked-looking symlink cannot redirect audit input outside the tree.""" + + module = load_module() + outside = tmp_path / "outside.txt" + _write_valid_lock(outside) + link = tmp_path / "requirements-link.txt" + link.symlink_to(outside) + error_type = getattr(module, "AuditConfigurationError", RuntimeError) + + with pytest.raises(error_type, match="regular non-symlink file"): + module.discover_requirement_files(tmp_path) + + +def test_valid_regular_hash_sibling_still_suppresses_compile_input( + tmp_path: pathlib.Path, +) -> None: + """The fail-closed checks preserve the intended complete-lock fast path.""" + + module = load_module() + source = tmp_path / "requirements-demo.txt" + source.write_text("demo==1.0.0\n", encoding="utf-8") + sibling = tmp_path / "requirements-demo-hashes.txt" + _write_valid_lock(sibling) + + assert module.hashed_sibling(source) == sibling + assert module.audit_command(source) is None + assert "--disable-pip" in (module.audit_command(sibling) or []) + + +def test_control_characters_in_paths_are_escaped_in_workflow_logs( + tmp_path: pathlib.Path, capsys: pytest.CaptureFixture[str] +) -> None: + """A filename newline cannot inject a GitHub Actions workflow command.""" + + module = load_module() + hostile = tmp_path / "requirements-bad\n::error::forged.txt" + _write_valid_lock(hostile) + + assert module.run_audits(tmp_path, runner=lambda _command: 0) == 0 + output = capsys.readouterr().out + assert "\\n::error::forged.txt" in output + assert "\n::error::forged.txt" not in output From 39102fe985ceb83a0c8d1e1a566e6dca03c576ab Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 16 Aug 2026 18:30:03 +0900 Subject: [PATCH 08/12] fix(pip-audit): fail closed on ambiguous lock inputs --- scripts/ci/pip_audit_requirements.py | 179 ++++++++++++++++++++------- 1 file changed, 137 insertions(+), 42 deletions(-) diff --git a/scripts/ci/pip_audit_requirements.py b/scripts/ci/pip_audit_requirements.py index 784611e93..4b86aa602 100644 --- a/scripts/ci/pip_audit_requirements.py +++ b/scripts/ci/pip_audit_requirements.py @@ -4,7 +4,7 @@ file. That lies about a complete hashed lock compiled with a documented override: pip reports ``ResolutionImpossible`` (strix-agent ``cryptography<49`` versus the CVE-fixed ``cryptography==50.0.0`` pin) and the workflow labels the -failure ``known-vulnerable``. This helper audits hashed locks with +failure ``known-vulnerable``. This helper audits validated hashed locks with ``--disable-pip`` so the pinned set is checked against the advisory database without re-applying stale metadata bounds. """ @@ -12,7 +12,10 @@ from __future__ import annotations import argparse +import json import pathlib +import re +import stat import subprocess import sys from collections.abc import Callable, Sequence @@ -22,13 +25,37 @@ SKIP_DISCOVERY_PARTS = frozenset( {".git", ".venv", "venv", "node_modules", "__pycache__"} ) +_HASH_FIELD = re.compile(r"--hash=sha256:[0-9a-f]{64}") -def _requirement_lines(path: pathlib.Path) -> list[str]: - """Return logical requirement lines, joining backslash continuations.""" +class AuditConfigurationError(RuntimeError): + """Signal that repository-controlled audit input is unsafe or ambiguous.""" + + +def _require_regular_file(path: pathlib.Path) -> None: + """Require one existing regular file without following a symbolic link.""" + + try: + metadata = path.lstat() + except OSError: + raise AuditConfigurationError( + "requirements input could not be inspected safely" + ) from None + if not stat.S_ISREG(metadata.st_mode): + raise AuditConfigurationError( + "requirements input must be a regular non-symlink file" + ) + - text = path.read_text(encoding="utf-8", errors="ignore").replace("\r\n", "\n") - joined = text.replace("\\\n", " ") +def _requirement_lines(path: pathlib.Path) -> list[str]: + """Return strict UTF-8 logical lines, joining backslash continuations.""" + + _require_regular_file(path) + try: + text = path.read_text(encoding="utf-8", errors="strict") + except UnicodeError: + raise AuditConfigurationError("invalid UTF-8 requirements input") from None + joined = text.replace("\r\n", "\n").replace("\\\n", " ") lines: list[str] = [] for raw_line in joined.splitlines(): line = raw_line.strip() @@ -38,6 +65,19 @@ def _requirement_lines(path: pathlib.Path) -> list[str]: return lines +def _is_exact_hashed_requirement(line: str) -> bool: + """Return whether one line is an exact ``==`` pin plus SHA-256 hashes.""" + + fields = re.split(r"\s+(?=--hash=)", line) + requirement = fields[0].strip() + hash_fields = fields[1:] + if not requirement or requirement.startswith("-") or "==" not in requirement: + return False + return bool(hash_fields) and all( + _HASH_FIELD.fullmatch(field) is not None for field in hash_fields + ) + + def is_override_file(path: pathlib.Path) -> bool: """Return whether *path* is a compile-time override input, not an install set.""" @@ -45,38 +85,52 @@ def is_override_file(path: pathlib.Path) -> bool: def is_hashed_lock(path: pathlib.Path) -> bool: - """Return whether *path* actually contains pip hash-checking evidence. + """Return whether *path* is a complete, syntactically bounded hashed lock. - A ``*-hashes.txt`` name, a lone ``--require-hashes`` directive, or a - mixed file with one hashed line beside unhashed packages is not - enough: those would otherwise be audited with ``--disable-pip`` and - report a clean incomplete set. + A ``*-hashes.txt`` name, a lone ``--require-hashes`` directive, a pip + option carrying hash-shaped text, or a mixed hashed-plus-unhashed file is + not sufficient. Every substantive package line must be an exact ``==`` pin + carrying one or more complete SHA-256 hashes before ``--disable-pip`` is + allowed to bypass pip's resolver. """ lines = _requirement_lines(path) package_lines = [line for line in lines if line != "--require-hashes"] - return bool(package_lines) and all("--hash=" in line for line in package_lines) + return bool(package_lines) and all( + _is_exact_hashed_requirement(line) for line in package_lines + ) + + +def _is_regular_non_symlink(path: pathlib.Path) -> bool: + """Return whether an existing path is a regular file without following it.""" + + try: + return stat.S_ISREG(path.lstat().st_mode) + except OSError: + return False def hashed_sibling(path: pathlib.Path) -> pathlib.Path | None: - """Return the compiled ``*-hashes.txt`` sibling when *path* is its input.""" + """Return a valid compiled ``*-hashes.txt`` sibling for one source input.""" if is_hashed_lock(path) or is_override_file(path): return None if not path.name.endswith(".txt"): return None sibling = path.with_name(f"{path.name[:-4]}-hashes.txt") - if sibling.is_file(): - return sibling - return None + if not _is_regular_non_symlink(sibling): + return None + if not is_hashed_lock(sibling): + return None + return sibling def audit_command(path: pathlib.Path) -> list[str] | None: """Return the pip-audit argv for *path*, or ``None`` to skip the file. - Hashed locks are audited with ``--disable-pip`` so pip cannot re-apply a - stale ``Requires-Dist`` upper bound. Compile-time override files and - unhashed inputs that already have a hashed sibling are skipped because the + Validated hashed locks are audited with ``--disable-pip`` so pip cannot + re-apply a stale ``Requires-Dist`` upper bound. Compile-time override files + and unhashed inputs with a validated hashed sibling are skipped because the lock is the install set a buyer actually receives. """ @@ -97,14 +151,30 @@ def audit_command(path: pathlib.Path) -> list[str] | None: def discover_requirement_files(root: pathlib.Path) -> list[pathlib.Path]: - """Return ``requirements*.txt`` files under *root*, skipping VCS and venvs.""" + """Return regular requirement files while rejecting matching symlinks.""" found: list[pathlib.Path] = [] for path in sorted(root.rglob("requirements*.txt")): - if SKIP_DISCOVERY_PARTS.intersection(path.parts): + try: + relative_parts = path.relative_to(root).parts + except ValueError: + raise AuditConfigurationError( + "requirements input escaped the audit root" + ) from None + if SKIP_DISCOVERY_PARTS.intersection(relative_parts): continue - if not path.is_file(): + try: + metadata = path.lstat() + except OSError: + raise AuditConfigurationError( + "requirements input could not be inspected safely" + ) from None + if stat.S_ISDIR(metadata.st_mode): continue + if not stat.S_ISREG(metadata.st_mode): + raise AuditConfigurationError( + "requirements input must be a regular non-symlink file" + ) found.append(path) return found @@ -113,41 +183,66 @@ def should_audit_project_manifest(root: pathlib.Path) -> bool: """Return whether a PEP 621 or pylock manifest exists within two levels.""" for current in (root, *list(root.glob("*"))): - if not current.is_dir(): + try: + current_metadata = current.lstat() + except OSError: continue - if (current / "pyproject.toml").is_file(): - return True - if any(current.glob("pylock.*.toml")): + if not stat.S_ISDIR(current_metadata.st_mode): + continue + pyproject = current / "pyproject.toml" + if _is_regular_non_symlink(pyproject): return True + for candidate in current.glob("pylock.*.toml"): + if _is_regular_non_symlink(candidate): + return True return False +def _display_path(root: pathlib.Path, path: pathlib.Path) -> str: + """Return an ASCII JSON string safe for GitHub Actions log output.""" + + try: + relative = path.relative_to(root).as_posix() + except ValueError: + relative = path.name + return json.dumps(relative, ensure_ascii=True) + + def run_audits( root: pathlib.Path, *, runner: Runner | None = None, ) -> int: - """Audit discovered requirement files and an optional project manifest.""" + """Audit discovered inputs, returning 2 for unsafe repository metadata.""" execute: Runner = runner or ( lambda command: subprocess.run(command, check=False).returncode ) status = 0 - for path in discover_requirement_files(root): - command = audit_command(path) - if command is None: - print(f"skip {path} (hashed lock is the audited install set)", flush=True) - continue - print(f"::group::pip-audit {' '.join(command[1:])}", flush=True) - if execute(command) != 0: - status = 1 - print("::endgroup::", flush=True) - if should_audit_project_manifest(root): - command = ["pip-audit", "--strict", "--desc=on", "."] - print("::group::pip-audit . (project manifest)", flush=True) - if execute(command) != 0: - status = 1 - print("::endgroup::", flush=True) + try: + requirement_files = discover_requirement_files(root) + for path in requirement_files: + command = audit_command(path) + display_path = _display_path(root, path) + if command is None: + print( + f"skip {display_path} (validated hashed lock is audited)", + flush=True, + ) + continue + print(f"::group::pip-audit {display_path}", flush=True) + if execute(command) != 0: + status = 1 + print("::endgroup::", flush=True) + if should_audit_project_manifest(root): + command = ["pip-audit", "--strict", "--desc=on", "."] + print("::group::pip-audit . (project manifest)", flush=True) + if execute(command) != 0: + status = 1 + print("::endgroup::", flush=True) + except AuditConfigurationError as error: + print(f"::error::{error}", file=sys.stderr) + return 2 if status != 0: print( "::error::pip-audit reported known-vulnerable Python dependencies.", @@ -172,7 +267,7 @@ def main(argv: list[str] | None = None) -> int: args = parser.parse_args(argv) root = args.root.resolve() if not root.is_dir(): - print(f"::error::audit root is not a directory: {root}", file=sys.stderr) + print("::error::audit root is not a directory", file=sys.stderr) return 2 return run_audits(root) From dd58efb50a9e05481438bba33c37e1d0ef9a19ba Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 16 Aug 2026 18:31:34 +0900 Subject: [PATCH 09/12] chore(strix): restore bounded audit scope --- .gitignore | 1 - AGENTS.md | 2 - ARCHITECTURE.md | 53 ------------------- CLAUDE.md | 4 -- ...st_materialize_base_python_requirements.py | 16 ------ 5 files changed, 76 deletions(-) delete mode 100644 ARCHITECTURE.md diff --git a/.gitignore b/.gitignore index d123b43e3..b98cb1f1d 100644 --- a/.gitignore +++ b/.gitignore @@ -3,4 +3,3 @@ __pycache__/ .coverage .pytest_cache/ .codegraph/ -.venv/ diff --git a/AGENTS.md b/AGENTS.md index 5d3dea049..688b33035 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -2,5 +2,3 @@ > **Agents: read the master context FIRST.** Before any work, read [`docs/CWL-MASTER-CONTEXT.md`](docs/CWL-MASTER-CONTEXT.md) (mission · naruon-as-platform + inter-component UML · cross-cutting disciplines · conventions · roadmap · current state), the live **GitHub Project #1** (work/roadmap source of truth), the full spec **ContextualWisdomLab/naruon#974**, and operate the Project per [`docs/agent-github-project-protocol.md`](docs/agent-github-project-protocol.md). The repo/Project — not any private agent memory — is the source of truth. -> -> Required Strix (`pull_request_target`) installs the hashed lock with `--require-hashes --no-deps`. Hashed-lock pip-audit uses `--disable-pip` via `scripts/ci/pip_audit_requirements.py` only when every package line carries a `--hash=` pin. A lone `--require-hashes` directive or a mixed hashed-plus-unhashed file is not a complete lock. Do not drop `cryptography==50.0.0` to satisfy strix-agent's stale `<49` bound. See [`docs/doctoring/strix-hashed-lock-no-deps.md`](docs/doctoring/strix-hashed-lock-no-deps.md) and [`ARCHITECTURE.md`](ARCHITECTURE.md). diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md deleted file mode 100644 index cb0dd8115..000000000 --- a/ARCHITECTURE.md +++ /dev/null @@ -1,53 +0,0 @@ -# Architecture — org-central `.github` control plane - -This repository is the ContextualWisdomLab organization profile, the central -PR-governance / required-workflow hub, and the Cloudflare DNS/Pages -declaration. The live work tracker is GitHub Project #1. - -## Trust boundary: required Strix vs head-tree locks - -```mermaid -flowchart LR - PR["PR head tree
requirements-strix-ci-hashes.txt"] - BASE["Protected main
.github/workflows/strix.yml"] - PIP["pip install --require-hashes --no-deps"] - AUDIT["python-security.yml
pip_audit_requirements.py --disable-pip"] - GATE["strix_quick_gate.sh
artifact fail-closed"] - - PR -->|"pull_request_target checks out head"| PIP - BASE -->|"workflow YAML comes from base"| PIP - PIP --> GATE - PR -->|"pull_request uses the PR workflow"| AUDIT -``` - -A `pull_request_target` job cannot honor an installer flag that exists only on -the pull request. `--no-deps` must therefore merge to `main` before a lock -that needs it (strix-agent 1.5.3 + cryptography 50.0.0) can install. pip-audit -is `pull_request`, so hashed-lock `--disable-pip` takes effect on the same -head. Decision record: -[`docs/doctoring/strix-hashed-lock-no-deps.md`](docs/doctoring/strix-hashed-lock-no-deps.md). - -## Trusted-uv installer platform gate - -```mermaid -flowchart TD - Host["Developer or CI host"] - Gate{"sys.platform is linux and machine is x86_64?"} - Install["Verify pinned archive, version, and cache"] - Reject["Fail closed: unsupported runner"] - - Host --> Gate - Gate -->|"no"| Reject - Gate -->|"yes"| Install -``` - -Installer verification tests simulate Linux x86_64 and clear the process cache -(CWE-670) so they measure verification rather than the host-architecture gate. - -## Other control-plane surfaces - -- OpenCode / Noema are reviewers (`edit: deny`); GitHub Actions performs - mechanical updates and merges. -- Review JSON embedded in Markdown is escaped by - `scripts/ci/opencode_review_normalize_output.py`. -- Cloudflare reconcile is dry-run by default; PRs never see the API token. diff --git a/CLAUDE.md b/CLAUDE.md index 31fc50403..1c7bdb2f6 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -87,10 +87,6 @@ interrogate ## Hash-pinned requirements discipline CI installs Python tools only with `pip install --require-hashes` from the `*-hashes.txt` files. -The required Strix installer adds `--no-deps` so pip cannot re-apply a stale -`Requires-Dist` bound after a compile-time cryptography override. `python-security.yml` -audits those hashed locks through `scripts/ci/pip_audit_requirements.py` (`pip-audit ---disable-pip`). See `docs/doctoring/strix-hashed-lock-no-deps.md`. Never hand-edit a `-hashes.txt` file: edit the top-level `requirements--ci.txt` input, then regenerate with the exact `uv pip compile` command recorded in the hashes file's header comment, e.g.: diff --git a/tests/test_materialize_base_python_requirements.py b/tests/test_materialize_base_python_requirements.py index a9e974d12..8a383f0c2 100644 --- a/tests/test_materialize_base_python_requirements.py +++ b/tests/test_materialize_base_python_requirements.py @@ -30,19 +30,6 @@ def _created_tool_directory(path: Path) -> str: return str(path) -def _simulate_linux_x86_64_runner(monkeypatch: pytest.MonkeyPatch) -> None: - """Let installer verification tests run on a non-Linux developer host. - - Production still fail-closes unless ``sys.platform`` is Linux and - ``platform.machine()`` is ``x86_64``. These unit tests pin both values so - they measure version verification, caching, and cleanup instead of the - host architecture gate already covered by the portability contract. - """ - monkeypatch.setattr(materializer.sys, "platform", "linux") - monkeypatch.setattr(materializer.platform, "machine", lambda: "x86_64") - materializer._install_trusted_uv.cache_clear() - - def test_materializes_only_regular_hash_locks_from_exact_base(tmp_path: Path) -> None: """A PR-modified lock cannot enter the networked coverage image build context.""" repo = tmp_path / "repo" @@ -657,7 +644,6 @@ def test_install_trusted_uv_verifies_version_and_caches_path( tmp_path: Path, monkeypatch: pytest.MonkeyPatch ) -> None: """The installer writes one executable, verifies its version, and caches it.""" - _simulate_linux_x86_64_runner(monkeypatch) tool_dir = tmp_path / "uv" monkeypatch.setattr( materializer.tempfile, @@ -704,7 +690,6 @@ def test_install_trusted_uv_rejects_version_process_failures( failure: OSError | subprocess.TimeoutExpired, ) -> None: """A missing or hung downloaded executable is removed and rejected.""" - _simulate_linux_x86_64_runner(monkeypatch) tool_dir = tmp_path / "uv" monkeypatch.setattr( materializer.tempfile, @@ -736,7 +721,6 @@ def test_install_trusted_uv_rejects_wrong_version_or_exit_status( completed: subprocess.CompletedProcess[bytes], ) -> None: """Unexpected version output or a nonzero status cannot satisfy the pin.""" - _simulate_linux_x86_64_runner(monkeypatch) tool_dir = tmp_path / f"uv-{completed.returncode}-{len(completed.stdout)}" monkeypatch.setattr( materializer.tempfile, From a2f3a7efdc122a51d182f18bcdfd023542f306b6 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sun, 16 Aug 2026 16:01:14 +0000 Subject: [PATCH 10/12] test(pip-audit): cover symlink sibling and filename-only locks Keep the hashed-lock --no-deps / --disable-pip contract fail-closed: a symlink *-hashes.txt sibling cannot suppress the compile input, a filename-only wheel path cannot earn resolver bypass, and unstatable manifest children are visited in deterministic glob order so coverage cannot miss the OSError continue path. Co-authored-by: Seongho Bae --- CHANGELOG.md | 2 +- docs/doctoring/strix-hashed-lock-no-deps.md | 7 +++- tests/test_pip_audit_requirements.py | 23 ++++++++++- ...test_pip_audit_requirements_fail_closed.py | 40 +++++++++++++++++++ 4 files changed, 68 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4b6791767..f49117ae0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -26,7 +26,7 @@ Semantic Versioning where the repository publishes a release. ### Fixed -- Required every package line to carry a `--hash=` pin before pip-audit treats a requirements file as a complete hashed lock. A lone `--require-hashes` directive or a mixed hashed-plus-unhashed file no longer receives `--disable-pip`, which would otherwise audit an incomplete set as clean. +- Required every package line to carry a `--hash=` pin before pip-audit treats a requirements file as a complete hashed lock. A lone `--require-hashes` directive, a mixed hashed-plus-unhashed file, a filename-only wheel path, or a hash-shaped pip option no longer receives `--disable-pip`. Invalid UTF-8 and symlink/special-file inputs fail before any audit command, and workflow log paths are escaped. - Landed `--require-hashes --no-deps` on the required Strix installer and taught pip-audit to audit hashed complete locks with `--disable-pip`, so a later strix-agent 1.5.3 + cryptography 50.0.0 lock can install under `pull_request_target` and pip-audit no longer labels pip `ResolutionImpossible` as a known vulnerability (ContextualWisdomLab/.github#961, #952). A `*-hashes.txt` name without hash evidence no longer receives `--disable-pip`, and discovery skips virtualenv trees. - Materialized base Python locks only when every package line is an exact SHA-256 pin or a bounded relative `-r`/`--requirement` include. A lone `--require-hashes` directive, a dotted include such as `./lock.txt`, or `-r other-hashes.txt` no longer enters the trusted build context. - Refused a conflict-scope repository root whose immediate parent is a symbolic link, so a swapped parent cannot redirect the canonical worktree after the last-component check (CWE-367). diff --git a/docs/doctoring/strix-hashed-lock-no-deps.md b/docs/doctoring/strix-hashed-lock-no-deps.md index 431ce272b..5ac5b99bf 100644 --- a/docs/doctoring/strix-hashed-lock-no-deps.md +++ b/docs/doctoring/strix-hashed-lock-no-deps.md @@ -55,7 +55,12 @@ requires `--disable-pip` for that file, a skip for the override/input pair, and the `--no-deps` installer line on `strix.yml`. A `*-hashes.txt` name or a lone `--require-hashes` directive without `--hash=` is not treated as a complete lock. A mixed file with one hashed line beside unhashed -packages also stays on the resolver path. Discovery skips `.venv` trees. +packages, a filename-only wheel path, or a pip option carrying hash-shaped +text also stays on the resolver path. An unhashed compile input is skipped +only when its regular, non-symlink sibling is itself a valid complete lock. +Invalid UTF-8 and symlink/special-file requirement inputs fail before any +audit command. Repository-controlled filenames are JSON-escaped before +GitHub Actions group titles. Discovery skips `.venv` trees. ## References (APA 7th) diff --git a/tests/test_pip_audit_requirements.py b/tests/test_pip_audit_requirements.py index 5dd9ce83e..fa39a214b 100644 --- a/tests/test_pip_audit_requirements.py +++ b/tests/test_pip_audit_requirements.py @@ -383,7 +383,7 @@ def test_should_audit_skips_unstatable_children_and_accepts_pylock( """Unstatable children are skipped; a pylock.*.toml file is a real manifest.""" module = load_module() - vanished = tmp_path / "vanished" + vanished = tmp_path / "aaa-vanished" vanished.mkdir() service = tmp_path / "service" service.mkdir() @@ -392,7 +392,7 @@ def test_should_audit_skips_unstatable_children_and_accepts_pylock( original = pathlib.Path.lstat def flaky(self: pathlib.Path) -> Any: - if self.name == "vanished": + if self.name == "aaa-vanished": raise OSError("race") return original(self) @@ -400,6 +400,25 @@ def flaky(self: pathlib.Path) -> Any: assert module.should_audit_project_manifest(tmp_path) is True +def test_should_audit_continues_when_only_child_cannot_be_stated( + tmp_path: pathlib.Path, monkeypatch: Any +) -> None: + """An unstatable child cannot hide the no-manifest result.""" + + module = load_module() + vanished = tmp_path / "vanished" + vanished.mkdir() + original = pathlib.Path.lstat + + def flaky(self: pathlib.Path) -> Any: + if self.name == "vanished": + raise OSError("race") + return original(self) + + monkeypatch.setattr(pathlib.Path, "lstat", flaky) + assert module.should_audit_project_manifest(tmp_path) is False + + def test_display_path_falls_back_to_name_outside_the_root( tmp_path: pathlib.Path, ) -> None: diff --git a/tests/test_pip_audit_requirements_fail_closed.py b/tests/test_pip_audit_requirements_fail_closed.py index 8e0ae5e04..0a546e666 100644 --- a/tests/test_pip_audit_requirements_fail_closed.py +++ b/tests/test_pip_audit_requirements_fail_closed.py @@ -119,6 +119,46 @@ def test_requirement_symlink_is_rejected_instead_of_followed( module.discover_requirement_files(tmp_path) +def test_symlink_hash_sibling_cannot_suppress_source_audit( + tmp_path: pathlib.Path, +) -> None: + """A symlink ``*-hashes.txt`` sibling is not a regular complete lock.""" + + module = load_module() + source = tmp_path / "requirements-demo.txt" + source.write_text("demo==1.0.0\n", encoding="utf-8") + outside = tmp_path / "outside-lock.txt" + _write_valid_lock(outside) + sibling = tmp_path / "requirements-demo-hashes.txt" + sibling.symlink_to(outside) + + assert module.hashed_sibling(source) is None + assert module.audit_command(source) == [ + "pip-audit", + "--strict", + "--desc=on", + "-r", + str(source), + ] + assert "--disable-pip" not in (module.audit_command(source) or []) + + +def test_filename_only_requirement_is_not_a_complete_lock( + tmp_path: pathlib.Path, +) -> None: + """A hashed wheel path without an exact ``==`` pin cannot bypass pip.""" + + module = load_module() + requirements = tmp_path / "requirements-wheel.txt" + requirements.write_text( + "./demo-1.0.0-py3-none-any.whl --hash=sha256:" + ("c" * 64) + "\n", + encoding="utf-8", + ) + + assert module.is_hashed_lock(requirements) is False + assert "--disable-pip" not in (module.audit_command(requirements) or []) + + def test_valid_regular_hash_sibling_still_suppresses_compile_input( tmp_path: pathlib.Path, ) -> None: From 0c8f062a336b0b7b055834641217850c73de066b Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Mon, 17 Aug 2026 16:04:17 +0000 Subject: [PATCH 11/12] fix(pip-audit): keep index-url locks on disable-pip Treat --index-url and other resolver-config lines as non-package input so a complete SHA-256 lock does not fall back to pip's resolver. Keep -r includes and hash-shaped option-only files on the resolver path. Reject presented requirement paths whose intermediate parent is a directory symlink before any audit command runs. Co-authored-by: Seongho Bae --- CHANGELOG.md | 2 +- docs/doctoring/strix-hashed-lock-no-deps.md | 15 ++- scripts/ci/pip_audit_requirements.py | 68 ++++++++++- ...test_pip_audit_requirements_fail_closed.py | 107 ++++++++++++++++++ 4 files changed, 183 insertions(+), 9 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f49117ae0..4740b71d7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -26,7 +26,7 @@ Semantic Versioning where the repository publishes a release. ### Fixed -- Required every package line to carry a `--hash=` pin before pip-audit treats a requirements file as a complete hashed lock. A lone `--require-hashes` directive, a mixed hashed-plus-unhashed file, a filename-only wheel path, or a hash-shaped pip option no longer receives `--disable-pip`. Invalid UTF-8 and symlink/special-file inputs fail before any audit command, and workflow log paths are escaped. +- Required every package line to carry a `--hash=` pin before pip-audit treats a requirements file as a complete hashed lock. A lone `--require-hashes` directive, a mixed hashed-plus-unhashed file, a filename-only wheel path, a `-r` include, or a hash-shaped pip option no longer receives `--disable-pip`. Resolver-config lines such as `--index-url` beside exact SHA-256 pins still use `--disable-pip`. Invalid UTF-8, symlink/special-file inputs, and directory-symlink parents fail before any audit command, and workflow log paths are escaped. - Landed `--require-hashes --no-deps` on the required Strix installer and taught pip-audit to audit hashed complete locks with `--disable-pip`, so a later strix-agent 1.5.3 + cryptography 50.0.0 lock can install under `pull_request_target` and pip-audit no longer labels pip `ResolutionImpossible` as a known vulnerability (ContextualWisdomLab/.github#961, #952). A `*-hashes.txt` name without hash evidence no longer receives `--disable-pip`, and discovery skips virtualenv trees. - Materialized base Python locks only when every package line is an exact SHA-256 pin or a bounded relative `-r`/`--requirement` include. A lone `--require-hashes` directive, a dotted include such as `./lock.txt`, or `-r other-hashes.txt` no longer enters the trusted build context. - Refused a conflict-scope repository root whose immediate parent is a symbolic link, so a swapped parent cannot redirect the canonical worktree after the last-component check (CWE-367). diff --git a/docs/doctoring/strix-hashed-lock-no-deps.md b/docs/doctoring/strix-hashed-lock-no-deps.md index 5ac5b99bf..f262ef019 100644 --- a/docs/doctoring/strix-hashed-lock-no-deps.md +++ b/docs/doctoring/strix-hashed-lock-no-deps.md @@ -55,12 +55,15 @@ requires `--disable-pip` for that file, a skip for the override/input pair, and the `--no-deps` installer line on `strix.yml`. A `*-hashes.txt` name or a lone `--require-hashes` directive without `--hash=` is not treated as a complete lock. A mixed file with one hashed line beside unhashed -packages, a filename-only wheel path, or a pip option carrying hash-shaped -text also stays on the resolver path. An unhashed compile input is skipped -only when its regular, non-symlink sibling is itself a valid complete lock. -Invalid UTF-8 and symlink/special-file requirement inputs fail before any -audit command. Repository-controlled filenames are JSON-escaped before -GitHub Actions group titles. Discovery skips `.venv` trees. +packages, a filename-only wheel path, a ``-r`` include, or a pip option +carrying hash-shaped text also stays on the resolver path. A complete lock +that only adds resolver config such as ``--index-url`` still uses +``--disable-pip``. An unhashed compile input is skipped only when its +regular, non-symlink sibling is itself a valid complete lock. Invalid UTF-8, +symlink/special-file inputs, and paths whose intermediate parent is a +directory symlink fail before any audit command. Repository-controlled +filenames are JSON-escaped before GitHub Actions group titles. Discovery +skips `.venv` trees. ## References (APA 7th) diff --git a/scripts/ci/pip_audit_requirements.py b/scripts/ci/pip_audit_requirements.py index 4b86aa602..ddfcfc4b2 100644 --- a/scripts/ci/pip_audit_requirements.py +++ b/scripts/ci/pip_audit_requirements.py @@ -26,6 +26,22 @@ {".git", ".venv", "venv", "node_modules", "__pycache__"} ) _HASH_FIELD = re.compile(r"--hash=sha256:[0-9a-f]{64}") +_RESOLVER_CONFIG_TOKENS = frozenset( + { + "--require-hashes", + "--index-url", + "--extra-index-url", + "-i", + "--trusted-host", + "--find-links", + "-f", + "--no-index", + "--pre", + "--prefer-binary", + "--only-binary", + "--no-binary", + } +) class AuditConfigurationError(RuntimeError): @@ -45,6 +61,50 @@ def _require_regular_file(path: pathlib.Path) -> None: raise AuditConfigurationError( "requirements input must be a regular non-symlink file" ) + _require_no_symlink_ancestors(path) + + +def _ancestor_in_scope( + ancestor: pathlib.Path, stop: pathlib.Path | None +) -> bool: + """Return whether *ancestor* is a repository parent that must be inspected.""" + + if stop is None: + return len(ancestor.parts) > 2 + if ancestor == stop: + return False + try: + ancestor.relative_to(stop) + except ValueError: + return False + return True + + +def _require_no_symlink_ancestors( + path: pathlib.Path, *, stop: pathlib.Path | None = None +) -> None: + """Reject a path whose intermediate parent is a directory symlink.""" + + for ancestor in path.parents: + if not _ancestor_in_scope(ancestor, stop): + continue + try: + metadata = ancestor.lstat() + except OSError: + raise AuditConfigurationError( + "requirements input could not be inspected safely" + ) from None + if stat.S_ISLNK(metadata.st_mode): + raise AuditConfigurationError( + "requirements input must be a regular non-symlink file" + ) + + +def _is_resolver_config_line(line: str) -> bool: + """Return whether a logical line is pip resolver config, not a package pin.""" + + token = re.split(r"[\s=]", line, maxsplit=1)[0] + return token in _RESOLVER_CONFIG_TOKENS def _requirement_lines(path: pathlib.Path) -> list[str]: @@ -89,13 +149,16 @@ def is_hashed_lock(path: pathlib.Path) -> bool: A ``*-hashes.txt`` name, a lone ``--require-hashes`` directive, a pip option carrying hash-shaped text, or a mixed hashed-plus-unhashed file is - not sufficient. Every substantive package line must be an exact ``==`` pin + not sufficient. Resolver-config lines such as ``--index-url`` are not + package pins. Every substantive package line must be an exact ``==`` pin carrying one or more complete SHA-256 hashes before ``--disable-pip`` is allowed to bypass pip's resolver. """ lines = _requirement_lines(path) - package_lines = [line for line in lines if line != "--require-hashes"] + package_lines = [ + line for line in lines if not _is_resolver_config_line(line) + ] return bool(package_lines) and all( _is_exact_hashed_requirement(line) for line in package_lines ) @@ -175,6 +238,7 @@ def discover_requirement_files(root: pathlib.Path) -> list[pathlib.Path]: raise AuditConfigurationError( "requirements input must be a regular non-symlink file" ) + _require_no_symlink_ancestors(path, stop=root) found.append(path) return found diff --git a/tests/test_pip_audit_requirements_fail_closed.py b/tests/test_pip_audit_requirements_fail_closed.py index 0a546e666..a5c15f20d 100644 --- a/tests/test_pip_audit_requirements_fail_closed.py +++ b/tests/test_pip_audit_requirements_fail_closed.py @@ -81,6 +81,39 @@ def test_pip_option_with_hash_is_not_a_complete_lock(tmp_path: pathlib.Path) -> assert "--disable-pip" not in (module.audit_command(requirements) or []) +def test_index_url_with_hashed_packages_stays_on_disable_pip( + tmp_path: pathlib.Path, +) -> None: + """Resolver config is not a package line and must not recreate ResolutionImpossible.""" + + module = load_module() + requirements = tmp_path / "requirements-index.txt" + requirements.write_text( + "--index-url https://example.invalid/simple\n" + "strix-agent==1.5.3 --hash=sha256:" + ("a" * 64) + "\n" + "cryptography==50.0.0 --hash=sha256:" + ("a" * 64) + "\n", + encoding="utf-8", + ) + + assert module.is_hashed_lock(requirements) is True + assert "--disable-pip" in (module.audit_command(requirements) or []) + + +def test_requirement_include_cannot_earn_disable_pip(tmp_path: pathlib.Path) -> None: + """A ``-r`` include is not resolver config and must keep pip's resolver.""" + + module = load_module() + requirements = tmp_path / "requirements-include.txt" + requirements.write_text( + "-r other-hashes.txt\n" + "demo==1.0.0 --hash=sha256:" + ("a" * 64) + "\n", + encoding="utf-8", + ) + + assert module.is_hashed_lock(requirements) is False + assert "--disable-pip" not in (module.audit_command(requirements) or []) + + def test_invalid_utf8_fails_before_any_audit_command( tmp_path: pathlib.Path, capsys: pytest.CaptureFixture[str] ) -> None: @@ -119,6 +152,80 @@ def test_requirement_symlink_is_rejected_instead_of_followed( module.discover_requirement_files(tmp_path) +def test_nested_regular_lock_is_still_discovered(tmp_path: pathlib.Path) -> None: + """An intermediate regular directory must not block hashed-lock discovery.""" + + module = load_module() + nested = tmp_path / "pkg" + nested.mkdir() + lock = nested / "requirements-nested.txt" + _write_valid_lock(lock) + + assert [path.name for path in module.discover_requirement_files(tmp_path)] == [ + "requirements-nested.txt" + ] + assert "--disable-pip" in (module.audit_command(lock) or []) + + +def test_ancestor_outside_the_audit_root_is_not_inspected( + tmp_path: pathlib.Path, +) -> None: + """Parents above the audit root are host paths, not repository input.""" + + module = load_module() + stop = tmp_path / "stop" + stop.mkdir() + other = tmp_path / "other" + other.mkdir() + presented = other / "requirements-outside.txt" + _write_valid_lock(presented) + + module._require_no_symlink_ancestors(presented, stop=stop) + + +def test_unstatable_intermediate_parent_fails_before_audit( + tmp_path: pathlib.Path, monkeypatch: Any +) -> None: + """A parent that cannot be lstat'd cannot enter the audited set.""" + + module = load_module() + nested = tmp_path / "pkg" + nested.mkdir() + presented = nested / "requirements-nested.txt" + _write_valid_lock(presented) + original = pathlib.Path.lstat + + def boom(self: pathlib.Path) -> Any: + if self.name == "pkg": + raise OSError("gone") + return original(self) + + monkeypatch.setattr(pathlib.Path, "lstat", boom) + error_type = getattr(module, "AuditConfigurationError", RuntimeError) + with pytest.raises(error_type, match="could not be inspected"): + module._require_no_symlink_ancestors(presented, stop=tmp_path) + + +def test_intermediate_symlink_parent_is_rejected_before_audit( + tmp_path: pathlib.Path, +) -> None: + """A directory symlink in the presented path cannot redirect the install set.""" + + module = load_module() + real = tmp_path / "real" + (real / "sub").mkdir(parents=True) + _write_valid_lock(real / "sub" / "requirements-hidden.txt") + link = tmp_path / "link" + link.symlink_to(real) + presented = link / "sub" / "requirements-hidden.txt" + error_type = getattr(module, "AuditConfigurationError", RuntimeError) + + with pytest.raises(error_type, match="regular non-symlink file"): + module.is_hashed_lock(presented) + with pytest.raises(error_type, match="regular non-symlink file"): + module.audit_command(presented) + + def test_symlink_hash_sibling_cannot_suppress_source_audit( tmp_path: pathlib.Path, ) -> None: From 6c2001a0b996a65df0a52c16a0117c1af483df3d Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Mon, 17 Aug 2026 23:11:02 +0000 Subject: [PATCH 12/12] test(pip-audit): cover non-regular pylock continue branch OpenCode dispatch coverage-evidence failed at 99% because should_audit_project_manifest's 260->259 continue depended on glob order. A pylock-shaped symlink or directory now exercises that branch without a regular sibling. Co-authored-by: Seongho Bae --- CHANGELOG.md | 2 +- docs/doctoring/strix-hashed-lock-no-deps.md | 3 ++- tests/test_pip_audit_requirements_fail_closed.py | 12 ++++++++++++ 3 files changed, 15 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4740b71d7..0c2f7a94b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -26,7 +26,7 @@ Semantic Versioning where the repository publishes a release. ### Fixed -- Required every package line to carry a `--hash=` pin before pip-audit treats a requirements file as a complete hashed lock. A lone `--require-hashes` directive, a mixed hashed-plus-unhashed file, a filename-only wheel path, a `-r` include, or a hash-shaped pip option no longer receives `--disable-pip`. Resolver-config lines such as `--index-url` beside exact SHA-256 pins still use `--disable-pip`. Invalid UTF-8, symlink/special-file inputs, and directory-symlink parents fail before any audit command, and workflow log paths are escaped. +- Required every package line to carry a `--hash=` pin before pip-audit treats a requirements file as a complete hashed lock. A lone `--require-hashes` directive, a mixed hashed-plus-unhashed file, a filename-only wheel path, a `-r` include, or a hash-shaped pip option no longer receives `--disable-pip`. Resolver-config lines such as `--index-url` beside exact SHA-256 pins still use `--disable-pip`. Invalid UTF-8, symlink/special-file inputs, and directory-symlink parents fail before any audit command, and workflow log paths are escaped. A pylock-shaped symlink or directory is not a project manifest, so the continue-to-next-candidate branch no longer depends on filesystem glob order. - Landed `--require-hashes --no-deps` on the required Strix installer and taught pip-audit to audit hashed complete locks with `--disable-pip`, so a later strix-agent 1.5.3 + cryptography 50.0.0 lock can install under `pull_request_target` and pip-audit no longer labels pip `ResolutionImpossible` as a known vulnerability (ContextualWisdomLab/.github#961, #952). A `*-hashes.txt` name without hash evidence no longer receives `--disable-pip`, and discovery skips virtualenv trees. - Materialized base Python locks only when every package line is an exact SHA-256 pin or a bounded relative `-r`/`--requirement` include. A lone `--require-hashes` directive, a dotted include such as `./lock.txt`, or `-r other-hashes.txt` no longer enters the trusted build context. - Refused a conflict-scope repository root whose immediate parent is a symbolic link, so a swapped parent cannot redirect the canonical worktree after the last-component check (CWE-367). diff --git a/docs/doctoring/strix-hashed-lock-no-deps.md b/docs/doctoring/strix-hashed-lock-no-deps.md index f262ef019..72655e782 100644 --- a/docs/doctoring/strix-hashed-lock-no-deps.md +++ b/docs/doctoring/strix-hashed-lock-no-deps.md @@ -61,7 +61,8 @@ that only adds resolver config such as ``--index-url`` still uses ``--disable-pip``. An unhashed compile input is skipped only when its regular, non-symlink sibling is itself a valid complete lock. Invalid UTF-8, symlink/special-file inputs, and paths whose intermediate parent is a -directory symlink fail before any audit command. Repository-controlled +directory symlink fail before any audit command. A pylock-shaped symlink +or directory is not a project manifest. Repository-controlled filenames are JSON-escaped before GitHub Actions group titles. Discovery skips `.venv` trees. diff --git a/tests/test_pip_audit_requirements_fail_closed.py b/tests/test_pip_audit_requirements_fail_closed.py index a5c15f20d..d553611da 100644 --- a/tests/test_pip_audit_requirements_fail_closed.py +++ b/tests/test_pip_audit_requirements_fail_closed.py @@ -266,6 +266,18 @@ def test_filename_only_requirement_is_not_a_complete_lock( assert "--disable-pip" not in (module.audit_command(requirements) or []) +def test_non_regular_pylock_name_is_not_a_project_manifest( + tmp_path: pathlib.Path, +) -> None: + """A pylock-shaped symlink or directory cannot earn the project-manifest audit.""" + + module = load_module() + (tmp_path / "pylock.alias.toml").symlink_to(tmp_path / "missing.toml") + (tmp_path / "pylock.dir.toml").mkdir() + + assert module.should_audit_project_manifest(tmp_path) is False + + def test_valid_regular_hash_sibling_still_suppresses_compile_input( tmp_path: pathlib.Path, ) -> None: