diff --git a/CHANGELOG.d/20260930-strix-report-path-token-boundary.md b/CHANGELOG.d/20260930-strix-report-path-token-boundary.md new file mode 100644 index 0000000000..37285c6e3a --- /dev/null +++ b/CHANGELOG.d/20260930-strix-report-path-token-boundary.md @@ -0,0 +1,5 @@ +## Fixed + +- Bind Strix changed-source report evidence to complete path tokens so backup or + child suffixes and same-named files under unrelated directories cannot satisfy + the central review-scope gate. diff --git a/docs/product-technical-gap-baseline.md b/docs/product-technical-gap-baseline.md index d4bedf024c..7cb88d777d 100644 --- a/docs/product-technical-gap-baseline.md +++ b/docs/product-technical-gap-baseline.md @@ -7,6 +7,12 @@ 이 문서는 제품·기술·운영 Gap을 현재 문서와 현재 GitHub 상태에 묶어 두는 기준선이다. 새 작업은 먼저 이 문서의 Gap ID를 PR 설명과 테스트 증거에 연결하고, PR의 정확한 exact HEAD·Checks·리뷰를 다시 수집한 뒤 구현한다. 표의 상태는 작성 시점의 관측값이므로, 병합 판단에는 재사용하지 않는다. 이 인벤토리는 스냅샷이며 merge authorization이 아니다. +### 2026-09-30 Strix report path-token false-positive delta + +| Gap ID | 상태 | exact evidence | causal owner / next gate | +|---|---|---|---| +| CONTROL-STRIX-REPORT-PATH-TOKEN-02 | **Proposed — RED→GREEN source repair; hosted exact-head acceptance pending** | `.github#2504`의 이전 `names_changed_path`는 전체 경로의 `.bak`/child suffix와 `other/scripts/ci/` 아래 동명 파일을 changed source로 오인했다. RED `fcf03118df421be7eff73964e711aaf1cd8312e6`이 두 오탐을 executable regression으로 고정했고, GREEN `ff64cfaa607a413976a8a85c9cd5a003289ef292`은 direct path·directory·filename을 완전 token 경계로 검증한다. focused path-boundary 계약은 7/7 GREEN이며 protected `main@37b10243cec3d160ecc9c1be75c71428b160a703`를 ordinary merge `e8fd6123c1ff6f4fd89848d15f07cb6ae5eb35b4`로 통합했다. | Canonical owner는 중앙 `scripts/ci/strix_report_scope.py`다. exact-head hosted security/quality Checks와 fresh independent review가 terminal GREEN이 되기 전에는 Accepted·merge authority로 승격하지 않는다. | + ### 2026-09-30 central coverage owner stack delta | Gap ID | 상태 | exact-head evidence | causal owner / next gate | diff --git a/scripts/ci/strix_report_scope.py b/scripts/ci/strix_report_scope.py old mode 100644 new mode 100755 index 158a570ea2..1f88096f54 --- a/scripts/ci/strix_report_scope.py +++ b/scripts/ci/strix_report_scope.py @@ -22,9 +22,35 @@ def _names_scoped_ancestor(report: str, changed_paths: list[str]) -> bool: return False -def validate(output: Path, changed_paths: list[str]) -> None: - """Validate that a completed Strix report covers changed source scope.""" +def _token(text: str) -> re.Pattern[str]: + """Match text only where it is not part of a longer name or path segment.""" + return re.compile(rf"(? re.Pattern[str]: + """Match a file name only when no longer name or child path continues it.""" + return re.compile(rf"(? bool: + """Return whether the report names the path, or its file within a named directory. + + The directory may be the file's own directory or any ancestor of at least two + segments; a lone top-level name such as ``crates`` is too generic to scope a file. + """ + if _file_token(path).search(report) is not None: + return True + directory, _, name = path.rpartition("/") + if not directory or _file_token(name).search(report) is None: + return False + if _token(directory).search(report) is not None: + return True + parts = directory.split("/") + return any(_token("/".join(parts[:depth])).search(report) for depth in range(2, len(parts) + 1)) + + +def validate(output: Path, changed_paths: list[str]) -> None: + """Raise ValueError unless one completed, unlinked report names a changed path.""" if not output.is_dir() or output.is_symlink(): raise ValueError("scan output directory is missing") runs = [path for path in output.iterdir() if path.is_dir() and not path.is_symlink()] @@ -44,7 +70,9 @@ def validate(output: Path, changed_paths: list[str]) -> None: if metadata.get("status") != "completed" or results.get("scan_completed") is not True or results.get("success") is not True: raise ValueError("scan report is incomplete") report = report_path.read_text(encoding="utf-8") - if not any(path in report for path in changed_paths) and not _names_scoped_ancestor(report, changed_paths): + if not any(names_changed_path(report, path) for path in changed_paths) and not _names_scoped_ancestor( + report, changed_paths + ): raise ValueError("scan report does not identify a changed source file") diff --git a/tests/test_strix_report_scope.py b/tests/test_strix_report_scope.py index 955557e214..7ef8589bae 100644 --- a/tests/test_strix_report_scope.py +++ b/tests/test_strix_report_scope.py @@ -194,6 +194,88 @@ def test_bare_repository_directory_or_scope_root_is_rejected( ) +def test_validate_accepts_directory_scoped_file_name(tmp_path: Path) -> None: + """A standalone file token within its reported directory is accepted.""" + _write_report( + tmp_path, + report_text=( + "Scope: `/workspace/strix-pr-scope.v6Wilu/scripts/ci/`.\n" + "Reviewed `strix_quick_gate.sh`; no vulnerabilities found.\n" + ), + ) + report_scope.validate(tmp_path, ["scripts/ci/strix_quick_gate.sh"]) + + +@pytest.mark.parametrize( + "report_text", + [ + "Reviewed `strix_quick_gate.sh` without naming its directory.\n", + "Scope: scripts/ci/. Reviewed `my_strix_quick_gate.sh`.\n", + "Scope: scripts/ci/. Reviewed `strix_quick_gate.sh.bak`.\n", + "Scope: other/scripts/ci-tools/. Reviewed strix_quick_gate.sh.\n", + ], +) +def test_validate_rejects_bare_or_partial_file_names( + tmp_path: Path, report_text: str +) -> None: + """Bare, prefixed, suffixed, and wrong-directory identities fail closed.""" + _write_report(tmp_path, report_text=report_text) + with pytest.raises(ValueError, match="does not identify a changed source file"): + report_scope.validate(tmp_path, ["scripts/ci/strix_quick_gate.sh"]) + + +@pytest.mark.parametrize( + "report_text", + [ + "Reviewed scripts/ci/strix_quick_gate.sh.bak.\n", + "Reviewed scripts/ci/strix_quick_gate.sh/notes.\n", + "Scope: other/scripts/ci/. Reviewed other/strix_quick_gate.sh.\n", + ], +) +def test_names_changed_path_rejects_longer_or_unrelated_paths( + report_text: str, +) -> None: + """A suffix or same-named file elsewhere is not the changed file.""" + assert not report_scope.names_changed_path( + report_text, "scripts/ci/strix_quick_gate.sh" + ) + + +def test_validate_accepts_file_within_reported_ancestor(tmp_path: Path) -> None: + """A two-segment ancestor plus standalone file token binds the source.""" + _write_report( + tmp_path, + report_text=( + "**Scope:** `/workspace/strix-pr-scope.4eyzTL` including " + "`crates/mlsirm-core`.\n" + "Reviewed `two_tier_recursion.rs`; no vulnerabilities found.\n" + ), + ) + report_scope.validate( + tmp_path, ["crates/mlsirm-core/src/two_tier_recursion.rs"] + ) + + +@pytest.mark.parametrize( + "report_text", + [ + "Scope: crates/ only. Reviewed two_tier_recursion.rs.\n", + "Scope: crates/mlsirm-core-extra. Reviewed two_tier_recursion.rs.\n", + "Scope: crates/mlsirm-core. Reviewed two_tier_recursion.rs/notes.\n", + "Scope: crates/mlsirm-core. Reviewed other_recursion.rs.\n", + ], +) +def test_validate_rejects_generic_or_mismatched_ancestors( + tmp_path: Path, report_text: str +) -> None: + """Generic, partial, child-suffixed, and wrong-file scopes are rejected.""" + _write_report(tmp_path, report_text=report_text) + with pytest.raises(ValueError, match="does not identify a changed source file"): + report_scope.validate( + tmp_path, ["crates/mlsirm-core/src/two_tier_recursion.rs"] + ) + + def test_cli_reports_validation_error_and_accepts_scoped_success( tmp_path: Path, monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str] ) -> None: