diff --git a/.github/workflows/strix-changed-path-quality-ci.yml b/.github/workflows/strix-changed-path-quality-ci.yml new file mode 100644 index 000000000..cddf5baa1 --- /dev/null +++ b/.github/workflows/strix-changed-path-quality-ci.yml @@ -0,0 +1,64 @@ +name: Strix Changed Path Quality CI + +on: + pull_request: + branches: [main] + paths: + - ".github/workflows/strix-changed-path-quality-ci.yml" + - "CHANGELOG.md" + - "docs/doctoring/strix-legal-git-paths.md" + - "scripts/ci/strix_quick_gate.sh" + - "tests/test_strix_changed_path_policy.py" + - "tests/test_strix_workflow_dependency_hashes.py" + +permissions: + contents: read + +concurrency: + group: strix-changed-path-quality-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: true + +jobs: + exact-head-path-policy: + if: github.event_name != 'pull_request' || github.event.action != 'closed' + runs-on: ubuntu-24.04 + timeout-minutes: 10 + steps: + - name: Checkout exact source revision + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + with: + ref: ${{ github.event.pull_request.head.sha || github.sha }} + persist-credentials: false + + - name: Set up Python + uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 + with: + python-version: "3.14" + + - name: Install exact hash-verified test runner dependencies + env: + PIP_DISABLE_PIP_VERSION_CHECK: "1" + PIP_NO_INPUT: "1" + shell: bash --noprofile --norc -e -o pipefail {0} + run: | + cat >"${RUNNER_TEMP}/strix-quality-requirements.txt" <<'EOF' + coverage==7.15.2 --hash=sha256:b9a6367e4aff723e8ee8190836836124284e8fcd4265e307c844010cfa074f3f + iniconfig==2.1.0 --hash=sha256:9deba5723312380e77435581c6bf4935c94cbfab9b1ed33ef8d238ea168eb760 + packaging==26.2 --hash=sha256:5fc45236b9446107ff2415ce77c807cee2862cb6fac22b8a73826d0693b0980e + pluggy==1.6.0 --hash=sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746 + pygments==2.20.0 --hash=sha256:81a9e26dd42fd28a23a2d169d86d7ac03b46e2f8b59ed4698fb4785f946d0176 + pytest==9.1.1 --hash=sha256:37a86b45efb9a47a61a36449063e8e18d0cab3161329fc099eb21783169c4f0c + EOF + python -m pip install \ + --only-binary=:all: \ + --require-hashes \ + -r "${RUNNER_TEMP}/strix-quality-requirements.txt" + + - name: Verify exact-head path policy and syntax + shell: bash --noprofile --norc -e -o pipefail {0} + run: | + test "$(git rev-parse HEAD)" = "${{ github.event.pull_request.head.sha || github.sha }}" + python -m coverage run -m pytest tests -q + python -m compileall -q tests/test_strix_changed_path_policy.py tests/test_strix_workflow_dependency_hashes.py + bash -n scripts/ci/strix_quick_gate.sh + git diff --exit-code diff --git a/CHANGELOG.md b/CHANGELOG.md index e601de81b..5512171d4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,5 +12,6 @@ Semantic Versioning where the repository publishes a release. ### Fixed +- 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 both trusted-uv quality jobs to `github.event.pull_request.head.sha` and added a permanent two-checkout regression contract so exact-head compatibility, coverage, docstring, and compilation claims cannot silently measure GitHub's generated pull-request merge revision. - Made Strix treat only a single LiteLLM provider-error line containing NVIDIA NIM context and model-catalog 404 evidence as cross-model fallback evidence, rejecting cross-line signal assembly and provider-like target source literals; moved the public default to Nemotron 3 Super 120B and added a second NVIDIA hosted candidate before GitHub Models without neutralizing reported vulnerabilities. diff --git a/docs/doctoring/strix-legal-git-paths.md b/docs/doctoring/strix-legal-git-paths.md new file mode 100644 index 000000000..aa68f8e7f --- /dev/null +++ b/docs/doctoring/strix-legal-git-paths.md @@ -0,0 +1,116 @@ +# Strix legal Git path compatibility + +## Incident and buyer impact + +The organization-required Strix quick gate rejected the exact changed-file list +for `ContextualWisdomLab/aFIPC#160` at head +`804ea97cd83144f94c5020a9d42f2573cc8cb442`. The pull request deletes generated +Packrat artifacts, including the tracked fixture path +`Ugly, but legal, path for a project (long)`. The central gate classified that +path as unsafe solely because its comma and parentheses were absent from the +bounded ASCII allowlist. Security analysis therefore stopped before examining +the pull request, leaving a valid supply-chain cleanup without exact-head Strix +evidence. + +## Decision + +The normalizer now admits comma and ASCII parentheses. No other punctuation is +broadened. Existing fail-closed controls remain authoritative: + +- empty, dot, absolute, leading/trailing-whitespace, NUL, CR, LF, and + backslash forms are rejected; +- raw `..` components are rejected before `posixpath.normpath()` can collapse + an embedded traversal such as `safe/../target.txt`; +- shell metacharacters such as semicolon, dollar sign, backtick, pipe, and + ampersand remain rejected; +- only the existing Unicode letter, combining-mark, and number categories are + accepted outside ASCII; +- `Path.resolve(strict=False)` followed by `relative_to()` proves containment + beneath the trusted repository root; and +- downstream Git and filesystem operations receive normalized paths as quoted + arguments, never as executable shell source. + +This is a compatibility correction, not a general relaxation to every pathname +byte Git can represent. The privileged scanner intentionally retains a smaller, +audited path policy. + +## Test-first evidence + +`tests/test_strix_changed_path_policy.py` extracts and executes the exact Python +normalizer embedded in `scripts/ci/strix_quick_gate.sh`. The materializer first +requires the historical Packrat fixture regression to fail on protected main, +then applies the narrow allowlist change and requires the same test to pass. +A test-only exact-head commit first demonstrated that `safe/../target.txt` +passed after normalization; the production repair now rejects its raw `..` +component before normalization. Permanent tests preserve established punctuation +and reject traversal, absolute paths, controls, whitespace ambiguity, backslashes, +and representative shell punctuation. The dedicated workflow runs the complete +repository test suite through coverage.py and pytest whenever code or either +authoritative contract document changes. + +## Workflow dependency integrity + +The exact-head policy workflow downloads its Python test runner from PyPI, so +version pins alone are insufficient: a compromised index response or replaced +artifact could otherwise change executable CI code without a repository diff. +The workflow therefore uses pip hash-checking mode (`--require-hashes`) together +with `--only-binary=:all:` and the exact SHA-256 digest of every wheel selected +on the fixed `ubuntu-24.04` x86-64 / CPython 3.14 runner: + +- coverage 7.15.2: `b9a6367e4aff723e8ee8190836836124284e8fcd4265e307c844010cfa074f3f`; +- iniconfig 2.1.0: `9deba5723312380e77435581c6bf4935c94cbfab9b1ed33ef8d238ea168eb760`; +- packaging 26.2: `5fc45236b9446107ff2415ce77c807cee2862cb6fac22b8a73826d0693b0980e`; +- pluggy 1.6.0: `e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746`; +- Pygments 2.20.0: `81a9e26dd42fd28a23a2d169d86d7ac03b46e2f8b59ed4698fb4785f946d0176`; +- pytest 9.1.1: `37a86b45efb9a47a61a36449063e8e18d0cab3161329fc099eb21783169c4f0c`. + +`tests/test_strix_workflow_dependency_hashes.py` was committed before the +workflow implementation and fails against the preceding exact head because +hash-checking mode and its trigger path are absent. It is now part of the +workflow's own path filter and verifies every requirement/digest pair. Any +package or runner-platform change must update the package version, PyPI wheel +digest, regression contract, and this record together. A digest mismatch must +fail closed; do not disable hash checking to restore availability. + +## Rollback and incident response + +Roll back the allowlist and regression together only if a downstream call is +proven to evaluate normalized paths as shell source. Until that defect is fixed, +fail Strix closed and retain the offending path, workflow run, and commit SHA as +incident evidence. Do not bypass the required security check. + +If an exact dependency wheel becomes unavailable, first verify the release and +artifact digest against PyPI's file record and provenance. A rollback may select +the last known-good fully versioned wheel only when its exact hash is recorded in +the workflow, regression contract, and this document. Never replace +`--require-hashes` with an unhashed install. + +## References + +Batchelder, N., & contributors. (2026). *coverage.py 7.15.2* [Computer +software]. Python Package Index. https://pypi.org/project/coverage/7.15.2/ + +Git Project. (2026). *Git index format*. https://git-scm.com/docs/index-format + +Git Project. (2026). *git-ls-tree documentation*. https://git-scm.com/docs/git-ls-tree + +Python Packaging Authority. (2026). *Secure installs*. pip documentation. +https://pip.pypa.io/en/stable/topics/secure-installs/ + +Python Software Foundation. (2026). *pathlib—Object-oriented filesystem paths +(Python 3.14.6 documentation)*. https://docs.python.org/3.14/library/pathlib.html + +pytest development team. (2025). *iniconfig 2.1.0* [Computer software]. Python +Package Index. https://pypi.org/project/iniconfig/2.1.0/ + +pytest development team. (2025). *pluggy 1.6.0* [Computer software]. Python +Package Index. https://pypi.org/project/pluggy/1.6.0/ + +pytest development team. (2026). *pytest 9.1.1* [Computer software]. Python +Package Index. https://pypi.org/project/pytest/9.1.1/ + +Python Packaging Authority. (2026). *packaging 26.2* [Computer software]. +Python Package Index. https://pypi.org/project/packaging/26.2/ + +Pygments contributors. (2026). *Pygments 2.20.0* [Computer software]. Python +Package Index. https://pypi.org/project/Pygments/2.20.0/ diff --git a/scripts/ci/strix_quick_gate.sh b/scripts/ci/strix_quick_gate.sh index c318f788f..0f37f3460 100755 --- a/scripts/ci/strix_quick_gate.sh +++ b/scripts/ci/strix_quick_gate.sh @@ -406,16 +406,21 @@ if "\x00" in relative_path_str: raise SystemExit(1) if "\\" in relative_path_str: raise SystemExit(1) +if any(component == ".." for component in relative_path_str.split("/")): + raise SystemExit(1) normalized = posixpath.normpath(relative_path_str) if normalized in (".", "") or normalized.startswith("../") or normalized == "..": raise SystemExit(1) # '@' is required for Apple/Tauri retina asset names (128x128@2x.png) and '+' -# for SvelteKit's mandatory route files (+page.svelte, +layout.ts). Preserve -# the existing ASCII allowlist and additionally accept only Unicode letters, -# combining marks, and numbers. This supports internationalized repository -# paths without admitting controls, separators, bidi formatting, shell -# metacharacters, or Unicode punctuation that could resemble a path boundary. -allowed_ascii = frozenset("_.@+/ []-") +# for SvelteKit's mandatory route files (+page.svelte, +layout.ts). Commas and +# parentheses are ordinary Git filename characters used by historical Packrat +# fixtures. They remain data because downstream filesystem and Git calls pass +# the normalized path as a quoted argument rather than executable shell source. +# Preserve the bounded ASCII allowlist and additionally accept only Unicode +# letters, combining marks, and numbers. This supports internationalized +# repository paths without admitting controls, separators, bidi formatting, +# shell metacharacters, or Unicode punctuation resembling a path boundary. +allowed_ascii = frozenset("_.@+/ [],()-") if not all( (character.isascii() and (character.isalnum() or character in allowed_ascii)) or ( diff --git a/tests/test_strix_changed_path_policy.py b/tests/test_strix_changed_path_policy.py new file mode 100644 index 000000000..4d5ddd3c4 --- /dev/null +++ b/tests/test_strix_changed_path_policy.py @@ -0,0 +1,94 @@ +"""Regression tests for the production Strix changed-path normalizer.""" + +from __future__ import annotations + +import subprocess +import sys +import tempfile +import unittest +from pathlib import Path + + +REPOSITORY_ROOT = Path(__file__).resolve().parents[1] +GATE_SCRIPT = REPOSITORY_ROOT / "scripts" / "ci" / "strix_quick_gate.sh" +START_MARKER = 'python3 - "$REPO_ROOT" "$changed_file" <<\'PY\'\n' +END_MARKER = "\nPY\n}\n\nnormalize_changed_files_cache()" +LEGAL_PACKRAT_PATH = ( + "packrat/lib/x86_64-pc-linux-gnu/3.4.1/packrat/tests/testthat/" + "Ugly, but legal, path for a project (long)/bread/DESCRIPTION" +) + + +def _normalizer_source() -> str: + """Return the exact embedded Python program used in production.""" + + gate_source = GATE_SCRIPT.read_text(encoding="utf-8") + prefix, separator, remainder = gate_source.partition(START_MARKER) + if not separator or not prefix: + raise AssertionError("Strix changed-path normalizer start marker is missing") + source, separator, _suffix = remainder.partition(END_MARKER) + if not separator: + raise AssertionError("Strix changed-path normalizer end marker is missing") + return source + + +def _normalize(candidate: str) -> subprocess.CompletedProcess[str]: + """Execute the production normalizer with an isolated repository root.""" + + with tempfile.TemporaryDirectory() as temporary_directory: + return subprocess.run( + [sys.executable, "-c", _normalizer_source(), temporary_directory, candidate], + check=False, + capture_output=True, + text=True, + ) + + +class StrixChangedPathPolicyTests(unittest.TestCase): + """Verify legal Git paths and fail-closed path boundaries.""" + + def test_accepts_historical_packrat_fixture_path(self) -> None: + """A tracked Packrat fixture with commas and parentheses is valid input.""" + + result = _normalize(LEGAL_PACKRAT_PATH) + self.assertEqual(result.returncode, 0, result.stderr) + self.assertEqual(result.stdout.strip(), LEGAL_PACKRAT_PATH) + + def test_preserves_existing_supported_punctuation(self) -> None: + """Existing bracket, at-sign, plus-sign, space, and hyphen support remains.""" + + candidate = "ui/[slug]/128x128@2x +page-safe/file-name.ts" + result = _normalize(candidate) + self.assertEqual(result.returncode, 0, result.stderr) + self.assertEqual(result.stdout.strip(), candidate) + + def test_rejects_traversal_absolute_controls_and_shell_punctuation(self) -> None: + """The repair does not admit traversal, controls, or shell syntax.""" + + rejected = ( + "", + ".", + "..", + "../secret.txt", + "safe/../target.txt", + "/tmp/secret.txt", + "safe\\escape.txt", + "safe\nname.txt", + "safe\rname.txt", + " leading.txt", + "trailing.txt ", + "safe;command.txt", + "safe$(command).txt", + "safe`command`.txt", + "safe|command.txt", + "safe&command.txt", + ) + for candidate in rejected: + with self.subTest(candidate=repr(candidate)): + result = _normalize(candidate) + self.assertNotEqual(result.returncode, 0) + self.assertEqual(result.stdout, "") + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_strix_workflow_dependency_hashes.py b/tests/test_strix_workflow_dependency_hashes.py new file mode 100644 index 000000000..eeda4d1b8 --- /dev/null +++ b/tests/test_strix_workflow_dependency_hashes.py @@ -0,0 +1,34 @@ +"""Supply-chain contracts for the Strix changed-path policy workflow.""" + +from pathlib import Path + + +ROOT = Path(__file__).resolve().parents[1] +WORKFLOW = ROOT / ".github" / "workflows" / "strix-changed-path-quality-ci.yml" +EXPECTED_WHEEL_HASHES = { + "coverage==7.15.2": "b9a6367e4aff723e8ee8190836836124284e8fcd4265e307c844010cfa074f3f", + "iniconfig==2.1.0": "9deba5723312380e77435581c6bf4935c94cbfab9b1ed33ef8d238ea168eb760", + "packaging==26.2": "5fc45236b9446107ff2415ce77c807cee2862cb6fac22b8a73826d0693b0980e", + "pluggy==1.6.0": "e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", + "pygments==2.20.0": "81a9e26dd42fd28a23a2d169d86d7ac03b46e2f8b59ed4698fb4785f946d0176", + "pytest==9.1.1": "37a86b45efb9a47a61a36449063e8e18d0cab3161329fc099eb21783169c4f0c", +} + + +def test_strix_workflow_installs_only_hash_verified_wheels() -> None: + """Every network-installed test dependency is versioned and hash verified.""" + workflow = WORKFLOW.read_text(encoding="utf-8") + + assert "--only-binary=:all:" in workflow + assert "--require-hashes" in workflow + assert 'cat >"${RUNNER_TEMP}/strix-quality-requirements.txt"' in workflow + assert '-r "${RUNNER_TEMP}/strix-quality-requirements.txt"' in workflow + for requirement, digest in EXPECTED_WHEEL_HASHES.items(): + assert f"{requirement} --hash=sha256:{digest}" in workflow + + +def test_strix_workflow_reruns_when_hash_contract_changes() -> None: + """Changing this regression contract must trigger the exact-head workflow.""" + workflow = WORKFLOW.read_text(encoding="utf-8") + + assert ' - "tests/test_strix_workflow_dependency_hashes.py"' in workflow