From 2df2f49145d212706599bdeee34d7d3bb47bd57b Mon Sep 17 00:00:00 2001 From: xeonvs <11463419+xeonvs@users.noreply.github.com> Date: Wed, 2 Sep 2026 08:52:29 +0200 Subject: [PATCH 01/15] plan: prepare v0.9.0 release --- PLANS.md | 95 +++++++++++++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 94 insertions(+), 1 deletion(-) diff --git a/PLANS.md b/PLANS.md index 6d4abc7..0cf7a85 100644 --- a/PLANS.md +++ b/PLANS.md @@ -6,4 +6,97 @@ before handoff or commit. Completed stable plans are indexed in ## Active Work -No active or blocked repository work. +### Release 0.9.0: unprotected-target integrity and OCR 1.11.2 + +Status: active +Owner: Codex +Last Updated: 2026-09-02 +Release classification: `release-required` +Target stable version: `0.9.0` +Milestone: `v0.9.0` +Authorization issues: `#167`, `#168`, `#169`, `#170` +Feature branch: `codex/v0.9.0-unprotected-target-integrity` + +#### Goal + +Deliver safe limited OCR reviews for explicitly permitted unprotected GitLab targets while +preserving immutable identity, comment-only approval boundaries, valid receipt/action +attribution, detached-pipeline diagnostics, and OCR 1.11.2 compatibility. + +#### Locked scope and decisions + +- The release snapshot contains every issue open at activation: #167-#170. Later issues are + not added automatically. Consumer repositories, B2B, `core/common`, and shared CI templates + are outside scope. +- Resolve one effective reviewed source SHA across review, posting, and pre-execution status + validation. Accept only lowercase 40-hex values, prefer a valid non-zero MR SHA, and fall + back to `CI_COMMIT_SHA` only for an absent or all-zero MR SHA. All malformed, conflicting, + stale, and mismatched identities remain fail-closed. +- Preserve receipt v6's strict positive evidence-action attribution. Move receipt construction, + hostile parsing, and validation behind one internal owner. Missing, malformed, unwritable, + or mismatched private action receipts fail before normal atomic publication and cannot publish + findings or authorize approval. Advisories are evaluated only after receipt validity. +- Add `OCR_GITLAB_TARGET_PROTECTION_MODE=required|unprotected`. Unset means `required`; an + explicit empty or unknown value fails closed. A protected target keeps current behavior under + either setting. An actually unprotected target is limited to comment-only review with context + `off` or bounded untrusted `metadata`; enriched context, adapters, protected-policy acquisition, + and direct or inherited external MCP are rejected before OCR. Built-in immutable repository + evidence remains available. Target Rules may be bounded model guidance, but accepted decisions + and structured project guidance are omitted in the first implementation. +- Advance the public toolkit receipt to v7 with immutable target SHA and closed actual protection + state. Unprotected receipts are structurally approval-ineligible and alone authorize the static + italic limitation line after the primary status line for every normal receipt-backed outcome. + Legacy receipts are not reinterpreted. +- Documentation is a same-release deliverable: README quick start, configuration, GitLab setup, + operations/troubleshooting, security/trust model, and synthetic public GitLab examples cover the + complete integration and failure model, including two-MR and supported one-MR setup paths. +- Qualify OCR 1.11.2, promote tested/recommended pins, and make the language-rule probe verify + `.mjs`/`.cjs` JavaScript and `.cxx`/`.hxx` C++ routing while `.svh` remains excluded. Viewer, + remote-MCP documentation, localization, and Go module-boundary changes have no toolkit runtime + impact. + +#### Delivery sequence + +1. [x] Create milestone `v0.9.0`, assign #167-#170, create this feature branch, and record the + complete release plan as the first signed commit. +2. [ ] Push only the plan commit and open a Draft PR with all issue links and the delivery plan; + do not use closing keywords because the stable Release workflow owns issue closure. +3. [ ] Implement #167 as a focused signed identity/status commit with its synthetic regression + matrix. +4. [ ] Implement #169 as a focused signed receipt/action-integrity commit. +5. [ ] Implement #168 provider/config/snapshot, receipt v7, and approval boundary as one complete + runtime commit. +6. [ ] Implement #168 summary projection, complete public documentation, and synthetic examples + as one complete documentation contract commit. +7. [ ] Implement #170 qualification, OCR 1.11.2 pins, Rules probes, and `maintenance` plus `rules` + Towncrier entries. +8. [ ] Review every commit and the complete `origin/main..HEAD` range, run all deterministic local + gates, and fix supported findings in separately reviewed signed commits. +9. [ ] Run exactly one Codex Security diff scan for `origin/main..HEAD`, validate attack paths, fix + supported findings, and repeat holistic review plus deterministic validation. +10. [ ] Checksum-verify and atomically install PATH-effective OCR 1.11.2 with a rollback copy, then + run exactly one configured-provider local OCR review of the complete exact range with context + `off`, public Rules, concurrency 2, and owner-only artifacts. Inspect complete manifest coverage + and receipt/action attribution; fix findings and repeat deterministic validation only. +11. [ ] Push the locally closed implementation, finish hosted checks and review with zero unresolved + threads, mark the Draft PR ready, and exact-head squash merge. Delete the feature branch. +12. [ ] Verify the protected-main TestPyPI development publication, create `release/v0.9.0`, set + stable/next versions to `0.9.0`/`0.9.1`, record deterministic source epoch and authorization + issues `[167,168,169,170]`, render Towncrier, archive this plan, and finish the protected release + PR. +13. [ ] Monitor stable publication and independently verify registry/workflow/Release byte equality, + PEP 740 and GitHub attestations, annotated tag target, immutable five-asset Release, release + receipt, Python 3.12-3.14 wheel/sdist installs, Actions-owned issue receipts, issue/milestone + closure, branch cleanup, and clean `main == origin/main == v0.9.0^{}`. + +#### Per-commit and final validation + +Before every commit, update this plan and affected status documents to post-commit truth, run focused +tests, apply the repository formatter for Python and require frozen Ruff format, inspect the entire +staged diff for correctness, hostile inputs, DLP/privacy, and scope, then run `git diff --check`. + +Final deterministic gates are `scripts/quality.sh check`, compatibility validation, Towncrier draft, +Gitleaks, build/Twine, archive/privacy checks, and clean wheel/sdist CLI smoke. OCR exit zero is not +completion without complete selected-item coverage and exact receipt/action reconciliation. A second +semantic local OCR run is prohibited. Stable delivery remains incomplete until external publication +and independent reconciliation are complete. From 90d553301885aa20bc0baab7d79928bce83e8820 Mon Sep 17 00:00:00 2001 From: xeonvs <11463419+xeonvs@users.noreply.github.com> Date: Wed, 2 Sep 2026 08:58:06 +0200 Subject: [PATCH 02/15] fix: unify reviewed source identity --- PLANS.md | 4 +- src/ocr_toolkit/posting/gitlab.py | 33 +++--- src/ocr_toolkit/posting/workflow.py | 10 +- src/ocr_toolkit/review_identity.py | 26 +++++ tests/test_posting_helpers.py | 163 ++++++++++++++++++++++++++++ 5 files changed, 210 insertions(+), 26 deletions(-) create mode 100644 src/ocr_toolkit/review_identity.py diff --git a/PLANS.md b/PLANS.md index 0cf7a85..c3182d7 100644 --- a/PLANS.md +++ b/PLANS.md @@ -59,9 +59,9 @@ attribution, detached-pipeline diagnostics, and OCR 1.11.2 compatibility. 1. [x] Create milestone `v0.9.0`, assign #167-#170, create this feature branch, and record the complete release plan as the first signed commit. -2. [ ] Push only the plan commit and open a Draft PR with all issue links and the delivery plan; +2. [x] Push only the plan commit and open a Draft PR with all issue links and the delivery plan; do not use closing keywords because the stable Release workflow owns issue closure. -3. [ ] Implement #167 as a focused signed identity/status commit with its synthetic regression +3. [x] Implement #167 as a focused signed identity/status commit with its synthetic regression matrix. 4. [ ] Implement #169 as a focused signed receipt/action-integrity commit. 5. [ ] Implement #168 provider/config/snapshot, receipt v7, and approval boundary as one complete diff --git a/src/ocr_toolkit/posting/gitlab.py b/src/ocr_toolkit/posting/gitlab.py index 7fb421d..010d0fb 100644 --- a/src/ocr_toolkit/posting/gitlab.py +++ b/src/ocr_toolkit/posting/gitlab.py @@ -35,6 +35,7 @@ fetch_current_user_identity, valid_discussion_id, ) +from ocr_toolkit.review_identity import effective_reviewed_sha @dataclass(frozen=True) @@ -900,14 +901,13 @@ def get_diff_refs(config: GitLabConfig) -> dict[str, str] | None: ) return None - candidate_head_shas: list[str] = [] - for sha in ( - getenv("CI_MERGE_REQUEST_SOURCE_BRANCH_SHA").strip(), - getenv("CI_COMMIT_SHA").strip(), - ): - if sha and sha not in candidate_head_shas and not re.fullmatch(r"0+", sha): - candidate_head_shas.append(sha) - if not candidate_head_shas: + reviewed_head = effective_reviewed_sha( + { + "CI_MERGE_REQUEST_SOURCE_BRANCH_SHA": getenv("CI_MERGE_REQUEST_SOURCE_BRANCH_SHA"), + "CI_COMMIT_SHA": getenv("CI_COMMIT_SHA"), + } + ) + if not reviewed_head: print( "CI MR source/head commit SHA is unavailable; inline comments will fall back to notes.", file=sys.stderr, @@ -915,20 +915,17 @@ def get_diff_refs(config: GitLabConfig) -> dict[str, str] | None: return None matching_version = None - for candidate_sha in candidate_head_shas: - for version in versions: - if not isinstance(version, dict): - continue - if str(version.get("head_commit_sha") or "") == candidate_sha: - matching_version = version - break - if matching_version is not None: + for version in versions: + if not isinstance(version, dict): + continue + if str(version.get("head_commit_sha") or "") == reviewed_head: + matching_version = version break if matching_version is None: print( - "GitLab MR versions do not include CI head SHA candidate(s) " - f"{', '.join(candidate_head_shas)}; " + "GitLab MR versions do not include the effective CI reviewed head " + f"{reviewed_head}; " "inline comments will fall back to notes.", file=sys.stderr, ) diff --git a/src/ocr_toolkit/posting/workflow.py b/src/ocr_toolkit/posting/workflow.py index 5ef3362..35a3c2c 100644 --- a/src/ocr_toolkit/posting/workflow.py +++ b/src/ocr_toolkit/posting/workflow.py @@ -109,6 +109,7 @@ provider_failure_reason, ) from ocr_toolkit.result_contract import OcrResultContractError, ReviewOutcome, parse_result_outcome +from ocr_toolkit.review_identity import effective_reviewed_sha # Kept as a module-level compatibility seam for tests and external monkey-patching. post_review_note = gitlab_api.post_review_note @@ -143,12 +144,9 @@ def inline_skip_reason(refs: dict[str, str] | None, path: str, line: int) -> str def reviewed_sha() -> str: - """Return the commit SHA OCR was expected to review in CI.""" + """Return the validated commit SHA OCR was expected to review in CI.""" - source_sha = clean_text(os.environ.get("CI_MERGE_REQUEST_SOURCE_BRANCH_SHA", "")) - if source_sha and not re.fullmatch(r"0+", source_sha): - return source_sha - return clean_text(os.environ.get("CI_COMMIT_SHA", "")) + return effective_reviewed_sha(os.environ) def mr_head_sha() -> str: @@ -1300,7 +1298,7 @@ def post_ocr_failure( try: status_path = repository_artifacts().pre_execution_status - source_sha = clean_text(os.environ.get("CI_MERGE_REQUEST_SOURCE_BRANCH_SHA", "")) + source_sha = reviewed_sha() diff_base_sha = clean_text(os.environ.get("CI_MERGE_REQUEST_DIFF_BASE_SHA", "")) status = read_pre_execution_status( status_path, diff --git a/src/ocr_toolkit/review_identity.py b/src/ocr_toolkit/review_identity.py new file mode 100644 index 0000000..e432966 --- /dev/null +++ b/src/ocr_toolkit/review_identity.py @@ -0,0 +1,26 @@ +"""Resolve one fail-closed reviewed source identity across review lifecycle owners.""" + +from __future__ import annotations + +import re +from collections.abc import Mapping + +SHA_RE = re.compile(r"[0-9a-f]{40}\Z") +ZERO_SHA = "0" * 40 + + +def full_sha(value: object) -> str: + """Return one exact non-zero lowercase SHA-1 identity or an empty value.""" + + if not isinstance(value, str) or SHA_RE.fullmatch(value) is None or value == ZERO_SHA: + return "" + return value + + +def effective_reviewed_sha(environment: Mapping[str, str]) -> str: + """Resolve the MR source head with the documented detached-pipeline fallback.""" + + merge_request_sha = environment.get("CI_MERGE_REQUEST_SOURCE_BRANCH_SHA", "").strip() + if merge_request_sha and merge_request_sha != ZERO_SHA: + return full_sha(merge_request_sha) + return full_sha(environment.get("CI_COMMIT_SHA", "").strip()) diff --git a/tests/test_posting_helpers.py b/tests/test_posting_helpers.py index 138f0ed..87651d9 100644 --- a/tests/test_posting_helpers.py +++ b/tests/test_posting_helpers.py @@ -44,6 +44,7 @@ ) from ocr_toolkit.provider_failure import ProviderFailureReason from ocr_toolkit.result_contract import CoverageFailure, OcrResultContractError, ReviewOutcome +from ocr_toolkit.review_identity import effective_reviewed_sha from tests.support import ( gitlab_config, patched_attr, @@ -52,6 +53,27 @@ class PostingIdentityTests(unittest.TestCase): + def test_effective_reviewed_sha_has_one_strict_detached_pipeline_fallback(self) -> None: + valid = "b" * 40 + commit = "c" * 40 + for merge_request_sha, commit_sha, expected in ( + (valid, commit, valid), + ("", valid, valid), + ("0" * 40, valid, valid), + (valid, "", valid), + ("d" * 40, valid, "d" * 40), + ("B" * 40, valid, ""), + ("bad", valid, ""), + ("", "C" * 40, ""), + ("", "bad", ""), + ): + with self.subTest(merge_request_sha=merge_request_sha, commit_sha=commit_sha): + environment = { + "CI_MERGE_REQUEST_SOURCE_BRANCH_SHA": merge_request_sha, + "CI_COMMIT_SHA": commit_sha, + } + self.assertEqual(effective_reviewed_sha(environment), expected) + def test_post_results_fails_closed_without_current_user(self) -> None: calls: list[str] = [] @@ -2081,6 +2103,19 @@ def test_workflow_identity_helpers_fail_closed_on_ambiguous_values(self) -> None ): self.assertEqual(workflow.reviewed_sha(), "b" * 40) + for merge_request_sha, commit_sha in ( + ("B" * 40, "b" * 40), + ("bad", "b" * 40), + ("", "B" * 40), + ("0" * 40, "bad"), + ): + with self.subTest(merge_request_sha=merge_request_sha, commit_sha=commit_sha): + with patched_env( + CI_MERGE_REQUEST_SOURCE_BRANCH_SHA=merge_request_sha, + CI_COMMIT_SHA=commit_sha, + ): + self.assertEqual(workflow.reviewed_sha(), "") + def test_coverage_diagnostics_are_deduplicated_redacted_and_fail_closed(self) -> None: """Count unique files while keeping malformed failure paths out of public notes.""" @@ -2467,6 +2502,134 @@ def capture_note( self.assertNotIn("rules.json", notes[0][1]) self.assertEqual(setup_cleanup, [[9]]) + def test_pre_execution_status_uses_effective_detached_pipeline_sha(self) -> None: + """Use the same strict SHA fallback for setup and background statuses.""" + + for reason, actual, limit, unit, expected_text in ( + (PROTECTED_TARGET_RULE_PATH_PENDING, None, None, None, "did not run"), + ( + BACKGROUND_CHARACTER_LIMIT_REASON, + 8_001, + 8_000, + "characters", + "rejected the generated review background", + ), + ): + for merge_request_sha in ("", "0" * 40): + with ( + self.subTest(reason=reason, merge_request_sha=merge_request_sha), + tempfile.TemporaryDirectory() as tmp, + ): + root = Path(tmp) + directory = root / ".review-context" + directory.mkdir(mode=0o700) + status_path = directory / "pre-execution-status.json" + write_pre_execution_status( + status_path, + PreExecutionStatus( + schema_version=STATUS_SCHEMA, + reason=reason, + diff_base_sha="a" * 40, + source_sha="b" * 40, + policy_sha="c" * 40, + actual=actual, + limit=limit, + unit=unit, + ), + ) + stderr_path = root / "stderr.log" + stderr_path.write_text("private details\n", encoding="utf-8") + notes: list[str] = [] + + def capture_note( + _config: gitlab.GitLabConfig, + _title: str, + body: str, + _transaction: PostingTransaction, + ) -> dict[str, int]: + notes.append(body) + return {"id": 1} + + with ( + patched_env( + CI_MERGE_REQUEST_DIFF_BASE_SHA="a" * 40, + CI_MERGE_REQUEST_SOURCE_BRANCH_SHA=merge_request_sha, + CI_COMMIT_SHA="b" * 40, + ), + patched_attr( + workflow, + "repository_artifacts", + lambda: type("Artifacts", (), {"pre_execution_status": status_path})(), + ), + patched_attr(workflow, "post_review_note_bounded", capture_note), + patched_attr(workflow, "finalize_posting", lambda *_args: True), + patched_attr( + workflow, + "collect_previous_bot_comment_refs", + lambda *_args: snapshot.BotCommentRefs(), + ), + patched_attr(workflow, "delete_previous_setup_notes", lambda *_args: None), + ): + self.assertEqual( + workflow.post_ocr_failure(gitlab_config(), stderr_path, 2), 0 + ) + + self.assertEqual(len(notes), 1) + self.assertIn(expected_text, notes[0]) + self.assertNotIn("private details", notes[0]) + + def test_malformed_mr_sha_does_not_fall_back_for_pre_execution_status(self) -> None: + """Do not hide a hostile populated MR identity behind CI_COMMIT_SHA.""" + + notes: list[str] = [] + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + directory = root / ".review-context" + directory.mkdir(mode=0o700) + status_path = directory / "pre-execution-status.json" + write_pre_execution_status( + status_path, + PreExecutionStatus( + schema_version=STATUS_SCHEMA, + reason=PROTECTED_TARGET_RULE_PATH_PENDING, + diff_base_sha="a" * 40, + source_sha="b" * 40, + policy_sha="c" * 40, + ), + ) + stderr_path = root / "stderr.log" + stderr_path.write_text("generic details\n", encoding="utf-8") + + def capture_note( + _config: gitlab.GitLabConfig, + _title: str, + body: str, + _transaction: PostingTransaction, + ) -> dict[str, int]: + notes.append(body) + return {"id": 1} + + with ( + patched_env( + CI_MERGE_REQUEST_DIFF_BASE_SHA="a" * 40, + CI_MERGE_REQUEST_SOURCE_BRANCH_SHA="B" * 40, + CI_COMMIT_SHA="b" * 40, + OCR_POST_ERROR_DETAILS="1", + ), + patched_attr( + workflow, + "repository_artifacts", + lambda: type("Artifacts", (), {"pre_execution_status": status_path})(), + ), + patched_attr(workflow, "post_review_note_bounded", capture_note), + patched_attr(workflow, "finalize_posting", lambda *_args: True), + ): + self.assertEqual(workflow.post_ocr_failure(gitlab_config(), stderr_path, 2), 0) + + self.assertEqual(len(notes), 1) + self.assertIn("result may be partial", notes[0]) + self.assertIn("generic details", notes[0]) + def test_stale_setup_status_falls_back_to_generic_failure(self) -> None: notes: list[str] = [] with tempfile.TemporaryDirectory() as tmp: From 836eb408207e5530ae3e4955aee0e2278813b70f Mon Sep 17 00:00:00 2001 From: xeonvs <11463419+xeonvs@users.noreply.github.com> Date: Wed, 2 Sep 2026 09:08:38 +0200 Subject: [PATCH 03/15] fix: enforce evidence action integrity --- PLANS.md | 6 +- src/ocr_toolkit/posting/approval.py | 413 ++------------------------ src/ocr_toolkit/review_receipt.py | 433 ++++++++++++++++++++++++++++ src/ocr_toolkit/review_runner.py | 45 ++- tests/test_evidence_mcp.py | 25 ++ tests/test_review_runner.py | 150 ++++++++-- 6 files changed, 623 insertions(+), 449 deletions(-) create mode 100644 src/ocr_toolkit/review_receipt.py diff --git a/PLANS.md b/PLANS.md index c3182d7..0343bb3 100644 --- a/PLANS.md +++ b/PLANS.md @@ -63,7 +63,11 @@ attribution, detached-pipeline diagnostics, and OCR 1.11.2 compatibility. do not use closing keywords because the stable Release workflow owns issue closure. 3. [x] Implement #167 as a focused signed identity/status commit with its synthetic regression matrix. -4. [ ] Implement #169 as a focused signed receipt/action-integrity commit. +4. [x] Implement #169 as a focused signed receipt/action-integrity commit. Positive evidence + usage now requires exact private action attribution before atomic publication, zero-call + results receive verified zero counts, and the shared neutral receipt owner validates every + generated MR receipt before any advisory can be attached. Security regressions cover missing, + malformed, unwritable, incomplete, type-confused, and per-tool-mismatched attribution. 5. [ ] Implement #168 provider/config/snapshot, receipt v7, and approval boundary as one complete runtime commit. 6. [ ] Implement #168 summary projection, complete public documentation, and synthetic examples diff --git a/src/ocr_toolkit/posting/approval.py b/src/ocr_toolkit/posting/approval.py index b798905..5e5a0c2 100644 --- a/src/ocr_toolkit/posting/approval.py +++ b/src/ocr_toolkit/posting/approval.py @@ -6,21 +6,32 @@ from enum import Enum from typing import Any -from ocr_toolkit.evidence.actions import EVIDENCE_ACTIONS -from ocr_toolkit.evidence.mcp import COVERAGE_TOOL_NAME, SEARCH_TOOL_NAME, TOOL_NAME -from ocr_toolkit.ocr_result import ( - MAX_TOOLKIT_MCP_TOOL_NAME_CHARS, - MAX_TOOLKIT_MCP_TOOLS_PER_SERVER, - MAX_TOOLKIT_MCP_USAGE_COUNT, - MAX_TOOLKIT_MCP_USAGE_SERVERS, - TOOLKIT_MCP_SERVER_NAME_RE, -) from ocr_toolkit.posting.settings import BooleanSetting -from ocr_toolkit.result_contract import OcrResultContractError, ReviewOutcome +from ocr_toolkit.result_contract import ReviewOutcome +from ocr_toolkit.review_receipt import ( + INVALID_APPROVAL_RECEIPT_REASON, + automatic_approval_metadata_reason, + publication_dlp_state, + publication_outcome_for_summary, + toolkit_receipt_is_valid, +) + +__all__ = [ + "INVALID_APPROVAL_RECEIPT_REASON", + "ApprovalEligibility", + "ApprovalResult", + "ApprovalStatus", + "approval_summary_line", + "automatic_approval_metadata_reason", + "evaluate_approval_policy", + "provisional_approval_result", + "publication_dlp_state", + "publication_outcome_for_summary", + "toolkit_receipt_is_valid", +] ALLOWED_CATEGORIES = frozenset({"style", "documentation", "maintainability"}) MAX_APPROVABLE_FINDINGS = 3 -INVALID_APPROVAL_RECEIPT_REASON = "the review-time approval receipt is missing or invalid" class ApprovalStatus(str, Enum): @@ -139,383 +150,3 @@ def provisional_approval_result(eligibility: ApprovalEligibility) -> ApprovalRes ApprovalStatus.FAILED, "automatic approval has not yet been confirmed", ) - - -def _full_sha(value: Any) -> bool: - return ( - isinstance(value, str) - and len(value) == 40 - and all(char in "0123456789abcdef" for char in value) - ) - - -def _positive_id(value: Any) -> bool: - return isinstance(value, int) and not isinstance(value, bool) and value > 0 - - -def _sha256(value: Any) -> bool: - return ( - isinstance(value, str) - and len(value) == 64 - and all(char in "0123456789abcdef" for char in value) - ) - - -def publication_dlp_state(value: Any) -> str | None: - """Validate the exact v6 publication-policy receipt.""" - - if value == {"state": "passed"}: - return "passed" - if not isinstance(value, dict): - return None - if value.get("state") == "private-sanitized": - if set(value) != {"state", "reason_counts", "sanitized_fields"}: - return None - reason_counts = value.get("reason_counts") - sanitized_fields = value.get("sanitized_fields") - if ( - not _valid_dlp_reason_counts(reason_counts) - or not any(reason_counts.values()) - or not isinstance(sanitized_fields, int) - or isinstance(sanitized_fields, bool) - or not 0 < sanitized_fields <= MAX_TOOLKIT_MCP_USAGE_COUNT - ): - return None - return "private-sanitized" - if not isinstance(value, dict) or set(value) != { - "state", - "reason_counts", - "retained", - "omitted", - "original", - }: - return None - if value.get("state") != "publication-filtered": - return None - reason_counts = value.get("reason_counts") - retained = value.get("retained") - omitted = value.get("omitted") - original = value.get("original") - if ( - not _valid_dlp_reason_counts(reason_counts) - or not any(reason_counts.values()) - or not isinstance(retained, dict) - or set(retained) != {"comments", "warnings"} - or not isinstance(omitted, dict) - or set(omitted) != {"comments", "warnings", "fields"} - or any( - not isinstance(count, int) - or isinstance(count, bool) - or not 0 <= count <= MAX_TOOLKIT_MCP_USAGE_COUNT - for counts in (retained, omitted) - for count in counts.values() - ) - or not isinstance(original, dict) - or set(original) != {"outcome", "selected", "completed", "reused", "failed", "waived"} - or original.get("outcome") not in {"clean", "warning", "partial", "failed", "skipped"} - or any( - not isinstance(original.get(field), int) - or isinstance(original.get(field), bool) - or not 0 <= original[field] <= MAX_TOOLKIT_MCP_USAGE_COUNT - for field in ("selected", "completed", "reused", "failed", "waived") - ) - ): - return None - selected = original["selected"] - completed = original["completed"] - reused = original["reused"] - failed = original["failed"] - waived = original["waived"] - outcome = original["outcome"] - derived_outcomes = {"failed"} | ( - {"skipped"} - if selected == 0 - else {"clean", "warning"} - if failed == 0 - else {"failed"} - if failed == selected - else {"partial"} - ) - if selected != completed + reused + failed + waived or outcome not in derived_outcomes: - return None - return "publication-filtered" - - -def _valid_dlp_reason_counts(value: Any) -> bool: - return bool( - isinstance(value, dict) - and set(value) == {"forbidden", "invalid_text", "laundering", "limit", "pii", "secret"} - and all( - isinstance(count, int) - and not isinstance(count, bool) - and 0 <= count <= MAX_TOOLKIT_MCP_USAGE_COUNT - for count in value.values() - ) - ) - - -def automatic_approval_metadata_reason(toolkit_metadata: Any) -> str: - """Return the closed review-time receipt blocker for automatic approval.""" - - invalid = INVALID_APPROVAL_RECEIPT_REASON - if not isinstance(toolkit_metadata, dict): - return invalid - if toolkit_metadata.get("schema_version") != 6 or set(toolkit_metadata) != { - "schema_version", - "review", - "context", - "mcp", - "evidence", - "publication", - "cleanup", - }: - return invalid - - review = toolkit_metadata.get("review") - if not isinstance(review, dict) or set(review) != {"source_sha", "policy_sha", "mr_author_id"}: - return invalid - if not _full_sha(review.get("source_sha")) or not _full_sha(review.get("policy_sha")): - return invalid - if not _positive_id(review.get("mr_author_id")): - return invalid - - context = toolkit_metadata.get("context") - if not isinstance(context, dict) or set(context) != { - "mode", - "state", - "classes", - "policy_digest", - "per_source", - "degradation_counts", - "required_degraded", - "mutable_admitted", - "tool_usage", - }: - return invalid - mode = context.get("mode") - state = context.get("state") - classes = context.get("classes") - expected_classes = { - "off": [], - "metadata": ["merge_request_metadata"], - "enriched": ["merge_request_metadata", "forge_discussions", "external_records"], - }.get(mode) - policy_digest = context.get("policy_digest") - per_source = context.get("per_source") - degradation = context.get("degradation_counts") - required_degraded = context.get("required_degraded") - mutable_admitted = context.get("mutable_admitted") - context_usage = context.get("tool_usage") - if ( - mode not in {"off", "metadata", "enriched"} - or state not in {"disabled", "complete", "degraded"} - or classes != expected_classes - or (mode == "off" and state != "disabled") - or (mode in {"metadata", "enriched"} and state == "disabled") - or (mode == "enriched") != _sha256(policy_digest) - or not isinstance(per_source, dict) - or len(per_source) > 64 - or any( - not isinstance(source, str) - or not source - or len(source) > 256 - or source_state not in {"complete", "partial", "unavailable", "mutated"} - for source, source_state in per_source.items() - ) - or not isinstance(degradation, dict) - or set(degradation) != {"invalid", "limit", "unavailable"} - or any( - not isinstance(count, int) or isinstance(count, bool) or not 0 <= count <= 1_000_000 - for count in degradation.values() - ) - or not isinstance(required_degraded, bool) - or not isinstance(mutable_admitted, bool) - or not isinstance(context_usage, dict) - or set(context_usage) != {"context_get", "context_list"} - or any( - not isinstance(count, int) - or isinstance(count, bool) - or not 0 <= count <= MAX_TOOLKIT_MCP_USAGE_COUNT - for count in context_usage.values() - ) - ): - return invalid - if mode != "enriched" and ( - policy_digest is not None - or per_source - or any(degradation.values()) - or required_degraded - or mutable_admitted - or any(context_usage.values()) - ): - return invalid - - mcp = toolkit_metadata.get("mcp") - if not isinstance(mcp, dict) or set(mcp) != {"capabilities", "usage"}: - return invalid - capabilities = mcp.get("capabilities") - usage = mcp.get("usage") - if ( - not isinstance(capabilities, list) - or not 1 <= len(capabilities) <= MAX_TOOLKIT_MCP_USAGE_SERVERS - or not isinstance(usage, dict) - or len(usage) > MAX_TOOLKIT_MCP_USAGE_SERVERS - ): - return invalid - servers: set[str] = set() - tool_owners: set[str] = set() - external = False - builtin_server = TOOL_NAME - builtin_tools = [TOOL_NAME, SEARCH_TOOL_NAME, COVERAGE_TOOL_NAME] - for capability in capabilities: - if not isinstance(capability, dict) or set(capability) != {"server", "transport", "tools"}: - return invalid - server = capability.get("server") - transport = capability.get("transport") - tools = capability.get("tools") - if ( - not isinstance(server, str) - or TOOLKIT_MCP_SERVER_NAME_RE.fullmatch(server) is None - or server in servers - or transport not in {"builtin", "stdio", "remote"} - or not isinstance(tools, list) - or not 1 <= len(tools) <= MAX_TOOLKIT_MCP_TOOLS_PER_SERVER - or any( - not isinstance(tool, str) or not tool or len(tool) > MAX_TOOLKIT_MCP_TOOL_NAME_CHARS - for tool in tools - ) - or len(set(tools)) != len(tools) - or any(tool in tool_owners for tool in tools) - or (server == builtin_server) != (transport == "builtin") - or ( - server == builtin_server - and tools - != ( - [*builtin_tools, "context_list", "context_get"] - if mode == "enriched" - else builtin_tools - ) - ) - ): - return invalid - servers.add(server) - tool_owners.update(tools) - external = external or server != builtin_server - if builtin_server not in servers: - return invalid - if any( - not isinstance(server, str) - or server not in servers - or not isinstance(count, int) - or isinstance(count, bool) - or not 0 < count <= MAX_TOOLKIT_MCP_USAGE_COUNT - for server, count in usage.items() - ): - return invalid - - evidence = toolkit_metadata.get("evidence") - if not isinstance(evidence, dict) or set(evidence) != { - "mandatory", - "used", - "calls", - "actions", - }: - return invalid - mandatory = evidence.get("mandatory") - used = evidence.get("used") - evidence_calls = evidence.get("calls") - evidence_actions = evidence.get("actions") - evidence_called = isinstance(evidence_calls, int) and evidence_calls > 0 - if ( - not isinstance(mandatory, bool) - or not isinstance(used, bool) - or not isinstance(evidence_calls, int) - or isinstance(evidence_calls, bool) - or not 0 <= evidence_calls <= MAX_TOOLKIT_MCP_USAGE_COUNT - or used is not evidence_called - or (mandatory and not used) - or usage.get(builtin_server, 0) != evidence_calls + sum(context_usage.values()) - or not _valid_evidence_actions(evidence_actions, evidence_calls) - ): - return invalid - publication = toolkit_metadata.get("publication") - cleanup = toolkit_metadata.get("cleanup") - publication_state = publication_dlp_state(publication) - if publication_state is None or cleanup != {"result": "passed"}: - return invalid - if publication_state == "publication-filtered": - return "publication DLP filtered the complete review result" - if context.get("state") == "degraded" or required_degraded: - return "the selected review context was degraded" - if mutable_admitted: - return "mutable review context was admitted" - if external: - return "external MCP was configured for a comment-only review" - return "" - - -def toolkit_receipt_is_valid(toolkit_metadata: Any) -> bool: - """Return whether metadata is an exact receipt v6, including valid blockers.""" - - return automatic_approval_metadata_reason(toolkit_metadata) != INVALID_APPROVAL_RECEIPT_REASON - - -def publication_outcome_for_summary(outcome: ReviewOutcome, publication: Any) -> ReviewOutcome: - """Recover only validated original coverage facts from a filtered receipt.""" - - if publication_dlp_state(publication) != "publication-filtered": - return outcome - if outcome.kind != "partial" or outcome.manifest_present: - raise OcrResultContractError( - "publication-filtered receipt is not bound to a safe result projection" - ) - original = publication["original"] - kind = original["outcome"] - if outcome.budget_exceeded and kind != "partial": - raise OcrResultContractError( - "publication-filtered receipt contradicts the result budget state" - ) - counts = { - field: original[field] for field in ("selected", "completed", "reused", "failed", "waived") - } - manifest_present = any(counts.values()) - status = { - "clean": "complete" if manifest_present else "success", - "warning": "completed_with_warnings", - "partial": "budget_exceeded" if outcome.budget_exceeded else "completed_with_errors", - "failed": "failed", - "skipped": "skipped", - }[kind] - return ReviewOutcome( - status=status, - kind=kind, - budget_exceeded=outcome.budget_exceeded and kind == "partial", - manifest_present=manifest_present, - selected_count=counts["selected"], - completed_count=counts["completed"], - reused_count=counts["reused"], - failed_count=counts["failed"], - waived_count=counts["waived"], - ) - - -def _valid_evidence_actions(value: Any, evidence_calls: Any) -> bool: - """Validate verified counts or an explicit unavailable attribution state.""" - - if value == {"state": "unavailable"}: - return evidence_calls == 0 - expected = {"state", *EVIDENCE_ACTIONS} - if not isinstance(value, dict) or set(value) != expected: - return False - counts = [value.get(action) for action in EVIDENCE_ACTIONS] - return bool( - value.get("state") == "verified" - and all( - isinstance(count, int) - and not isinstance(count, bool) - and 0 <= count <= MAX_TOOLKIT_MCP_USAGE_COUNT - for count in counts - ) - and sum(counts) == evidence_calls - and (evidence_calls == 0 or value.get("summary", 0) >= 1) - ) diff --git a/src/ocr_toolkit/review_receipt.py b/src/ocr_toolkit/review_receipt.py new file mode 100644 index 0000000..17e0273 --- /dev/null +++ b/src/ocr_toolkit/review_receipt.py @@ -0,0 +1,433 @@ +"""Construct and hostile-validate closed toolkit review receipts.""" + +from __future__ import annotations + +from typing import Any + +from ocr_toolkit.evidence.actions import EVIDENCE_ACTIONS +from ocr_toolkit.evidence.mcp import COVERAGE_TOOL_NAME, SEARCH_TOOL_NAME, TOOL_NAME +from ocr_toolkit.ocr_result import ( + MAX_TOOLKIT_MCP_TOOL_NAME_CHARS, + MAX_TOOLKIT_MCP_TOOLS_PER_SERVER, + MAX_TOOLKIT_MCP_USAGE_COUNT, + MAX_TOOLKIT_MCP_USAGE_SERVERS, + TOOLKIT_MCP_SERVER_NAME_RE, +) +from ocr_toolkit.result_contract import OcrResultContractError, ReviewOutcome + +INVALID_APPROVAL_RECEIPT_REASON = "the review-time approval receipt is missing or invalid" + + +def verified_evidence_actions( + evidence_by_tool: dict[str, int], + action_counts: dict[str, int] | None, + *, + mandatory: bool, +) -> dict[str, object]: + """Build exact action attribution or reject a positive incomplete receipt.""" + + evidence_calls = sum(evidence_by_tool.values()) + if action_counts is None: + if evidence_calls: + raise ValueError("evidence action attribution is unavailable") + action_counts = dict.fromkeys(EVIDENCE_ACTIONS, 0) + if set(action_counts) != set(EVIDENCE_ACTIONS) or any( + not isinstance(count, int) + or isinstance(count, bool) + or not 0 <= count <= MAX_TOOLKIT_MCP_USAGE_COUNT + for count in action_counts.values() + ): + raise ValueError("evidence action attribution is unavailable") + if mandatory and action_counts["summary"] < 1: + raise ValueError("OCR review did not call the mandatory evidence summary action") + if ( + sum(action_counts[action] for action in ("summary", "list", "get")) + != evidence_by_tool[TOOL_NAME] + or action_counts["search"] != evidence_by_tool[SEARCH_TOOL_NAME] + or action_counts["coverage"] != evidence_by_tool[COVERAGE_TOOL_NAME] + ): + raise ValueError("evidence action attribution does not match OCR tool usage") + return { + "state": "verified", + **{action: action_counts[action] for action in EVIDENCE_ACTIONS}, + } + + +def _full_sha(value: Any) -> bool: + return ( + isinstance(value, str) + and len(value) == 40 + and all(char in "0123456789abcdef" for char in value) + ) + + +def _positive_id(value: Any) -> bool: + return isinstance(value, int) and not isinstance(value, bool) and value > 0 + + +def _sha256(value: Any) -> bool: + return ( + isinstance(value, str) + and len(value) == 64 + and all(char in "0123456789abcdef" for char in value) + ) + + +def publication_dlp_state(value: Any) -> str | None: + """Validate the exact v6 publication-policy receipt.""" + + if value == {"state": "passed"}: + return "passed" + if not isinstance(value, dict): + return None + if value.get("state") == "private-sanitized": + if set(value) != {"state", "reason_counts", "sanitized_fields"}: + return None + reason_counts = value.get("reason_counts") + sanitized_fields = value.get("sanitized_fields") + if ( + not _valid_dlp_reason_counts(reason_counts) + or not any(reason_counts.values()) + or not isinstance(sanitized_fields, int) + or isinstance(sanitized_fields, bool) + or not 0 < sanitized_fields <= MAX_TOOLKIT_MCP_USAGE_COUNT + ): + return None + return "private-sanitized" + if not isinstance(value, dict) or set(value) != { + "state", + "reason_counts", + "retained", + "omitted", + "original", + }: + return None + if value.get("state") != "publication-filtered": + return None + reason_counts = value.get("reason_counts") + retained = value.get("retained") + omitted = value.get("omitted") + original = value.get("original") + if ( + not _valid_dlp_reason_counts(reason_counts) + or not any(reason_counts.values()) + or not isinstance(retained, dict) + or set(retained) != {"comments", "warnings"} + or not isinstance(omitted, dict) + or set(omitted) != {"comments", "warnings", "fields"} + or any( + not isinstance(count, int) + or isinstance(count, bool) + or not 0 <= count <= MAX_TOOLKIT_MCP_USAGE_COUNT + for counts in (retained, omitted) + for count in counts.values() + ) + or not isinstance(original, dict) + or set(original) != {"outcome", "selected", "completed", "reused", "failed", "waived"} + or original.get("outcome") not in {"clean", "warning", "partial", "failed", "skipped"} + or any( + not isinstance(original.get(field), int) + or isinstance(original.get(field), bool) + or not 0 <= original[field] <= MAX_TOOLKIT_MCP_USAGE_COUNT + for field in ("selected", "completed", "reused", "failed", "waived") + ) + ): + return None + selected = original["selected"] + completed = original["completed"] + reused = original["reused"] + failed = original["failed"] + waived = original["waived"] + outcome = original["outcome"] + derived_outcomes = {"failed"} | ( + {"skipped"} + if selected == 0 + else {"clean", "warning"} + if failed == 0 + else {"failed"} + if failed == selected + else {"partial"} + ) + if selected != completed + reused + failed + waived or outcome not in derived_outcomes: + return None + return "publication-filtered" + + +def _valid_dlp_reason_counts(value: Any) -> bool: + return bool( + isinstance(value, dict) + and set(value) == {"forbidden", "invalid_text", "laundering", "limit", "pii", "secret"} + and all( + isinstance(count, int) + and not isinstance(count, bool) + and 0 <= count <= MAX_TOOLKIT_MCP_USAGE_COUNT + for count in value.values() + ) + ) + + +def automatic_approval_metadata_reason(toolkit_metadata: Any) -> str: + """Return the closed review-time receipt blocker for automatic approval.""" + + invalid = INVALID_APPROVAL_RECEIPT_REASON + if not isinstance(toolkit_metadata, dict): + return invalid + if toolkit_metadata.get("schema_version") != 6 or set(toolkit_metadata) != { + "schema_version", + "review", + "context", + "mcp", + "evidence", + "publication", + "cleanup", + }: + return invalid + + review = toolkit_metadata.get("review") + if not isinstance(review, dict) or set(review) != {"source_sha", "policy_sha", "mr_author_id"}: + return invalid + if not _full_sha(review.get("source_sha")) or not _full_sha(review.get("policy_sha")): + return invalid + if not _positive_id(review.get("mr_author_id")): + return invalid + + context = toolkit_metadata.get("context") + if not isinstance(context, dict) or set(context) != { + "mode", + "state", + "classes", + "policy_digest", + "per_source", + "degradation_counts", + "required_degraded", + "mutable_admitted", + "tool_usage", + }: + return invalid + mode = context.get("mode") + state = context.get("state") + classes = context.get("classes") + expected_classes = { + "off": [], + "metadata": ["merge_request_metadata"], + "enriched": ["merge_request_metadata", "forge_discussions", "external_records"], + }.get(mode) + policy_digest = context.get("policy_digest") + per_source = context.get("per_source") + degradation = context.get("degradation_counts") + required_degraded = context.get("required_degraded") + mutable_admitted = context.get("mutable_admitted") + context_usage = context.get("tool_usage") + if ( + mode not in {"off", "metadata", "enriched"} + or state not in {"disabled", "complete", "degraded"} + or classes != expected_classes + or (mode == "off" and state != "disabled") + or (mode in {"metadata", "enriched"} and state == "disabled") + or (mode == "enriched") != _sha256(policy_digest) + or not isinstance(per_source, dict) + or len(per_source) > 64 + or any( + not isinstance(source, str) + or not source + or len(source) > 256 + or source_state not in {"complete", "partial", "unavailable", "mutated"} + for source, source_state in per_source.items() + ) + or not isinstance(degradation, dict) + or set(degradation) != {"invalid", "limit", "unavailable"} + or any( + not isinstance(count, int) or isinstance(count, bool) or not 0 <= count <= 1_000_000 + for count in degradation.values() + ) + or not isinstance(required_degraded, bool) + or not isinstance(mutable_admitted, bool) + or not isinstance(context_usage, dict) + or set(context_usage) != {"context_get", "context_list"} + or any( + not isinstance(count, int) + or isinstance(count, bool) + or not 0 <= count <= MAX_TOOLKIT_MCP_USAGE_COUNT + for count in context_usage.values() + ) + ): + return invalid + if mode != "enriched" and ( + policy_digest is not None + or per_source + or any(degradation.values()) + or required_degraded + or mutable_admitted + or any(context_usage.values()) + ): + return invalid + + mcp = toolkit_metadata.get("mcp") + if not isinstance(mcp, dict) or set(mcp) != {"capabilities", "usage"}: + return invalid + capabilities = mcp.get("capabilities") + usage = mcp.get("usage") + if ( + not isinstance(capabilities, list) + or not 1 <= len(capabilities) <= MAX_TOOLKIT_MCP_USAGE_SERVERS + or not isinstance(usage, dict) + or len(usage) > MAX_TOOLKIT_MCP_USAGE_SERVERS + ): + return invalid + servers: set[str] = set() + tool_owners: set[str] = set() + external = False + builtin_server = TOOL_NAME + builtin_tools = [TOOL_NAME, SEARCH_TOOL_NAME, COVERAGE_TOOL_NAME] + for capability in capabilities: + if not isinstance(capability, dict) or set(capability) != {"server", "transport", "tools"}: + return invalid + server = capability.get("server") + transport = capability.get("transport") + tools = capability.get("tools") + if ( + not isinstance(server, str) + or TOOLKIT_MCP_SERVER_NAME_RE.fullmatch(server) is None + or server in servers + or transport not in {"builtin", "stdio", "remote"} + or not isinstance(tools, list) + or not 1 <= len(tools) <= MAX_TOOLKIT_MCP_TOOLS_PER_SERVER + or any( + not isinstance(tool, str) or not tool or len(tool) > MAX_TOOLKIT_MCP_TOOL_NAME_CHARS + for tool in tools + ) + or len(set(tools)) != len(tools) + or any(tool in tool_owners for tool in tools) + or (server == builtin_server) != (transport == "builtin") + or ( + server == builtin_server + and tools + != ( + [*builtin_tools, "context_list", "context_get"] + if mode == "enriched" + else builtin_tools + ) + ) + ): + return invalid + servers.add(server) + tool_owners.update(tools) + external = external or server != builtin_server + if builtin_server not in servers: + return invalid + if any( + not isinstance(server, str) + or server not in servers + or not isinstance(count, int) + or isinstance(count, bool) + or not 0 < count <= MAX_TOOLKIT_MCP_USAGE_COUNT + for server, count in usage.items() + ): + return invalid + + evidence = toolkit_metadata.get("evidence") + if not isinstance(evidence, dict) or set(evidence) != { + "mandatory", + "used", + "calls", + "actions", + }: + return invalid + mandatory = evidence.get("mandatory") + used = evidence.get("used") + evidence_calls = evidence.get("calls") + evidence_actions = evidence.get("actions") + evidence_called = isinstance(evidence_calls, int) and evidence_calls > 0 + if ( + not isinstance(mandatory, bool) + or not isinstance(used, bool) + or not isinstance(evidence_calls, int) + or isinstance(evidence_calls, bool) + or not 0 <= evidence_calls <= MAX_TOOLKIT_MCP_USAGE_COUNT + or used is not evidence_called + or (mandatory and not used) + or usage.get(builtin_server, 0) != evidence_calls + sum(context_usage.values()) + or not _valid_evidence_actions(evidence_actions, evidence_calls) + ): + return invalid + publication = toolkit_metadata.get("publication") + cleanup = toolkit_metadata.get("cleanup") + publication_state = publication_dlp_state(publication) + if publication_state is None or cleanup != {"result": "passed"}: + return invalid + if publication_state == "publication-filtered": + return "publication DLP filtered the complete review result" + if context.get("state") == "degraded" or required_degraded: + return "the selected review context was degraded" + if mutable_admitted: + return "mutable review context was admitted" + if external: + return "external MCP was configured for a comment-only review" + return "" + + +def toolkit_receipt_is_valid(toolkit_metadata: Any) -> bool: + """Return whether metadata is an exact receipt v6, including valid blockers.""" + + return automatic_approval_metadata_reason(toolkit_metadata) != INVALID_APPROVAL_RECEIPT_REASON + + +def publication_outcome_for_summary(outcome: ReviewOutcome, publication: Any) -> ReviewOutcome: + """Recover only validated original coverage facts from a filtered receipt.""" + + if publication_dlp_state(publication) != "publication-filtered": + return outcome + if outcome.kind != "partial" or outcome.manifest_present: + raise OcrResultContractError( + "publication-filtered receipt is not bound to a safe result projection" + ) + original = publication["original"] + kind = original["outcome"] + if outcome.budget_exceeded and kind != "partial": + raise OcrResultContractError( + "publication-filtered receipt contradicts the result budget state" + ) + counts = { + field: original[field] for field in ("selected", "completed", "reused", "failed", "waived") + } + manifest_present = any(counts.values()) + status = { + "clean": "complete" if manifest_present else "success", + "warning": "completed_with_warnings", + "partial": "budget_exceeded" if outcome.budget_exceeded else "completed_with_errors", + "failed": "failed", + "skipped": "skipped", + }[kind] + return ReviewOutcome( + status=status, + kind=kind, + budget_exceeded=outcome.budget_exceeded and kind == "partial", + manifest_present=manifest_present, + selected_count=counts["selected"], + completed_count=counts["completed"], + reused_count=counts["reused"], + failed_count=counts["failed"], + waived_count=counts["waived"], + ) + + +def _valid_evidence_actions(value: Any, evidence_calls: Any) -> bool: + """Validate verified counts or an explicit unavailable attribution state.""" + + if value == {"state": "unavailable"}: + return evidence_calls == 0 + expected = {"state", *EVIDENCE_ACTIONS} + if not isinstance(value, dict) or set(value) != expected: + return False + counts = [value.get(action) for action in EVIDENCE_ACTIONS] + return bool( + value.get("state") == "verified" + and all( + isinstance(count, int) + and not isinstance(count, bool) + and 0 <= count <= MAX_TOOLKIT_MCP_USAGE_COUNT + for count in counts + ) + and sum(counts) == evidence_calls + and (evidence_calls == 0 or value.get("summary", 0) >= 1) + ) diff --git a/src/ocr_toolkit/review_runner.py b/src/ocr_toolkit/review_runner.py index af28921..2908d7b 100644 --- a/src/ocr_toolkit/review_runner.py +++ b/src/ocr_toolkit/review_runner.py @@ -54,7 +54,7 @@ ContextStoreError, PendingContextRecord, ) -from ocr_toolkit.evidence.actions import EVIDENCE_ACTIONS, read_action_receipt +from ocr_toolkit.evidence.actions import read_action_receipt from ocr_toolkit.evidence.artifacts import ( EvidenceArtifacts, prepare_artifact_directory, @@ -126,6 +126,7 @@ from ocr_toolkit.providers.gitlab_discussions import acquire_gitlab_context from ocr_toolkit.result_contract import OcrResultContractError, parse_result_outcome from ocr_toolkit.result_usage import normalize_token_usage, token_usage_mapping +from ocr_toolkit.review_receipt import toolkit_receipt_is_valid, verified_evidence_actions STDERR_PROBE_BYTES = 64 * 1024 BACKGROUND_PREVIEW_STDERR_BYTES = 64 * 1024 @@ -402,32 +403,14 @@ def _review_receipt( evidence_used = isinstance(evidence_calls, int) and evidence_calls > 0 if outcome.requires_evidence_mcp and not evidence_by_tool[TOOL_NAME]: raise ReviewRunnerError(f"OCR review did not call the mandatory {TOOL_NAME} tool") - if ( - outcome.requires_evidence_mcp - and evidence_action_counts is not None - and evidence_action_counts.get("summary", 0) < 1 - ): - raise ReviewRunnerError("OCR review did not call the mandatory evidence summary action") - action_attribution: dict[str, object] - if evidence_calls == 0 and evidence_action_counts is None: - action_attribution = { - "state": "verified", - **dict.fromkeys(EVIDENCE_ACTIONS, 0), - } - elif ( - evidence_action_counts is not None - and set(evidence_action_counts) == set(EVIDENCE_ACTIONS) - and sum(evidence_action_counts[action] for action in ("summary", "list", "get")) - == evidence_by_tool[TOOL_NAME] - and evidence_action_counts["search"] == evidence_by_tool[SEARCH_TOOL_NAME] - and evidence_action_counts["coverage"] == evidence_by_tool[COVERAGE_TOOL_NAME] - ): - action_attribution = { - "state": "verified", - **{action: evidence_action_counts[action] for action in EVIDENCE_ACTIONS}, - } - else: - action_attribution = {"state": "unavailable"} + try: + action_attribution = verified_evidence_actions( + evidence_by_tool, + evidence_action_counts, + mandatory=outcome.requires_evidence_mcp, + ) + except ValueError as exc: + raise ReviewRunnerError(str(exc)) from exc capabilities = [ { "server": capability.server, @@ -1053,6 +1036,8 @@ def finalize(payload: dict[str, object]) -> dict[str, object]: ) metadata["publication"] = publication metadata["schema_version"] = TOOLKIT_RESULT_SCHEMA_VERSION + if identity.mr_author_id is not None and not toolkit_receipt_is_valid(metadata): + raise ReviewRunnerError("toolkit generated an invalid review receipt") mcp = metadata.get("mcp") raw_usage = mcp.get("usage") if isinstance(mcp, dict) else None usage = dict(raw_usage) if isinstance(raw_usage, dict) else {} @@ -1065,6 +1050,12 @@ def finalize(payload: dict[str, object]) -> dict[str, object]: transform_ocr_result(result_path, finalize) except (OcrResultMalformed, OcrResultMissing, OcrResultTooLarge) as exc: raise ReviewRunnerError("OCR result is not valid bounded JSON") from exc + except ReviewRunnerError: + try: + result_path.unlink(missing_ok=True) + except OSError: + pass + raise return usage, filtered, publication diff --git a/tests/test_evidence_mcp.py b/tests/test_evidence_mcp.py index 112d072..366f501 100644 --- a/tests/test_evidence_mcp.py +++ b/tests/test_evidence_mcp.py @@ -21,6 +21,7 @@ RefRole, TrustClass, ) +from ocr_toolkit.evidence import mcp as evidence_mcp from ocr_toolkit.evidence.actions import read_action_receipt from ocr_toolkit.evidence.mcp import ( COVERAGE_TOOL_NAME, @@ -823,6 +824,30 @@ def test_server_records_only_completed_model_time_evidence_actions(tmp_path: Pat } +@pytest.mark.parametrize("failure", [OSError("unwritable"), ValueError("malformed")]) +def test_server_action_receipt_failure_cannot_change_tool_result( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch, failure: Exception +) -> None: + """Keep model behavior stable while finalization later fails closed on attribution.""" + + monkeypatch.setattr( + evidence_mcp, "record_action", lambda *_args: (_ for _ in ()).throw(failure) + ) + result = handle_request( + _store(), + { + "jsonrpc": "2.0", + "id": 1, + "method": "tools/call", + "params": {"name": TOOL_NAME, "arguments": {"action": "summary"}}, + }, + action_receipt_path=tmp_path / "actions.json", + ) + + assert result and result["result"].get("isError", False) is False + assert not (tmp_path / "actions.json").exists() + + def test_notifications_never_receive_json_rpc_responses() -> None: """Honor the no-response contract for every request without an id.""" diff --git a/tests/test_review_runner.py b/tests/test_review_runner.py index 6cc4eca..aff0807 100644 --- a/tests/test_review_runner.py +++ b/tests/test_review_runner.py @@ -659,9 +659,10 @@ def test_ocr_result_requires_builtin_mcp_usage_for_completed_review(tmp_path: Pa external_servers=(), secret_values=(), ) - assert review_runner._record_ocr_result_mcp_usage(result, composition, DEFAULT_IDENTITY) == { - "ocr_toolkit_evidence": 2 - } + counts = {"summary": 1, "list": 1, "get": 0, "search": 0, "coverage": 0} + assert review_runner._record_ocr_result_mcp_usage( + result, composition, DEFAULT_IDENTITY, evidence_action_counts=counts + ) == {"ocr_toolkit_evidence": 2} assert json.loads(result.read_text(encoding="utf-8"))["_ocr_toolkit"] == { "schema_version": 6, "review": {"source_sha": "a" * 40, "policy_sha": "b" * 40, "mr_author_id": None}, @@ -690,7 +691,7 @@ def test_ocr_result_requires_builtin_mcp_usage_for_completed_review(tmp_path: Pa "mandatory": True, "used": True, "calls": 2, - "actions": {"state": "unavailable"}, + "actions": {"state": "verified", **counts}, }, "publication": {"state": "passed"}, "cleanup": {"result": "passed"}, @@ -704,7 +705,7 @@ def test_ocr_result_requires_builtin_mcp_usage_for_completed_review(tmp_path: Pa review_runner._record_ocr_result_mcp_usage(result, composition, DEFAULT_IDENTITY) -def test_evidence_action_attribution_is_verified_only_on_exact_reconciliation( +def test_evidence_action_attribution_fails_before_publication_without_exact_reconciliation( tmp_path: Path, ) -> None: composition = MCPComposition( @@ -714,37 +715,104 @@ def test_evidence_action_attribution_is_verified_only_on_exact_reconciliation( secret_values=(), ) cases = ( + ("mismatch", {"summary": 1, "list": 0, "get": 0, "search": 0, "coverage": 0}), + ("missing", None), + ("incomplete", {"summary": 1}), ( - {"summary": 1, "list": 1, "get": 0, "search": 0, "coverage": 0}, - "verified", + "type-confused", + {"summary": True, "list": 1, "get": 0, "search": 0, "coverage": 0}, ), - ( - {"summary": 1, "list": 0, "get": 0, "search": 0, "coverage": 0}, - "unavailable", - ), - (None, "unavailable"), ) - for index, (counts, expected) in enumerate(cases): - result = tmp_path / f"case-{index}.json" + for name, counts in cases: + result = tmp_path / f"case-{name}.json" + raw = json.dumps( + { + "status": "success", + "tool_calls": {"total": 2, "by_tool": {"ocr_toolkit_evidence": 2}}, + } + ) + result.write_text(raw, encoding="utf-8") + with pytest.raises(review_runner.ReviewRunnerError, match="action attribution"): + review_runner._record_ocr_result_mcp_usage( + result, + composition, + DEFAULT_IDENTITY, + evidence_action_counts=counts, + ) + assert not result.exists() + + +def test_evidence_action_attribution_failure_is_advisory_independent(tmp_path: Path) -> None: + """Discover receipt failure before an optional toolkit advisory can be attached.""" + + composition = MCPComposition( + payload={}, + capabilities=(MCPCapability("ocr_toolkit_evidence", BUILTIN_EVIDENCE_TOOLS, True),), + external_servers=(), + secret_values=(), + ) + for advisory in ( + None, + ocr_result.background_recommended_advisory(actual=2_100, recommended=2_000), + ): + result = tmp_path / f"result-{advisory is not None}.json" result.write_text( json.dumps( { "status": "success", - "tool_calls": {"total": 2, "by_tool": {"ocr_toolkit_evidence": 2}}, + "tool_calls": {"total": 1, "by_tool": {"ocr_toolkit_evidence": 1}}, } ), encoding="utf-8", ) - review_runner._record_ocr_result_mcp_usage( + + with pytest.raises(review_runner.ReviewRunnerError, match="action attribution"): + review_runner._finalize_ocr_result( + result, + composition, + replace(DEFAULT_IDENTITY, mr_author_id=41), + None, + forbidden=(), + toolkit_advisory=advisory, + ) + + assert not result.exists() + + +def test_generated_mr_receipt_is_validated_before_atomic_publication( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + """Do not let a producer-side receipt regression become a posting-time surprise.""" + + result = tmp_path / "result.json" + result.write_text( + json.dumps( + { + "status": "success", + "tool_calls": {"total": 1, "by_tool": {"ocr_toolkit_evidence": 1}}, + } + ), + encoding="utf-8", + ) + composition = MCPComposition( + payload={}, + capabilities=(MCPCapability("ocr_toolkit_evidence", BUILTIN_EVIDENCE_TOOLS, True),), + external_servers=(), + secret_values=(), + ) + monkeypatch.setattr(review_runner, "toolkit_receipt_is_valid", lambda _receipt: False) + + with pytest.raises(review_runner.ReviewRunnerError, match="invalid review receipt"): + review_runner._finalize_ocr_result( result, composition, - DEFAULT_IDENTITY, - evidence_action_counts=counts, + replace(DEFAULT_IDENTITY, mr_author_id=41), + None, + SUMMARY_ACTION_COUNTS, + forbidden=(), ) - actions = json.loads(result.read_text(encoding="utf-8"))["_ocr_toolkit"]["evidence"][ - "actions" - ] - assert actions["state"] == expected + + assert not result.exists() def test_evidence_action_receipt_reconciles_each_fixed_tool_independently( @@ -858,6 +926,13 @@ def test_ocr_result_receipt_blocks_approval_when_mr_context_was_admitted( result, composition, review_runner.ReviewIdentity("a" * 40, "b" * 40, 41, "metadata", None), + evidence_action_counts={ + "summary": 1, + "list": 0, + "get": 0, + "search": 0, + "coverage": 0, + }, ) assert json.loads(result.read_text(encoding="utf-8"))["_ocr_toolkit"] == { @@ -888,7 +963,14 @@ def test_ocr_result_receipt_blocks_approval_when_mr_context_was_admitted( "mandatory": True, "used": True, "calls": 1, - "actions": {"state": "unavailable"}, + "actions": { + "state": "verified", + "summary": 1, + "list": 0, + "get": 0, + "search": 0, + "coverage": 0, + }, }, "publication": {"state": "passed"}, "cleanup": {"result": "passed"}, @@ -2305,9 +2387,9 @@ def test_ocr_result_manifest_complete_requires_builtin_mcp_usage(tmp_path: Path) secret_values=(), ) - assert review_runner._record_ocr_result_mcp_usage(result, composition, DEFAULT_IDENTITY) == { - "ocr_toolkit_evidence": 1 - } + assert review_runner._record_ocr_result_mcp_usage( + result, composition, DEFAULT_IDENTITY, evidence_action_counts=SUMMARY_ACTION_COUNTS + ) == {"ocr_toolkit_evidence": 1} @pytest.mark.parametrize( @@ -2403,7 +2485,12 @@ def test_ocr_result_receipt_attributes_independent_mcp_servers(tmp_path: Path) - secret_values=(), ) - assert review_runner._record_ocr_result_mcp_usage(result, composition, DEFAULT_IDENTITY) == { + assert review_runner._record_ocr_result_mcp_usage( + result, + composition, + DEFAULT_IDENTITY, + evidence_action_counts={"summary": 1, "list": 1, "get": 0, "search": 0, "coverage": 0}, + ) == { "documentation": 5, "ocr_toolkit_evidence": 2, } @@ -2470,9 +2557,12 @@ def test_budget_limited_result_preserves_verified_mcp_usage(tmp_path: Path) -> N secret_values=(), ) - assert review_runner._record_ocr_result_mcp_usage(result, composition, DEFAULT_IDENTITY) == { - "ocr_toolkit_evidence": 2 - } + assert review_runner._record_ocr_result_mcp_usage( + result, + composition, + DEFAULT_IDENTITY, + evidence_action_counts={"summary": 1, "list": 1, "get": 0, "search": 0, "coverage": 0}, + ) == {"ocr_toolkit_evidence": 2} persisted = json.loads(result.read_text(encoding="utf-8")) assert persisted["summary"] == {"budget_exceeded": True, "total_tokens": 321} assert persisted["comments"] == [{"path": "example.py", "line": 7}] From 35d9cd8fe43a605ce2f16a342c7a65755031b96b Mon Sep 17 00:00:00 2001 From: xeonvs <11463419+xeonvs@users.noreply.github.com> Date: Wed, 2 Sep 2026 09:51:09 +0200 Subject: [PATCH 04/15] feat: constrain unprotected target reviews --- PLANS.md | 8 +- src/ocr_toolkit/evidence/collect.py | 20 +- src/ocr_toolkit/evidence/project.py | 16 ++ src/ocr_toolkit/mcp_config.py | 19 +- src/ocr_toolkit/ocr_result.py | 2 +- src/ocr_toolkit/posting/formatting.py | 9 +- src/ocr_toolkit/posting/workflow.py | 53 ++--- src/ocr_toolkit/providers/gitlab.py | 31 ++- src/ocr_toolkit/review_receipt.py | 82 ++++++- src/ocr_toolkit/review_runner.py | 60 ++++- tests/test_evidence_repository.py | 44 ++++ tests/test_gitlab_provider.py | 201 ++++++++++++++++- tests/test_posting_approval.py | 310 +++++++++++++++++++++++--- tests/test_posting_helpers.py | 42 ++-- tests/test_review_runner.py | 147 ++++++++++-- tests/test_runtime_helpers.py | 72 ++++++ 16 files changed, 990 insertions(+), 126 deletions(-) diff --git a/PLANS.md b/PLANS.md index 0343bb3..1ef0817 100644 --- a/PLANS.md +++ b/PLANS.md @@ -68,8 +68,12 @@ attribution, detached-pipeline diagnostics, and OCR 1.11.2 compatibility. results receive verified zero counts, and the shared neutral receipt owner validates every generated MR receipt before any advisory can be attached. Security regressions cover missing, malformed, unwritable, incomplete, type-confused, and per-tool-mismatched attribution. -5. [ ] Implement #168 provider/config/snapshot, receipt v7, and approval boundary as one complete - runtime commit. +5. [x] Implement #168 provider/config/snapshot, receipt v7, and approval boundary as one complete + runtime commit. The exact closed setting preserves protected behavior, while an actually + unprotected target rejects privileged context and external MCP, omits structured target policy, + binds source/target/protection in receipt v7, renders the validated limitation, and cannot reach + the approval executor. Hostile, orchestration, DLP-summary, and immutable-target regressions + cover these boundaries. 6. [ ] Implement #168 summary projection, complete public documentation, and synthetic examples as one complete documentation contract commit. 7. [ ] Implement #170 qualification, OCR 1.11.2 pins, Rules probes, and `maintenance` plus `rules` diff --git a/src/ocr_toolkit/evidence/collect.py b/src/ocr_toolkit/evidence/collect.py index 1fd23b9..a7c05dd 100644 --- a/src/ocr_toolkit/evidence/collect.py +++ b/src/ocr_toolkit/evidence/collect.py @@ -50,8 +50,9 @@ def collect_repository_evidence( base_ref: str | None = None, head_ref: str | None = None, policy_ref: str | None = None, + include_policy_records: bool = True, ) -> EvidenceStore: - """Build one evidence store from immutable refs and existing bounded collectors.""" + """Build evidence, optionally omitting target-derived policy authority records.""" reader = GitRepositoryReader(root or Path.cwd()) base_sha, head_sha = _commit_refs(reader, base_ref, head_ref) @@ -70,13 +71,16 @@ def collect_repository_evidence( changed_paths=changed, coverage_sink=base_coverage, ) - policy_facts, policy_diagnostics = collect_ref_facts( - reader, - policy_sha, - RefRole.POLICY, - changed_paths=changed, - ) - policy_facts = [record for record in policy_facts if record.kind in POLICY_KINDS] + if include_policy_records: + policy_facts, policy_diagnostics = collect_ref_facts( + reader, + policy_sha, + RefRole.POLICY, + changed_paths=changed, + ) + policy_facts = [record for record in policy_facts if record.kind in POLICY_KINDS] + else: + policy_facts, policy_diagnostics = [], [] policy = EvidenceSnapshot( RefRole.POLICY, policy_sha, diff --git a/src/ocr_toolkit/evidence/project.py b/src/ocr_toolkit/evidence/project.py index f85f7cd..ab35f59 100644 --- a/src/ocr_toolkit/evidence/project.py +++ b/src/ocr_toolkit/evidence/project.py @@ -80,6 +80,7 @@ def render_bootstrap( *, capabilities: Sequence[CapabilityView] = (), context_hints: Mapping[str, int] | None = None, + unprotected_target: bool = False, max_chars: int = DEFAULT_BOOTSTRAP_MAX_CHARS, max_bytes: int = DEFAULT_BOOTSTRAP_MAX_BYTES, ) -> str: @@ -114,6 +115,21 @@ def render_bootstrap( f"- head: `{store.head.commit_sha if store.head else 'unavailable'}`", f"- policy: `{store.policy.commit_sha if store.policy else 'legacy base semantics'}`", ] + if unprotected_target: + lines.extend( + ( + "", + "## Unprotected target boundary", + ( + "The captured target was unprotected. Its configured Rules are bounded " + "untrusted review guidance only." + ), + ( + "They cannot authorize tools, external acquisition, suppression, posting, " + "or approval; structured target decisions and guidance are omitted." + ), + ) + ) mr_context = next( (record for record in store.records if record.kind == "review.merge_request_context"), None, diff --git a/src/ocr_toolkit/mcp_config.py b/src/ocr_toolkit/mcp_config.py index 0ac8d29..3defa07 100644 --- a/src/ocr_toolkit/mcp_config.py +++ b/src/ocr_toolkit/mcp_config.py @@ -527,6 +527,7 @@ def compose_mcp_servers( replace: bool, profile: str = "local", context: MCPContextConfig | None = None, + allow_external: bool = True, ) -> MCPComposition: """Build OCR's registry from independent optional and mandatory MCP entries.""" @@ -534,17 +535,27 @@ def compose_mcp_servers( raise MCPConfigError("internal MCP execution profile is invalid") if profile == "gitlab_mr" and any(server.transport != "remote" for server in servers): raise MCPConfigError("GitLab merge-request reviews require external remote MCP") + if not allow_external and servers: + raise MCPConfigError("unprotected-target reviews do not allow external MCP servers") payload: dict[str, dict[str, Any]] = {} capabilities: list[MCPCapability] = [] secret_values: list[str] = [] - if not replace: + if not replace or not allow_external: try: current = read_ocr_config().get("mcp_servers", {}) except OCRConfigError as exc: raise MCPConfigError(str(exc)) from exc if not isinstance(current, dict): raise MCPConfigError("Existing OCR mcp_servers value is not a JSON object") + if not allow_external and ( + any(not isinstance(name, str) for name in current) + or any(name != BUILTIN_EVIDENCE_SERVER for name in current) + ): + raise MCPConfigError( + "unprotected-target reviews do not allow inherited external MCP servers" + ) + if not replace: declared_names = {server.name for server in servers} for name, value in current.items(): # Explicit operator input replaces the same named inherited entry; @@ -632,7 +643,10 @@ def compose_mcp_servers( def build_mcp_composition( - *, profile: str = "local", context: MCPContextConfig | None = None + *, + profile: str = "local", + context: MCPContextConfig | None = None, + allow_external: bool = True, ) -> MCPComposition: """Parse environment settings into the complete profiled MCP composition.""" @@ -641,6 +655,7 @@ def build_mcp_composition( replace=_replace_configured_servers(), profile=profile, context=context, + allow_external=allow_external, ) diff --git a/src/ocr_toolkit/ocr_result.py b/src/ocr_toolkit/ocr_result.py index 545fe9b..67f08ba 100644 --- a/src/ocr_toolkit/ocr_result.py +++ b/src/ocr_toolkit/ocr_result.py @@ -19,7 +19,7 @@ DEFAULT_MAX_RESULT_BYTES = 2_000_000 MAX_RESULT_BYTES_HARD_LIMIT = 20_000_000 TOOLKIT_RESULT_KEY = "_ocr_toolkit" -TOOLKIT_RESULT_SCHEMA_VERSION = 6 +TOOLKIT_RESULT_SCHEMA_VERSION = 7 SUPPORTED_TOOLKIT_RESULT_SCHEMA_VERSIONS = frozenset({TOOLKIT_RESULT_SCHEMA_VERSION}) TOOLKIT_ADVISORY_KEY = "_ocr_toolkit_advisory" TOOLKIT_ADVISORY_SCHEMA_VERSION = "ocr.toolkit-advisory/v1" diff --git a/src/ocr_toolkit/posting/formatting.py b/src/ocr_toolkit/posting/formatting.py index df97cd7..e487e42 100644 --- a/src/ocr_toolkit/posting/formatting.py +++ b/src/ocr_toolkit/posting/formatting.py @@ -109,6 +109,10 @@ "low": "green", } SHIELDS_CATEGORY_COLOR = "blue" +UNPROTECTED_TARGET_LIMITATION = ( + "*The target branch was not protected in GitLab. This review ran in limited, " + "comment-only mode.*" +) def inline_code(value: str) -> str: @@ -546,7 +550,7 @@ def format_mcp_usage_summary(toolkit_metadata: Any) -> str: def publication_dlp_signal( publication: Any, *, carried_forward_comments: int = 0 ) -> dict[str, Any] | None: - """Return one low-cardinality signal from an exact v6 DLP receipt.""" + """Return one low-cardinality signal from an exact v7 DLP receipt.""" state = publication_dlp_state(publication) if ( @@ -974,6 +978,7 @@ def summarize_result( warnings: Sequence[Any] = (), suppressed_count: int = 0, approval_result: ApprovalResult | None = None, + unprotected_target: bool = False, emoji: bool | None = None, ) -> str: """Build one decision-first summary for every validated OCR outcome.""" @@ -991,6 +996,8 @@ def summarize_result( emoji=use_emoji, ) lines = ["## Open Code Review", "", outcome_line] + if unprotected_target: + lines.append(UNPROTECTED_TARGET_LIMITATION) if outcome_status == "failed": lines.extend( [ diff --git a/src/ocr_toolkit/posting/workflow.py b/src/ocr_toolkit/posting/workflow.py index 35a3c2c..1cf1b97 100644 --- a/src/ocr_toolkit/posting/workflow.py +++ b/src/ocr_toolkit/posting/workflow.py @@ -110,6 +110,7 @@ ) from ocr_toolkit.result_contract import OcrResultContractError, ReviewOutcome, parse_result_outcome from ocr_toolkit.review_identity import effective_reviewed_sha +from ocr_toolkit.review_receipt import receipt_review_identity, validated_review_identity # Kept as a module-level compatibility seam for tests and external monkey-patching. post_review_note = gitlab_api.post_review_note @@ -158,25 +159,17 @@ def mr_head_sha() -> str: def approval_receipt_identity(toolkit_metadata: Any) -> tuple[str, int | None]: """Return only validated-by-policy receipt identities for provider readback.""" - if ( - not isinstance(toolkit_metadata, dict) - or toolkit_metadata.get("schema_version") != TOOLKIT_RESULT_SCHEMA_VERSION - ): - return "", None - review = toolkit_metadata.get("review") - if not isinstance(review, dict): - return "", None - source_sha = review.get("source_sha") - author_id = review.get("mr_author_id") - if not ( - isinstance(source_sha, str) - and re.fullmatch(r"[0-9a-f]{40}", source_sha) - and isinstance(author_id, int) - and not isinstance(author_id, bool) - and author_id > 0 - ): + identity = receipt_review_identity(toolkit_metadata) + if identity is None: return "", None - return source_sha, author_id + return identity.source_sha, identity.mr_author_id + + +def unprotected_target_limitation(toolkit_metadata: Any) -> bool: + """Select the static limitation only from one fully validated receipt v7.""" + + identity = validated_review_identity(toolkit_metadata) + return identity is not None and identity.target_protection == "unprotected" def summary_with_run_marker(body: str, run_id: str) -> str: @@ -255,11 +248,15 @@ def finalize_review_approval( if not finalize_posting(config, transaction): return publish_failure_exit(config, transaction) - execution = execute_approval( - config, - eligibility, - reviewed_commit, - reviewed_author_id, + execution = ( + execute_approval( + config, + eligibility, + reviewed_commit, + reviewed_author_id, + ) + if eligibility.eligible + else ApprovalExecution(eligibility.result) ) final_body = summary_with_run_marker( render_summary(execution.result), @@ -615,6 +612,7 @@ def post_results(config: GitLabConfig, result: dict[str, Any]) -> int: return 1 toolkit_metadata = result.get(TOOLKIT_RESULT_KEY) + constrained_target = unprotected_target_limitation(toolkit_metadata) publication = ( toolkit_metadata.get("publication") if isinstance(toolkit_metadata, dict) else None ) @@ -630,7 +628,7 @@ def post_results(config: GitLabConfig, result: dict[str, Any]) -> int: if publication_state is None: return invalid_ocr_schema_exit( config, - "receipt v6 publication state is invalid", + "receipt v7 publication state is invalid", intro="OCR result publication policy state could not be validated.", title="**Open Code Review publication policy error**", ) @@ -644,7 +642,7 @@ def post_results(config: GitLabConfig, result: dict[str, Any]) -> int: if not toolkit_receipt_is_valid(toolkit_metadata): return invalid_ocr_schema_exit( config, - "OCR toolkit advisory is not bound to a valid receipt v6", + "OCR toolkit advisory is not bound to a valid receipt v7", ) ocr_core_advisory_summary = format_ocr_core_advisory(advisory) @@ -693,6 +691,7 @@ def post_results(config: GitLabConfig, result: dict[str, Any]) -> int: mcp_usage_summary=mcp_usage_summary, token_usage_summary=token_usage_summary, ocr_core_advisory_summary=ocr_core_advisory_summary, + unprotected_target=constrained_target, ) billing_reason = llm_billing_failure_reason(warnings) @@ -806,6 +805,7 @@ def render_no_comments_summary(approval_result: ApprovalResult) -> str: warnings=warnings, suppressed_count=suppressed_count, approval_result=approval_result, + unprotected_target=constrained_target, emoji=emoji, ) @@ -994,6 +994,7 @@ def render_findings_summary(approval_result: ApprovalResult) -> str: warnings=warnings, suppressed_count=suppressed_count, approval_result=approval_result, + unprotected_target=constrained_target, emoji=emoji, ) @@ -1103,6 +1104,7 @@ def post_manifest_failure( mcp_usage_summary: str = "", token_usage_summary: str = "", ocr_core_advisory_summary: str = "", + unprotected_target: bool = False, ) -> int: """Post a manifest-declared run failure while preserving prior review notes.""" @@ -1121,6 +1123,7 @@ def post_manifest_failure( coverage_summary=outcome.coverage_summary, coverage_diagnostics=normalize_coverage_diagnostics(outcome, warnings), warnings=warnings, + unprotected_target=unprotected_target, emoji=post_emoji(), ) diff --git a/src/ocr_toolkit/providers/gitlab.py b/src/ocr_toolkit/providers/gitlab.py index b5e063f..39ac7c5 100644 --- a/src/ocr_toolkit/providers/gitlab.py +++ b/src/ocr_toolkit/providers/gitlab.py @@ -10,7 +10,7 @@ import urllib.request from collections.abc import Mapping from dataclasses import dataclass -from typing import Any +from typing import Any, Literal from ocr_toolkit.evidence.invocation import MAX_CI_IDENTIFIER_CHARS, InvocationIdentifier from ocr_toolkit.evidence.review_context import ( @@ -28,6 +28,9 @@ MAX_PROVIDER_BODY_BYTES = 2_000_000 PROVIDER_READ_CHUNK_BYTES = 64 * 1024 PROVIDER_TIMEOUT_SECONDS = 30 +TARGET_PROTECTION_MODE_VARIABLE = "OCR_GITLAB_TARGET_PROTECTION_MODE" +TargetProtectionMode = Literal["required", "unprotected"] +TargetProtectionState = Literal["protected", "unprotected"] class GitLabProviderError(ValueError): @@ -36,13 +39,14 @@ class GitLabProviderError(ValueError): @dataclass(frozen=True, slots=True) class GitLabReviewSnapshot: - """Bind one reviewed source head to the current protected target commit.""" + """Bind one reviewed source head to the current target commit and protection state.""" project_id: str merge_request_iid: str source_sha: str target_branch: str target_sha: str + target_protection: TargetProtectionState author_id: int context: MergeRequestContext | None @@ -208,6 +212,20 @@ def _positive_identifier(value: object, label: str) -> int: return value +def parse_target_protection_mode(raw: str | None) -> TargetProtectionMode: + """Parse the exact fail-closed target-protection policy setting.""" + + if raw is None: + return "required" + if raw == "required": + return "required" + if raw == "unprotected": + return "unprotected" + raise GitLabProviderError( + f"{TARGET_PROTECTION_MODE_VARIABLE} must be exactly 'required' or 'unprotected'" + ) + + def _branch(value: object) -> str: """Return one bounded safe target branch name for endpoint construction.""" @@ -227,6 +245,7 @@ def acquire_review_snapshot( """Acquire and cross-check one MR plus protected-target branch snapshot.""" expected_head = _sha(expected_head, "reviewed source head") + protection_mode = parse_target_protection_mode(environment.get(TARGET_PROTECTION_MODE_VARIABLE)) project_id = _numeric_identifier(environment, "CI_PROJECT_ID") merge_request_iid = _numeric_identifier(environment, "CI_MERGE_REQUEST_IID") token = environment.get("GITLAB_API_TOKEN", "").strip() @@ -273,18 +292,22 @@ def acquire_review_snapshot( ) if not isinstance(branch, dict): raise GitLabProviderError("GitLab target-branch metadata must be an object") - if branch.get("name") != target_branch or branch.get("protected") is not True: + protected = branch.get("protected") + if branch.get("name") != target_branch or not isinstance(protected, bool): + raise GitLabProviderError("GitLab target branch protection metadata is invalid") + if not protected and protection_mode != "unprotected": raise GitLabProviderError("GitLab target branch is not the captured protected branch") commit = branch.get("commit") if not isinstance(commit, dict): raise GitLabProviderError("GitLab target branch has no commit identity") - target_sha = _sha(commit.get("id"), "protected target head") + target_sha = _sha(commit.get("id"), "target head") return GitLabReviewSnapshot( project_id=project_id, merge_request_iid=merge_request_iid, source_sha=source_sha, target_branch=target_branch, target_sha=target_sha, + target_protection="protected" if protected else "unprotected", author_id=author_id, context=context, ) diff --git a/src/ocr_toolkit/review_receipt.py b/src/ocr_toolkit/review_receipt.py index 17e0273..bb7d7fa 100644 --- a/src/ocr_toolkit/review_receipt.py +++ b/src/ocr_toolkit/review_receipt.py @@ -2,6 +2,7 @@ from __future__ import annotations +from dataclasses import dataclass from typing import Any from ocr_toolkit.evidence.actions import EVIDENCE_ACTIONS @@ -16,6 +17,19 @@ from ocr_toolkit.result_contract import OcrResultContractError, ReviewOutcome INVALID_APPROVAL_RECEIPT_REASON = "the review-time approval receipt is missing or invalid" +UNPROTECTED_APPROVAL_REASON = ( + "the GitLab target branch was unprotected; limited reviews are comment-only" +) + + +@dataclass(frozen=True, slots=True) +class ReceiptReviewIdentity: + """Carry immutable provider facts from one fully validated receipt.""" + + source_sha: str + target_sha: str + target_protection: str + mr_author_id: int def verified_evidence_actions( @@ -74,7 +88,7 @@ def _sha256(value: Any) -> bool: def publication_dlp_state(value: Any) -> str | None: - """Validate the exact v6 publication-policy receipt.""" + """Validate the exact v7 publication-policy receipt.""" if value == {"state": "passed"}: return "passed" @@ -172,7 +186,7 @@ def automatic_approval_metadata_reason(toolkit_metadata: Any) -> str: invalid = INVALID_APPROVAL_RECEIPT_REASON if not isinstance(toolkit_metadata, dict): return invalid - if toolkit_metadata.get("schema_version") != 6 or set(toolkit_metadata) != { + if toolkit_metadata.get("schema_version") != 7 or set(toolkit_metadata) != { "schema_version", "review", "context", @@ -184,9 +198,22 @@ def automatic_approval_metadata_reason(toolkit_metadata: Any) -> str: return invalid review = toolkit_metadata.get("review") - if not isinstance(review, dict) or set(review) != {"source_sha", "policy_sha", "mr_author_id"}: + if not isinstance(review, dict) or set(review) != { + "source_sha", + "policy_sha", + "target_sha", + "target_protection", + "mr_author_id", + }: return invalid - if not _full_sha(review.get("source_sha")) or not _full_sha(review.get("policy_sha")): + if ( + not _full_sha(review.get("source_sha")) + or not _full_sha(review.get("policy_sha")) + or not _full_sha(review.get("target_sha")) + or review.get("policy_sha") != review.get("target_sha") + or not isinstance(review.get("target_protection"), str) + or review.get("target_protection") not in {"protected", "unprotected"} + ): return invalid if not _positive_id(review.get("mr_author_id")): return invalid @@ -355,6 +382,10 @@ def automatic_approval_metadata_reason(toolkit_metadata: Any) -> str: publication_state = publication_dlp_state(publication) if publication_state is None or cleanup != {"result": "passed"}: return invalid + if review.get("target_protection") == "unprotected": + if mode not in {"off", "metadata"} or external: + return invalid + return UNPROTECTED_APPROVAL_REASON if publication_state == "publication-filtered": return "publication DLP filtered the complete review result" if context.get("state") == "degraded" or required_degraded: @@ -367,11 +398,52 @@ def automatic_approval_metadata_reason(toolkit_metadata: Any) -> str: def toolkit_receipt_is_valid(toolkit_metadata: Any) -> bool: - """Return whether metadata is an exact receipt v6, including valid blockers.""" + """Return whether metadata is an exact receipt v7, including valid blockers.""" return automatic_approval_metadata_reason(toolkit_metadata) != INVALID_APPROVAL_RECEIPT_REASON +def receipt_review_identity(toolkit_metadata: Any) -> ReceiptReviewIdentity | None: + """Parse the exact immutable identity section without granting approval authority.""" + + if not isinstance(toolkit_metadata, dict) or toolkit_metadata.get("schema_version") != 7: + return None + review = toolkit_metadata.get("review") + if ( + not isinstance(review, dict) + or set(review) + != { + "source_sha", + "policy_sha", + "target_sha", + "target_protection", + "mr_author_id", + } + or not _full_sha(review.get("source_sha")) + or not _full_sha(review.get("policy_sha")) + or not _full_sha(review.get("target_sha")) + or review.get("policy_sha") != review.get("target_sha") + or not isinstance(review.get("target_protection"), str) + or review.get("target_protection") not in {"protected", "unprotected"} + or not _positive_id(review.get("mr_author_id")) + ): + return None + return ReceiptReviewIdentity( + source_sha=review["source_sha"], + target_sha=review["target_sha"], + target_protection=review["target_protection"], + mr_author_id=review["mr_author_id"], + ) + + +def validated_review_identity(toolkit_metadata: Any) -> ReceiptReviewIdentity | None: + """Return immutable review facts only after complete hostile receipt validation.""" + + if not toolkit_receipt_is_valid(toolkit_metadata): + return None + return receipt_review_identity(toolkit_metadata) + + def publication_outcome_for_summary(outcome: ReviewOutcome, publication: Any) -> ReviewOutcome: """Recover only validated original coverage facts from a filtered receipt.""" diff --git a/src/ocr_toolkit/review_runner.py b/src/ocr_toolkit/review_runner.py index 2908d7b..72c911c 100644 --- a/src/ocr_toolkit/review_runner.py +++ b/src/ocr_toolkit/review_runner.py @@ -277,6 +277,8 @@ class ReviewIdentity: mr_author_id: int | None context_mode: str context: MergeRequestContext | None + target_sha: str + target_protection: str @property def context_state(self) -> str: @@ -458,6 +460,8 @@ def _review_receipt( "review": { "source_sha": identity.source_sha, "policy_sha": identity.policy_sha, + "target_sha": identity.target_sha, + "target_protection": identity.target_protection, "mr_author_id": identity.mr_author_id, }, "context": context_receipt, @@ -911,7 +915,7 @@ def _publication_projection( forbidden: tuple[str, ...], allowed_tools: frozenset[str], ) -> tuple[dict[str, object], dict[str, object], bool]: - """Return a DLP-safe result plus one exact v6 publication state.""" + """Return a DLP-safe result plus one exact v7 publication state.""" budgets = TextBudgets(max_chars=2_000_000, max_bytes=8_000_000, max_lines=100_000) matcher = ForbiddenMatcher.compile(forbidden) @@ -1501,6 +1505,7 @@ def _prepare_policy_context( reader = GitRepositoryReader(Path.cwd()) context = None author_id = None + target_protection = "local" if is_merge_request_environment(os.environ): snapshot = acquire_review_snapshot( os.environ, @@ -1511,27 +1516,53 @@ def _prepare_policy_context( policy_sha = reader.resolve_commit(snapshot.target_sha) context = snapshot.context author_id = snapshot.author_id + target_sha = policy_sha + target_protection = snapshot.target_protection + if target_protection == "unprotected": + if context_mode == "enriched": + raise ReviewRunnerError( + "enriched review context requires a protected GitLab target branch" + ) + if os.environ.get("OCR_REVIEW_CONTEXT_ADAPTERS_JSON") is not None: + raise ReviewRunnerError("unprotected-target reviews do not allow context adapters") else: if context_mode in {"metadata", "enriched"}: raise ReviewRunnerError( f"{context_mode} review context requires a GitLab merge request" ) policy_sha = refs.base - identity = ReviewIdentity(refs.head, policy_sha, author_id, context_mode, context) + target_sha = policy_sha + identity = ReviewIdentity( + refs.head, + policy_sha, + author_id, + context_mode, + context, + target_sha, + target_protection, + ) rule = _one_option(ocr_args, "--rule") if rule is None: + if target_protection == "unprotected": + raise ReviewRunnerError( + "unprotected-target reviews require repository rules from the captured target commit" + ) remove_private_artifact(artifacts.policy_rules) return identity, ocr_args repository_path = _repository_rule_path(rule, reader.root) if repository_path is None: + if target_protection == "unprotected": + raise ReviewRunnerError( + "unprotected-target reviews require repository rules from the captured target commit" + ) remove_private_artifact(artifacts.policy_rules) return identity, ocr_args try: content = reader.read_blob(policy_sha, repository_path) except RepositoryEvidenceError as exc: - raise ReviewRunnerError("protected-target OCR rule is unavailable or unsafe") from exc + raise ReviewRunnerError("target OCR rule is unavailable or unsafe") from exc if content is None: - if author_id is not None: + if author_id is not None and target_protection == "protected": try: setup_pending = _record_rules_path_setup( reader, @@ -1546,7 +1577,8 @@ def _prepare_policy_context( ) from exc if setup_pending: raise ReviewRunnerError("protected-target OCR rule path setup is pending") - raise ReviewRunnerError("protected-target OCR rule does not exist") + target_kind = "protected target" if target_protection == "protected" else "target" + raise ReviewRunnerError(f"{target_kind} OCR rule does not exist") policy_rules = artifacts.policy_rules write_private_bytes(policy_rules, content) return identity, _replace_rule_argument(ocr_args, rule, str(policy_rules)) @@ -1625,7 +1657,7 @@ def _bounded_combined_records( def _remediation_mutable_admitted(records: Sequence[ContextRecord]) -> bool: - """Report only admitted remediation as the receipt-v6 comment-only condition.""" + """Report only admitted remediation as the receipt-v7 comment-only condition.""" return any( record.mutable and record.resource_class == "remediation_thread" for record in records @@ -1666,6 +1698,15 @@ def _prepare_enrichment( ) -> tuple[mcp_config.MCPContextConfig | None, EnrichmentReceipt | None]: """Acquire all external context before the one OCR model loop and commit it locally.""" + if identity.target_protection == "unprotected": + if identity.context_mode == "enriched": + raise ReviewRunnerError( + "enriched review context requires a protected GitLab target branch" + ) + if os.environ.get("OCR_REVIEW_CONTEXT_ADAPTERS_JSON") is not None: + raise ReviewRunnerError("unprotected-target reviews do not allow context adapters") + remove_private_artifact(artifacts.context_store) + return None, None if identity.context_mode != "enriched": remove_private_artifact(artifacts.context_store) return None, None @@ -1975,7 +2016,10 @@ def run_evidence_review( identity, requested=preserve_private_artifacts ) store = collect_repository_evidence( - base_ref=refs.base, head_ref=refs.head, policy_ref=identity.policy_sha + base_ref=refs.base, + head_ref=refs.head, + policy_ref=identity.policy_sha, + include_policy_records=identity.target_protection != "unprotected", ) head_sha = store.head.commit_sha if store.head else "" identifiers = invocation_identifiers(os.environ) @@ -1994,11 +2038,13 @@ def run_evidence_review( composition = mcp_config.build_mcp_composition( profile="gitlab_mr" if identity.mr_author_id is not None else "local", context=context_config, + allow_external=identity.target_protection != "unprotected", ) bootstrap = render_bootstrap( store, capabilities=composition.capabilities, context_hints=enrichment.bootstrap_hints if enrichment is not None else None, + unprotected_target=identity.target_protection == "unprotected", ) write_private_text(artifacts.bootstrap, bootstrap) mcp_config.apply_mcp_composition(composition) diff --git a/tests/test_evidence_repository.py b/tests/test_evidence_repository.py index 8329866..3aaa64d 100644 --- a/tests/test_evidence_repository.py +++ b/tests/test_evidence_repository.py @@ -251,6 +251,50 @@ def test_collector_and_projections_keep_typed_facts_queryable( assert serialized == store.to_json() +def test_constrained_collection_omits_structured_target_policy_records( + tmp_path: Path, +) -> None: + """Keep immutable review evidence while removing target guidance and decisions.""" + + root = tmp_path / "repository" + root.mkdir() + git(root, "init", "-q") + (root / "AGENTS.md").write_text("Target guidance.\n", encoding="utf-8") + (root / ".opencodereview").mkdir() + (root / ".opencodereview/accepted-decisions.md").write_text( + "## Synthetic choice\n\nRationale: bounded choice.\n", + encoding="utf-8", + ) + (root / "app.py").write_text("VALUE = 1\n", encoding="utf-8") + git(root, "add", ".") + git(root, "commit", "-qm", "target") + target = git(root, "rev-parse", "HEAD") + (root / "app.py").write_text("VALUE = 2\n", encoding="utf-8") + git(root, "commit", "-qam", "source") + source = git(root, "rev-parse", "HEAD") + + store = collect_repository_evidence( + root, + base_ref=target, + head_ref=source, + policy_ref=target, + include_policy_records=False, + ) + + assert store.policy is not None and store.policy.commit_sha == target + assert store.policy.records == () + assert not any( + record.kind in {"repository.accepted_decision", "repository.guidance"} + for record in store.records + ) + assert any(record.kind == "repository.file" for record in store.records) + bootstrap = render_bootstrap(store, unprotected_target=True) + assert "Unprotected target boundary" in bootstrap + assert "bounded untrusted review guidance only" in bootstrap + assert "Applicable accepted decisions" not in bootstrap + assert "Applicable target guidance" not in bootstrap + + def test_collection_keeps_snapshots_atomic_when_store_rejects_records( tmp_path: Path, monkeypatch: pytest.MonkeyPatch ) -> None: diff --git a/tests/test_gitlab_provider.py b/tests/test_gitlab_provider.py index 5054254..c88cbd0 100644 --- a/tests/test_gitlab_provider.py +++ b/tests/test_gitlab_provider.py @@ -19,9 +19,11 @@ from ocr_toolkit.evidence.artifacts import prepare_artifact_directory, repository_artifacts from ocr_toolkit.evidence.repository import GitRepositoryReader, RepositoryEvidenceError +from ocr_toolkit.evidence.review_context import normalize_merge_request_context from ocr_toolkit.pre_execution import read_pre_execution_status from ocr_toolkit.providers import gitlab from ocr_toolkit.review_runner import ( + ReviewIdentity, ReviewRefs, ReviewRunnerError, _prepare_policy_context, @@ -112,6 +114,10 @@ def do_GET(self) -> None: } if self.response_mode == "unprotected": payload = {**payload, "protected": False} + if self.response_mode == "malformed_protection": + payload = {**payload, "protected": "false"} + if self.response_mode == "missing_target_commit": + payload = {**payload, "commit": {}} else: self.send_error(404) return @@ -214,6 +220,7 @@ def test_gitlab_snapshot_crosses_real_https_adapter_and_binds_protected_target( assert snapshot.source_sha == SOURCE_SHA assert snapshot.target_sha == TARGET_SHA + assert snapshot.target_protection == "protected" assert snapshot.target_branch == "main" assert snapshot.author_id == 41 assert snapshot.context.admitted is True @@ -227,6 +234,68 @@ def test_gitlab_snapshot_crosses_real_https_adapter_and_binds_protected_target( ] +@pytest.mark.parametrize( + "raw", + ("", " ", " REQUIRED", "required ", "Required", "UNPROTECTED", "optional", "required\n"), +) +def test_target_protection_mode_grammar_is_exact_and_fail_closed(raw: str) -> None: + with pytest.raises(gitlab.GitLabProviderError, match="must be exactly"): + gitlab.parse_target_protection_mode(raw) + + +def test_target_protection_mode_defaults_to_required() -> None: + assert gitlab.parse_target_protection_mode(None) == "required" + assert gitlab.parse_target_protection_mode("required") == "required" + assert gitlab.parse_target_protection_mode("unprotected") == "unprotected" + + +def test_explicit_unprotected_mode_binds_actual_unprotected_target_over_https( + tmp_path: Path, +) -> None: + environment: dict[str, str] + with _https_gitlab(tmp_path, "unprotected") as api_root: + environment = _environment(api_root) + environment[gitlab.TARGET_PROTECTION_MODE_VARIABLE] = "unprotected" + snapshot = gitlab.acquire_review_snapshot(environment, expected_head=SOURCE_SHA) + + assert snapshot.source_sha == SOURCE_SHA + assert snapshot.target_sha == TARGET_SHA + assert snapshot.target_protection == "unprotected" + + +@pytest.mark.parametrize("setting", (None, "required", "unprotected")) +def test_protected_target_behavior_is_unchanged_in_every_valid_mode( + tmp_path: Path, setting: str | None +) -> None: + with _https_gitlab(tmp_path / (setting or "unset")) as api_root: + environment = _environment(api_root) + if setting is not None: + environment[gitlab.TARGET_PROTECTION_MODE_VARIABLE] = setting + snapshot = gitlab.acquire_review_snapshot(environment, expected_head=SOURCE_SHA) + + assert snapshot.target_protection == "protected" + assert snapshot.target_sha == TARGET_SHA + + +@pytest.mark.parametrize( + ("mode", "message"), + ( + ("malformed_protection", "protection metadata"), + ("missing_target_commit", "target head"), + ), +) +def test_target_protection_and_identity_shapes_fail_closed_over_https( + tmp_path: Path, mode: str, message: str +) -> None: + with ( + _https_gitlab(tmp_path, mode) as api_root, + pytest.raises(gitlab.GitLabProviderError, match=message), + ): + environment = _environment(api_root) + environment[gitlab.TARGET_PROTECTION_MODE_VARIABLE] = "unprotected" + gitlab.acquire_review_snapshot(environment, expected_head=SOURCE_SHA) + + def test_identity_only_snapshot_never_normalizes_provider_metadata( tmp_path: Path, monkeypatch: pytest.MonkeyPatch ) -> None: @@ -393,6 +462,128 @@ def test_metadata_mode_requires_merge_request_before_ocr( _prepare_policy_context(ReviewRefs(sha, sha), [], artifacts) +@pytest.mark.parametrize("context_mode", ("off", "metadata")) +def test_unprotected_policy_context_reads_rules_only_from_exact_target( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch, context_mode: str +) -> None: + """Permit only bounded metadata and exact target Rules in constrained mode.""" + + _git(tmp_path, "init", "-q") + _git(tmp_path, "config", "user.name", "Synthetic") + _git(tmp_path, "config", "user.email", "synthetic@example.invalid") + rules = tmp_path / "rules.json" + rules.write_text('{"target":true}\n', encoding="utf-8") + (tmp_path / "app.py").write_text("VALUE = 1\n", encoding="utf-8") + _git(tmp_path, "add", ".") + _git(tmp_path, "commit", "-qm", "unprotected target") + target = _git(tmp_path, "rev-parse", "HEAD") + rules.write_text('{"source":true}\n', encoding="utf-8") + (tmp_path / "app.py").write_text("VALUE = 2\n", encoding="utf-8") + _git(tmp_path, "commit", "-qam", "source") + source = _git(tmp_path, "rev-parse", "HEAD") + monkeypatch.chdir(tmp_path) + monkeypatch.setenv("CI_MERGE_REQUEST_IID", "9") + monkeypatch.setenv("OCR_REVIEW_CONTEXT_MODE", context_mode) + context = ( + normalize_merge_request_context( + provider="gitlab", + project_id="7", + merge_request_iid="9", + source_sha=source, + title="Synthetic review", + description="Bounded metadata only.", + labels=[], + source_branch="feature/synthetic", + ) + if context_mode == "metadata" + else None + ) + monkeypatch.setattr( + "ocr_toolkit.review_runner.acquire_review_snapshot", + lambda *_args, **_kwargs: gitlab.GitLabReviewSnapshot( + project_id="7", + merge_request_iid="9", + source_sha=source, + target_branch="main", + target_sha=target, + target_protection="unprotected", + author_id=41, + context=context, + ), + ) + artifacts = repository_artifacts(tmp_path) + prepare_artifact_directory(artifacts) + + identity, arguments = _prepare_policy_context( + ReviewRefs(target, source), ["--rule", "rules.json"], artifacts + ) + + assert identity == ReviewIdentity( + source, + target, + 41, + context_mode, + context, + target, + "unprotected", + ) + assert artifacts.policy_rules.read_text(encoding="utf-8") == '{"target":true}\n' + assert arguments == ["--rule", str(artifacts.policy_rules)] + + +@pytest.mark.parametrize( + ("context_mode", "adapters", "message"), + ( + ("enriched", None, "enriched review context requires"), + ("off", "", "do not allow context adapters"), + ("metadata", "[]", "do not allow context adapters"), + ), +) +def test_unprotected_policy_context_rejects_privileged_context_before_rules_read( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, + context_mode: str, + adapters: str | None, + message: str, +) -> None: + """Reject even empty adapter settings and enriched mode before target policy reads.""" + + _git(tmp_path, "init", "-q") + _git(tmp_path, "config", "user.name", "Synthetic") + _git(tmp_path, "config", "user.email", "synthetic@example.invalid") + (tmp_path / "rules.json").write_text("{}\n", encoding="utf-8") + _git(tmp_path, "add", ".") + _git(tmp_path, "commit", "-qm", "target") + sha = _git(tmp_path, "rev-parse", "HEAD") + monkeypatch.chdir(tmp_path) + monkeypatch.setenv("CI_MERGE_REQUEST_IID", "9") + monkeypatch.setenv("OCR_REVIEW_CONTEXT_MODE", context_mode) + if adapters is None: + monkeypatch.delenv("OCR_REVIEW_CONTEXT_ADAPTERS_JSON", raising=False) + else: + monkeypatch.setenv("OCR_REVIEW_CONTEXT_ADAPTERS_JSON", adapters) + monkeypatch.setattr( + "ocr_toolkit.review_runner.acquire_review_snapshot", + lambda *_args, **_kwargs: gitlab.GitLabReviewSnapshot( + "7", "9", sha, "main", sha, "unprotected", 41, None + ), + ) + read_called = False + + def fail_read(*_args: object, **_kwargs: object) -> bytes: + nonlocal read_called + read_called = True + raise AssertionError("target policy must not be read") + + monkeypatch.setattr(GitRepositoryReader, "read_blob", fail_read) + artifacts = repository_artifacts(tmp_path) + prepare_artifact_directory(artifacts) + + with pytest.raises(ReviewRunnerError, match=message): + _prepare_policy_context(ReviewRefs(sha, sha), ["--rule", "rules.json"], artifacts) + assert read_called is False + + def test_policy_rule_transport_rejects_unsafe_or_unavailable_repository_inputs( tmp_path: Path, monkeypatch: pytest.MonkeyPatch ) -> None: @@ -790,8 +981,14 @@ def test_evidence_review_crosses_provider_git_store_mcp_and_subprocess_boundarie assert _git(checkout, "status", "--short") == "" payload = json.loads(result.read_text(encoding="utf-8")) assert payload["_ocr_toolkit"] == { - "schema_version": 6, - "review": {"source_sha": head, "policy_sha": policy, "mr_author_id": 41}, + "schema_version": 7, + "review": { + "source_sha": head, + "policy_sha": policy, + "target_sha": policy, + "target_protection": "protected", + "mr_author_id": 41, + }, "context": { "mode": "metadata", "state": "complete", diff --git a/tests/test_posting_approval.py b/tests/test_posting_approval.py index 40cc4af..b0c0619 100644 --- a/tests/test_posting_approval.py +++ b/tests/test_posting_approval.py @@ -7,6 +7,8 @@ from contextlib import redirect_stderr from typing import Any +import pytest + from ocr_toolkit import ocr_result from ocr_toolkit.posting import ( approval, @@ -42,8 +44,12 @@ def finding(category: Any = "style", severity: Any = "low") -> dict[str, Any]: return {"category": category, "severity": severity} -def receipt_v6( - *, context_state: str = "disabled", external: bool = False, author_id: int | None = 41 +def receipt_v7( + *, + context_state: str = "disabled", + external: bool = False, + author_id: int | None = 41, + target_protection: str = "protected", ) -> dict[str, Any]: """Return one closed synthetic review-time receipt.""" @@ -64,10 +70,12 @@ def receipt_v6( {"server": "documentation", "transport": "remote", "tools": ["docs_read"]} ) return { - "schema_version": 6, + "schema_version": 7, "review": { "source_sha": "a" * 40, "policy_sha": "b" * 40, + "target_sha": "b" * 40, + "target_protection": target_protection, "mr_author_id": author_id, }, "context": { @@ -103,6 +111,68 @@ def receipt_v6( } +def test_unprotected_receipt_is_valid_but_structurally_comment_only() -> None: + receipt = receipt_v7(target_protection="unprotected") + + assert approval.toolkit_receipt_is_valid(receipt) + decision = approval.evaluate_approval_policy( + settings.BooleanSetting(True), complete_outcome(), [], [], 0, receipt + ) + assert decision.eligible is False + assert decision.result.reason == ( + "the GitLab target branch was unprotected; limited reviews are comment-only" + ) + + +def test_unprotected_receipt_never_reaches_approval_mutation_path() -> None: + receipt = receipt_v7(target_protection="unprotected") + eligibility = approval.evaluate_approval_policy( + settings.BooleanSetting(True), complete_outcome(), [], [], 0, receipt + ) + calls: list[str] = [] + + with ( + patched_attr( + gitlab_approval, + "wait_for_synchronized_approval_state", + lambda *_args, **_kwargs: calls.append("synchronize") or (None, None), + ), + patched_attr( + gitlab, + "approve_merge_request", + lambda *_args, **_kwargs: calls.append("approve"), + ), + ): + result = gitlab_approval.execute_approval( + gitlab_config(), eligibility, "a" * 40, expected_author_id=41 + ) + + assert calls == [] + assert result.result == eligibility.result + + +@pytest.mark.parametrize( + "value", + (None, "", "required", "UNPROTECTED", True, False, 0, {}, [], "unprotected "), +) +def test_hostile_target_protection_states_invalidate_receipt(value: Any) -> None: + receipt = receipt_v7() + receipt["review"]["target_protection"] = value + assert not approval.toolkit_receipt_is_valid(receipt) + assert workflow.unprotected_target_limitation(receipt) is False + + +def test_target_sha_is_exactly_bound_to_policy_sha() -> None: + for mutate in ( + lambda review: review.update({"target_sha": "c" * 40}), + lambda review: review.pop("target_sha"), + lambda review: review.update({"extra": "c" * 40}), + ): + receipt = receipt_v7() + mutate(receipt["review"]) + assert not approval.toolkit_receipt_is_valid(receipt) + + def eligibility( comments: list[dict[str, Any]] | None = None, *, @@ -119,14 +189,14 @@ def eligibility( comments or [], warnings or [], omitted, - receipt_v6(), + receipt_v7(), ) def enriched_receipt(*, mutable: bool = False, required_degraded: bool = False) -> dict[str, Any]: - """Return one v6 local-store-only enrichment receipt.""" + """Return one v7 local-store-only enrichment receipt.""" - receipt = receipt_v6() + receipt = receipt_v7() receipt["context"] = { "mode": "enriched", "state": "degraded" if required_degraded else "complete", @@ -227,7 +297,7 @@ def test_degraded_context_receipt_blocks_without_exposing_provider_text(self) -> [], [], 0, - receipt_v6(context_state="degraded", author_id=41), + receipt_v7(context_state="degraded", author_id=41), ) self.assertFalse(decision.eligible) @@ -235,7 +305,7 @@ def test_degraded_context_receipt_blocks_without_exposing_provider_text(self) -> self.assertEqual(decision.result.reason, "the selected review context was degraded") def test_publication_dlp_filtered_receipt_is_valid_but_never_eligible(self) -> None: - receipt = receipt_v6() + receipt = receipt_v7() receipt["publication"] = { "state": "publication-filtered", "reason_counts": { @@ -278,7 +348,7 @@ def test_provider_private_fields_cannot_enter_approval_receipt(self) -> None: """Reject replay or request-control fields at the closed receipt boundary.""" for field in ("reasoning_content", "native_payload", "tool_choice"): - receipt = receipt_v6() + receipt = receipt_v7() receipt[field] = "private" with self.subTest(field=field): @@ -333,7 +403,7 @@ def test_filtered_receipt_rejects_outcomes_that_contradict_coverage(self) -> Non self.assertIsNone(approval.publication_dlp_state(candidate)) def test_private_only_sanitization_keeps_existing_approval_gates(self) -> None: - receipt = receipt_v6() + receipt = receipt_v7() receipt["publication"] = { "state": "private-sanitized", "reason_counts": { @@ -354,7 +424,7 @@ def test_private_only_sanitization_keeps_existing_approval_gates(self) -> None: self.assertTrue(decision.eligible) def test_evidence_action_attribution_does_not_change_approval_eligibility(self) -> None: - receipt = receipt_v6() + receipt = receipt_v7() receipt["evidence"]["actions"] = { "state": "verified", "summary": 1, @@ -382,10 +452,10 @@ def test_complete_metadata_and_external_mcp_have_independent_approval_effects(se [], [], 0, - receipt_v6(context_state="complete", author_id=41), + receipt_v7(context_state="complete", author_id=41), ) external = approval.evaluate_approval_policy( - settings.BooleanSetting(True), complete_outcome(), [], [], 0, receipt_v6(external=True) + settings.BooleanSetting(True), complete_outcome(), [], [], 0, receipt_v7(external=True) ) self.assertTrue(complete_metadata.eligible) @@ -435,7 +505,7 @@ def test_optional_context_mutation_remains_visible_without_becoming_required_fai self.assertTrue(decision.eligible) def test_receipt_accepts_ocr_compatible_non_identifier_tool_names(self) -> None: - metadata = receipt_v6(external=True) + metadata = receipt_v7(external=True) metadata["mcp"]["capabilities"][1]["tools"] = ["repo.search", "records/read"] decision = approval.evaluate_approval_policy( @@ -448,8 +518,8 @@ def test_receipt_accepts_ocr_compatible_non_identifier_tool_names(self) -> None: "external MCP was configured for a comment-only review", ) - def test_every_pre_v6_receipt_is_rejected(self) -> None: - for version in range(1, 6): + def test_every_pre_v7_receipt_is_rejected(self) -> None: + for version in range(1, 7): with self.subTest(version=version): decision = approval.evaluate_approval_policy( settings.BooleanSetting(True), @@ -466,15 +536,15 @@ def test_every_pre_v6_receipt_is_rejected(self) -> None: "the review-time approval receipt is missing or invalid", ) - def test_missing_or_malformed_v6_receipt_fails_closed(self) -> None: - cases: list[Any] = [None, {"schema_version": 6}] + def test_missing_or_malformed_v7_receipt_fails_closed(self) -> None: + cases: list[Any] = [None, {"schema_version": 7}] for mutate in ( lambda value: value["context"].update({"state": "complete"}), lambda value: value["review"].update({"source_sha": "invalid"}), lambda value: value["mcp"].update({"usage": {"unknown": 1}}), lambda value: value["evidence"].update({"used": False}), ): - candidate = receipt_v6() + candidate = receipt_v7() mutate(candidate) cases.append(candidate) for metadata in cases: @@ -495,7 +565,7 @@ def test_group_diagnostics_cannot_extend_the_closed_receipt(self) -> None: ("groups", [{"label": "core", "files": ["src/core.py"]}]), ("review_rounds", 2), ): - receipt = receipt_v6() + receipt = receipt_v7() receipt[field] = value decision = approval.evaluate_approval_policy( @@ -511,40 +581,40 @@ def test_group_diagnostics_cannot_extend_the_closed_receipt(self) -> None: def test_impossible_v6_capability_and_evidence_states_fail_closed(self) -> None: cases: list[dict[str, Any]] = [] - builtin_remote = receipt_v6() + builtin_remote = receipt_v7() builtin_remote["mcp"]["capabilities"][0]["transport"] = "remote" cases.append(builtin_remote) - external_builtin = receipt_v6(external=True) + external_builtin = receipt_v7(external=True) external_builtin["mcp"]["capabilities"][1]["transport"] = "builtin" cases.append(external_builtin) - wrong_builtin_tool = receipt_v6() + wrong_builtin_tool = receipt_v7() wrong_builtin_tool["mcp"]["capabilities"][0]["tools"] = ["other_read"] cases.append(wrong_builtin_tool) - duplicate_tool = receipt_v6(external=True) + duplicate_tool = receipt_v7(external=True) duplicate_tool["mcp"]["capabilities"][1]["tools"] = ["ocr_toolkit_evidence"] cases.append(duplicate_tool) - too_many_tools = receipt_v6(external=True) + too_many_tools = receipt_v7(external=True) too_many_tools["mcp"]["capabilities"][1]["tools"] = [ f"tool_{index}" for index in range(129) ] cases.append(too_many_tools) - usage_mismatch = receipt_v6() + usage_mismatch = receipt_v7() usage_mismatch["mcp"]["usage"] = {} cases.append(usage_mismatch) - usage_overflow = receipt_v6() + usage_overflow = receipt_v7() usage_overflow["mcp"]["usage"] = {"ocr_toolkit_evidence": 1_000_000_001} cases.append(usage_overflow) - missing_author = receipt_v6(author_id=None) + missing_author = receipt_v7(author_id=None) cases.append(missing_author) - mandatory_mismatch = receipt_v6() + mandatory_mismatch = receipt_v7() mandatory_mismatch["evidence"] = {"mandatory": False, "used": True} cases.append(mandatory_mismatch) @@ -924,7 +994,7 @@ def tearDown(self) -> None: settings.post_mode.cache_clear() def test_receipt_identity_is_atomic_for_summary_and_approval(self) -> None: - valid = receipt_v6(author_id=41) + valid = receipt_v7(author_id=41) self.assertEqual(workflow.approval_receipt_identity(valid), ("a" * 40, 41)) for mutate in ( @@ -932,7 +1002,7 @@ def test_receipt_identity_is_atomic_for_summary_and_approval(self) -> None: lambda value: value["review"].update({"mr_author_id": None}), lambda value: value["review"].update({"mr_author_id": True}), ): - candidate = receipt_v6(author_id=41) + candidate = receipt_v7(author_id=41) mutate(candidate) with self.subTest(candidate=candidate): self.assertEqual(workflow.approval_receipt_identity(candidate), ("", None)) @@ -940,7 +1010,7 @@ def test_receipt_identity_is_atomic_for_summary_and_approval(self) -> None: def test_valid_receipt_binds_advisory_without_changing_summary_or_approval_inputs(self) -> None: """Publish one closed advisory in Technical details with ordinary clean status.""" - receipt = receipt_v6(author_id=41) + receipt = receipt_v7(author_id=41) self.assertTrue(approval.toolkit_receipt_is_valid(receipt)) notes: list[str] = [] @@ -995,12 +1065,141 @@ def capture_note(_config: Any, _title: str, body: str, *_args: Any) -> dict[str, technical, ) + def test_unprotected_limitation_is_adjacent_once_for_all_receipt_outcomes(self) -> None: + scenarios = ( + ("complete", [], "complete", {}, []), + ( + "complete", + [], + "complete", + {}, + [ + { + "path": "src/example.py", + "line": 7, + "content": "Keep the boundary explicit.", + "severity": "low", + "category": "maintainability", + } + ], + ), + ("complete", ["Synthetic warning."], "complete", {}, []), + ("partial", [], "partial", {}, []), + ("partial", [], "partial", {"budget_exceeded": True}, []), + ("skipped", [], "skipped", {}, []), + ("failed", [], "failed", {}, []), + ) + for status, warnings, manifest_state, summary, comments in scenarios: + with self.subTest(status=status, manifest_state=manifest_state): + self._assert_unprotected_limitation_is_adjacent_once( + status, warnings, manifest_state, summary, comments + ) + + def _assert_unprotected_limitation_is_adjacent_once( + self, + status: str, + warnings: list[str], + manifest_state: str, + summary: dict[str, bool], + comments: list[dict[str, Any]], + ) -> None: + receipt = receipt_v7(target_protection="unprotected") + if status in {"skipped", "failed"}: + receipt["evidence"] = { + "mandatory": False, + "used": False, + "calls": 0, + "actions": {"state": "unavailable"}, + } + receipt["mcp"]["usage"] = {} + selected = ( + [] + if status == "skipped" + else [{"item_id": "synthetic-a"}, {"item_id": "synthetic-b"}] + if manifest_state == "partial" + else [{"item_id": "synthetic-item"}] + ) + completed = ( + selected + if manifest_state == "complete" + else selected[:1] + if manifest_state == "partial" + else [] + ) + failed = [] if manifest_state == "complete" else selected[-1:] + if failed: + failed = [ + { + **failed[0], + "classification": "budget" if summary else "provider", + } + ] + result = { + "status": status, + "comments": comments, + "warnings": warnings, + "tool_calls": ( + {"total": 0, "by_tool": {}} + if status in {"skipped", "failed"} + else {"total": 1, "by_tool": {"ocr_toolkit_evidence": 1}} + ), + "manifest": { + "schema_version": "ocr.run-manifest/v1", + "operation": "review", + "terminal_state": manifest_state, + "coverage": { + "selected": selected, + "completed": completed, + "reused": [], + "failed": failed, + "waived": [], + }, + }, + "_ocr_toolkit": receipt, + } + if summary: + result["summary"] = summary + notes: list[str] = [] + + def capture(_config: Any, _title: str, body: str, *_args: Any) -> dict[str, int]: + notes.append(body) + return {"id": len(notes)} + + with ( + patched_attr( + workflow, "collect_previous_bot_comment_refs", lambda _config: BotCommentRefs() + ), + patched_attr(workflow, "get_diff_refs", lambda _config: None), + patched_attr(workflow, "post_review_note_bounded", capture), + patched_attr(workflow, "finalize_posting", lambda *_args: True), + patched_attr(workflow, "finalize_review_approval", lambda *_args, **_kwargs: 0), + ): + workflow.post_results(gitlab_config(), result) + + limitation = formatting.UNPROTECTED_TARGET_LIMITATION + summary_note = next(note for note in notes if "## Open Code Review" in note) + assert summary_note.count(limitation) == 1 + visible = summary_note.splitlines() + status_index = next(index for index, line in enumerate(visible) if "**Review" in line) + assert visible[status_index + 1] == limitation + + def test_unprotected_limitation_requires_fully_validated_v7_receipt(self) -> None: + valid = receipt_v7(target_protection="unprotected") + assert workflow.unprotected_target_limitation(valid) + for candidate in ( + {**valid, "schema_version": 6}, + {**valid, "cleanup": {"result": "unknown"}}, + {**valid, "extra": True}, + receipt_v7(target_protection="unprotected", external=True), + ): + assert workflow.unprotected_target_limitation(candidate) is False + def test_complete_filtered_review_keeps_coverage_and_activity_dimensions_separate( self, ) -> None: """Render scenario B without inventing partial coverage or a failed item.""" - receipt = receipt_v6(author_id=41) + receipt = receipt_v7(author_id=41, target_protection="unprotected") receipt["publication"] = { "state": "publication-filtered", "reason_counts": { @@ -1071,6 +1270,9 @@ def capture_note( self.assertEqual(exit_code, 0) summary = next(body for title, body in notes if not title and "## Open Code Review" in body) self.assertIn("Review complete with publication filtering — 1 finding published", summary) + visible = summary.splitlines() + status_index = next(index for index, line in enumerate(visible) if "**Review" in line) + self.assertEqual(visible[status_index + 1], formatting.UNPROTECTED_TARGET_LIMITATION) self.assertNotIn("Review incomplete", summary) self.assertNotIn("OCR reported partial coverage", summary) self.assertNotIn("failed item(s) had no safe", summary) @@ -1085,7 +1287,7 @@ def capture_note( def test_private_sanitized_review_keeps_tool_and_token_activity_visible(self) -> None: """Keep independent numeric activity lines after private-only sanitization.""" - receipt = receipt_v6(author_id=41) + receipt = receipt_v7(author_id=41) receipt["publication"] = { "state": "private-sanitized", "reason_counts": { @@ -1235,6 +1437,46 @@ def update_summary(*_args: Any) -> bool: self.assertEqual(exit_code, 0) self.assertEqual(calls, []) + def test_unprotected_review_never_calls_approval_or_rewrites_summary(self) -> None: + decision = approval.evaluate_approval_policy( + settings.BooleanSetting(True), + complete_outcome(), + [], + [], + 0, + receipt_v7(target_protection="unprotected"), + ) + calls: list[str] = [] + + with ( + patched_attr(workflow, "finalize_posting", lambda *_args: True), + patched_attr( + workflow, + "execute_approval", + lambda *_args, **_kwargs: calls.append("approve"), + ), + patched_attr( + workflow, + "replace_current_summary", + lambda *_args, **_kwargs: calls.append("summary") or True, + ), + patched_attr(workflow, "finalize_previous_review_state", lambda *_args: None), + ): + exit_code = workflow.finalize_review_approval( + gitlab_config(), + BotCommentRefs(), + complete_outcome(), + [], + decision, + self.SHA, + 41, + self.RUN_ID, + lambda result: approval.approval_summary_line(result), + ) + + self.assertEqual(exit_code, 0) + self.assertEqual(calls, []) + def test_approval_failure_is_nonfatal_unless_strict(self) -> None: failed = gitlab_approval.ApprovalExecution( approval.ApprovalResult( diff --git a/tests/test_posting_helpers.py b/tests/test_posting_helpers.py index 87651d9..21967ec 100644 --- a/tests/test_posting_helpers.py +++ b/tests/test_posting_helpers.py @@ -95,7 +95,7 @@ def test_invalid_v6_publication_state_never_reaches_normal_result_flow(self) -> "status": "failed", "comments": [{"content": "locally retained safe finding"}], "_ocr_toolkit": { - "schema_version": 6, + "schema_version": 7, "publication": {"dlp": "blocked"}, }, } @@ -114,7 +114,7 @@ def test_invalid_v6_publication_state_never_reaches_normal_result_flow(self) -> self.assertEqual(exit_code, 0) self.assertEqual(notes, ["**Open Code Review publication policy error**"]) - def test_advisory_without_exact_receipt_v6_never_reaches_normal_result_flow(self) -> None: + def test_advisory_without_exact_receipt_v7_never_reaches_normal_result_flow(self) -> None: """Treat a correctly shaped but unbound advisory as an invalid result.""" notes: list[str] = [] @@ -1333,7 +1333,7 @@ def capture_note(_config: Any, *args: Any) -> dict[str, int]: "status": "completed_with_errors", "comments": [old, new], "warnings": [], - "_ocr_toolkit": {"schema_version": 6, "publication": publication}, + "_ocr_toolkit": {"schema_version": 7, "publication": publication}, }, ) @@ -1376,10 +1376,12 @@ def capture_note( }, }, "_ocr_toolkit": { - "schema_version": 6, + "schema_version": 7, "review": { "source_sha": "a" * 40, "policy_sha": "b" * 40, + "target_sha": "b" * 40, + "target_protection": "protected", "mr_author_id": 41, }, "context": { @@ -2079,9 +2081,9 @@ def test_workflow_identity_helpers_fail_closed_on_ambiguous_values(self) -> None for metadata in ( None, {"schema_version": 4}, - {"schema_version": 6, "review": []}, + {"schema_version": 7, "review": []}, { - "schema_version": 6, + "schema_version": 7, "review": {"source_sha": "invalid", "mr_author_id": True}, }, ): @@ -2090,8 +2092,14 @@ def test_workflow_identity_helpers_fail_closed_on_ambiguous_values(self) -> None self.assertEqual( workflow.approval_receipt_identity( { - "schema_version": 6, - "review": {"source_sha": "a" * 40, "mr_author_id": 41}, + "schema_version": 7, + "review": { + "source_sha": "a" * 40, + "policy_sha": "b" * 40, + "target_sha": "b" * 40, + "target_protection": "protected", + "mr_author_id": 41, + }, } ), ("a" * 40, 41), @@ -3274,7 +3282,7 @@ def test_budget_summary_and_guide_mark_findings_as_partial(self) -> None: def test_mcp_usage_summary_reports_only_servers_actually_called(self) -> None: summary = posting_formatting.format_mcp_usage_summary( { - "schema_version": 6, + "schema_version": 7, "mcp": { "usage": { "ocr_toolkit_evidence": 2, @@ -3291,10 +3299,10 @@ def test_mcp_usage_summary_reports_only_servers_actually_called(self) -> None: ) self.assertNotIn("file_read", summary) - def test_mcp_usage_summary_reads_receipt_v6_inventory(self) -> None: + def test_mcp_usage_summary_reads_receipt_v7_inventory(self) -> None: summary = posting_formatting.format_mcp_usage_summary( { - "schema_version": 6, + "schema_version": 7, "mcp": { "capabilities": [], "usage": {"ocr_toolkit_evidence": 3}, @@ -3311,7 +3319,7 @@ def test_mcp_usage_summary_reads_receipt_v6_inventory(self) -> None: def test_mcp_usage_summary_renders_verified_action_breakdown(self) -> None: summary = posting_formatting.format_mcp_usage_summary( { - "schema_version": 6, + "schema_version": 7, "mcp": {"usage": {"ocr_toolkit_evidence": 4}}, "evidence": { "calls": 4, @@ -3360,7 +3368,7 @@ def test_mcp_usage_summary_omits_zero_or_unavailable_action_breakdown(self) -> N self.assertEqual( posting_formatting.format_mcp_usage_summary( { - "schema_version": 6, + "schema_version": 7, "mcp": {"usage": {"ocr_toolkit_evidence": 3}}, "evidence": {"calls": calls, "actions": actions}, } @@ -3378,7 +3386,7 @@ def test_mcp_usage_summary_omits_hostile_or_unreconciled_action_breakdown(self) with self.subTest(actions=actions): summary = posting_formatting.format_mcp_usage_summary( { - "schema_version": 6, + "schema_version": 7, "mcp": {"usage": {"ocr_toolkit_evidence": 4}}, "evidence": {"calls": 4, "actions": actions}, } @@ -3393,7 +3401,7 @@ def test_mcp_usage_summary_omits_hostile_or_unreconciled_action_breakdown(self) def test_mcp_usage_summary_reconciles_actions_after_context_calls(self) -> None: summary = posting_formatting.format_mcp_usage_summary( { - "schema_version": 6, + "schema_version": 7, "context": {"tool_usage": {"context_get": 1, "context_list": 2}}, "mcp": {"usage": {"ocr_toolkit_evidence": 7}}, "evidence": { @@ -3427,7 +3435,7 @@ def test_mcp_usage_summary_rejects_malformed_usage_before_rendering(self) -> Non self.assertEqual( posting_formatting.format_mcp_usage_summary( { - "schema_version": 6, + "schema_version": 7, "mcp": {"usage": usage}, "evidence": {"actions": {"state": "unavailable"}}, } @@ -5554,7 +5562,7 @@ def test_toolkit_metadata_is_reserved_and_schema_owned(self) -> None: lambda _payload: {"schema_version": 999, "publication": {"state": "passed"}}, ) - self.assertEqual(metadata["schema_version"], 6) + self.assertEqual(metadata["schema_version"], 7) self.assertEqual(transformed["_ocr_toolkit"], metadata) with self.assertRaisesRegex(ocr_result.OcrResultMalformed, "reserved field"): diff --git a/tests/test_review_runner.py b/tests/test_review_runner.py index aff0807..fce6d83 100644 --- a/tests/test_review_runner.py +++ b/tests/test_review_runner.py @@ -39,6 +39,8 @@ mr_author_id=None, context_mode="off", context=None, + target_sha="b" * 40, + target_protection="local", ) BUILTIN_EVIDENCE_TOOLS = ( "ocr_toolkit_evidence", @@ -73,6 +75,8 @@ def enriched_identity() -> review_runner.ReviewIdentity: mr_author_id=41, context_mode="enriched", context=context, + target_sha="b" * 40, + target_protection="protected", ) @@ -181,6 +185,46 @@ def test_enrichment_admits_provider_neutral_ci_without_approval_authority( assert "pipeline_id" not in artifacts.context_store.read_text(encoding="utf-8") +def test_unprotected_enrichment_guard_never_loads_policy_or_acquires_context( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + """Keep protected-policy and provider acquisition unreachable in constrained mode.""" + + artifacts = repository_artifacts(tmp_path) + artifacts.directory.mkdir(mode=0o700) + artifacts.context_store.write_text("stale", encoding="utf-8") + identity = replace( + enriched_identity(), + context_mode="metadata", + target_protection="unprotected", + ) + calls: list[str] = [] + monkeypatch.setattr( + review_runner, + "load_protected_policy", + lambda *_args, **_kwargs: calls.append("policy") or None, + ) + monkeypatch.setattr( + review_runner, + "acquire_gitlab_context", + lambda *_args, **_kwargs: calls.append("gitlab") or None, + ) + monkeypatch.setattr( + review_runner, + "acquire_external_records", + lambda *_args, **_kwargs: calls.append("external") or None, + ) + monkeypatch.delenv("OCR_REVIEW_CONTEXT_ADAPTERS_JSON", raising=False) + + assert review_runner._prepare_enrichment( + identity, + artifacts, + SimpleNamespace(), # type: ignore[arg-type] + ) == (None, None) + assert calls == [] + assert not artifacts.context_store.exists() + + def test_optional_ci_record_rejection_does_not_degrade_required_context( tmp_path: Path, monkeypatch: pytest.MonkeyPatch ) -> None: @@ -506,7 +550,7 @@ def test_safe_mr_and_enrichment_data_preserve_auto_approval_but_remediation_does safe_enrichment, {"summary": 1, "list": 0, "get": 0, "search": 0, "coverage": 0}, ) - metadata["schema_version"] = 6 + metadata["schema_version"] = ocr_result.TOOLKIT_RESULT_SCHEMA_VERSION eligible = approval.evaluate_approval_policy( settings.BooleanSetting(True), parse_result_outcome(payload), @@ -539,7 +583,7 @@ def test_safe_mr_and_enrichment_data_preserve_auto_approval_but_remediation_does changed, {"summary": 1, "list": 0, "get": 0, "search": 0, "coverage": 0}, ) - blocked["schema_version"] = 6 + blocked["schema_version"] = ocr_result.TOOLKIT_RESULT_SCHEMA_VERSION decision = approval.evaluate_approval_policy( settings.BooleanSetting(True), parse_result_outcome(payload), @@ -664,8 +708,14 @@ def test_ocr_result_requires_builtin_mcp_usage_for_completed_review(tmp_path: Pa result, composition, DEFAULT_IDENTITY, evidence_action_counts=counts ) == {"ocr_toolkit_evidence": 2} assert json.loads(result.read_text(encoding="utf-8"))["_ocr_toolkit"] == { - "schema_version": 6, - "review": {"source_sha": "a" * 40, "policy_sha": "b" * 40, "mr_author_id": None}, + "schema_version": 7, + "review": { + "source_sha": "a" * 40, + "policy_sha": "b" * 40, + "target_sha": "b" * 40, + "target_protection": "local", + "mr_author_id": None, + }, "context": { "mode": "off", "state": "disabled", @@ -925,7 +975,9 @@ def test_ocr_result_receipt_blocks_approval_when_mr_context_was_admitted( review_runner._record_ocr_result_mcp_usage( result, composition, - review_runner.ReviewIdentity("a" * 40, "b" * 40, 41, "metadata", None), + review_runner.ReviewIdentity( + "a" * 40, "b" * 40, 41, "metadata", None, "b" * 40, "protected" + ), evidence_action_counts={ "summary": 1, "list": 0, @@ -936,8 +988,14 @@ def test_ocr_result_receipt_blocks_approval_when_mr_context_was_admitted( ) assert json.loads(result.read_text(encoding="utf-8"))["_ocr_toolkit"] == { - "schema_version": 6, - "review": {"source_sha": "a" * 40, "policy_sha": "b" * 40, "mr_author_id": 41}, + "schema_version": 7, + "review": { + "source_sha": "a" * 40, + "policy_sha": "b" * 40, + "target_sha": "b" * 40, + "target_protection": "protected", + "mr_author_id": 41, + }, "context": { "mode": "metadata", "state": "degraded", @@ -1274,7 +1332,7 @@ def test_background_preview_advisory_is_atomically_finalized_without_blocking_ap _usage, blocked, publication = review_runner._finalize_ocr_result( result, composition, - replace(DEFAULT_IDENTITY, mr_author_id=41), + replace(DEFAULT_IDENTITY, mr_author_id=41, target_protection="protected"), None, SUMMARY_ACTION_COUNTS, forbidden=(), @@ -1670,7 +1728,7 @@ def test_finalized_result_drops_provider_private_fields_before_receipt_binding( usage, blocked, publication = review_runner._finalize_ocr_result( result, composition, - replace(DEFAULT_IDENTITY, mr_author_id=41), + replace(DEFAULT_IDENTITY, mr_author_id=41, target_protection="protected"), None, SUMMARY_ACTION_COUNTS, forbidden=(), @@ -1745,7 +1803,7 @@ def test_review_groups_remain_private_and_cannot_change_approval(tmp_path: Path) external_servers=(), secret_values=(), ) - identity = replace(DEFAULT_IDENTITY, mr_author_id=41) + identity = replace(DEFAULT_IDENTITY, mr_author_id=41, target_protection="protected") base_payload: dict[str, object] = { "status": "complete", "comments": [], @@ -2237,7 +2295,9 @@ def test_context_tool_calls_never_satisfy_mandatory_evidence_summary(tmp_path: P review_runner._record_ocr_result_mcp_usage( result, composition, - review_runner.ReviewIdentity("a" * 40, "b" * 40, 41, "enriched", None), + review_runner.ReviewIdentity( + "a" * 40, "b" * 40, 41, "enriched", None, "b" * 40, "protected" + ), enrichment, ) @@ -3231,15 +3291,25 @@ def test_preview_gate_clears_stale_handoff_artifacts_before_preflight( @pytest.mark.parametrize( - ("preserve_private_artifacts", "ocr_exit_code"), - [(False, 0), (False, 1), (True, 0)], + ("preserve_private_artifacts", "ocr_exit_code", "target_protection"), + [ + (False, 0, "local"), + (False, 1, "local"), + (True, 0, "local"), + (False, 0, "unprotected"), + ], ) def test_evidence_review_prepares_internal_context_before_ocr( - tmp_path: Path, preserve_private_artifacts: bool, ocr_exit_code: int + tmp_path: Path, + preserve_private_artifacts: bool, + ocr_exit_code: int, + target_protection: str, ) -> None: """Clean ordinary success/failure while retaining requested local diagnostics.""" events: list[object] = [] + composition_inputs: list[dict[str, object]] = [] + bootstrap_inputs: list[dict[str, object]] = [] session_homes: list[Path] = [] real_subprocess_run = subprocess.run original_home = os.environ.get("HOME") @@ -3305,6 +3375,23 @@ def write_bootstrap(path: Path, content: str) -> None: events.append(("bootstrap", path, content)) path.write_text(content, encoding="utf-8") + def compose(**kwargs: object) -> MCPComposition: + composition_inputs.append(kwargs) + return composition + + def bootstrap(*_args: object, **kwargs: object) -> str: + bootstrap_inputs.append(kwargs) + return "bootstrap" + + identity = replace( + DEFAULT_IDENTITY, + source_sha="b" * 40, + policy_sha="a" * 40, + target_sha="a" * 40, + target_protection=target_protection, + mr_author_id=41 if target_protection == "unprotected" else None, + ) + with ( patched_attr( review_runner, @@ -3312,6 +3399,11 @@ def write_bootstrap(path: Path, content: str) -> None: lambda _refs: review_runner.ReviewRefs("a" * 40, "b" * 40), ), patched_attr(review_runner, "_write_isolated_runtime_config", lambda: None), + patched_attr( + review_runner, + "_prepare_policy_context", + lambda _refs, args, _artifacts: (identity, args), + ), patched_attr(review_runner, "repository_artifacts", lambda: artifacts), patched_attr(review_runner, "collect_repository_evidence", collect), patched_attr( @@ -3323,7 +3415,7 @@ def write_bootstrap(path: Path, content: str) -> None: patched_attr( review_runner.mcp_config, "build_mcp_composition", - lambda **_kwargs: composition, + compose, ), patched_attr( review_runner.mcp_config, @@ -3335,7 +3427,7 @@ def write_bootstrap(path: Path, content: str) -> None: "verify_mcp_composition", lambda _composition: events.append("verify"), ), - patched_attr(review_runner, "render_bootstrap", lambda *_args, **_kwargs: "bootstrap"), + patched_attr(review_runner, "render_bootstrap", bootstrap), patched_attr(review_runner, "write_private_text", write_bootstrap), patched_attr( review_runner, @@ -3381,8 +3473,23 @@ def write_bootstrap(path: Path, content: str) -> None: "base_ref": "a" * 40, "head_ref": "b" * 40, "policy_ref": "a" * 40, + "include_policy_records": target_protection != "unprotected", }, ) + assert composition_inputs == [ + { + "profile": "gitlab_mr" if target_protection == "unprotected" else "local", + "context": None, + "allow_external": target_protection != "unprotected", + } + ] + assert bootstrap_inputs == [ + { + "capabilities": composition.capabilities, + "context_hints": None, + "unprotected_target": target_protection == "unprotected", + } + ] assert events[1] == ("enrich", f"invocation:{'b' * 40}") assert events[2] == ("write", artifacts.store) assert events[3] == ("bootstrap", artifacts.bootstrap, "bootstrap") @@ -3461,7 +3568,9 @@ def test_private_artifact_preservation_is_rejected_for_gitlab_mr_profile() -> No with pytest.raises(review_runner.ReviewRunnerError, match="local reviews only"): review_runner._authorize_private_artifact_preservation( - review_runner.ReviewIdentity("a" * 40, "b" * 40, 41, "off", None), + review_runner.ReviewIdentity( + "a" * 40, "b" * 40, 41, "off", None, "b" * 40, "protected" + ), requested=True, ) @@ -3502,7 +3611,9 @@ def run(*_args: object, **_kwargs: object) -> int: review_runner, "_prepare_policy_context", lambda *_args: ( - review_runner.ReviewIdentity("b" * 40, "a" * 40, 41, "off", None), + review_runner.ReviewIdentity( + "b" * 40, "a" * 40, 41, "off", None, "a" * 40, "protected" + ), ["--from", "base", "--to", "head"], ), ), diff --git a/tests/test_runtime_helpers.py b/tests/test_runtime_helpers.py index 4a7b431..52f2400 100644 --- a/tests/test_runtime_helpers.py +++ b/tests/test_runtime_helpers.py @@ -48,6 +48,78 @@ def test_composition_readback_preserves_independent_registry_entries(self) -> No ): mcp_config.verify_mcp_composition(composition) + def test_constrained_composition_rejects_direct_external_server(self) -> None: + external = mcp_config.MCPServerConfig( + name="synthetic_docs", + transport="remote", + command=None, + url="https://mcp.synthetic.invalid/v1", + args=[], + tools=["docs_read"], + setup="", + env=[], + headers={}, + secret_values=[], + ) + with self.assertRaisesRegex(mcp_config.MCPConfigError, "do not allow external"): + mcp_config.compose_mcp_servers( + [external], replace=True, profile="gitlab_mr", allow_external=False + ) + + def test_constrained_composition_rejects_inherited_external_even_with_replace(self) -> None: + current = { + "mcp_servers": { + "remote": { + "type": "remote", + "url": "https://mcp.synthetic.invalid/v1", + "tools": ["docs_read"], + } + } + } + for replace in (False, True): + with ( + self.subTest(replace=replace), + patched_attr(mcp_config, "read_ocr_config", lambda: current), + self.assertRaisesRegex(mcp_config.MCPConfigError, "inherited external"), + ): + mcp_config.compose_mcp_servers( + [], replace=replace, profile="gitlab_mr", allow_external=False + ) + + def test_constrained_composition_rejects_malformed_inherited_registry(self) -> None: + for current in ( + {"mcp_servers": []}, + {"mcp_servers": {7: {}}}, + {"mcp_servers": {"external": "malformed"}}, + ): + with ( + self.subTest(current=current), + patched_attr(mcp_config, "read_ocr_config", lambda: current), + self.assertRaises(mcp_config.MCPConfigError), + ): + mcp_config.compose_mcp_servers( + [], replace=True, profile="gitlab_mr", allow_external=False + ) + + def test_constrained_composition_allows_only_rebuilt_builtin(self) -> None: + current = { + "mcp_servers": { + mcp_config.BUILTIN_EVIDENCE_SERVER: { + "type": "stdio", + "command": "stale", + "tools": [mcp_config.TOOL_NAME], + } + } + } + with patched_attr(mcp_config, "read_ocr_config", lambda: current): + composition = mcp_config.compose_mcp_servers( + [], replace=True, profile="gitlab_mr", allow_external=False + ) + + self.assertEqual(set(composition.payload), {mcp_config.BUILTIN_EVIDENCE_SERVER}) + self.assertEqual(composition.external_servers, ()) + self.assertEqual(composition.capabilities[0].server, mcp_config.BUILTIN_EVIDENCE_SERVER) + mismatched = {"mcp_servers": {mcp_config.BUILTIN_EVIDENCE_SERVER: {}}} with ( patched_attr(mcp_config, "read_ocr_config", lambda: mismatched), From 3ae238bf2dfdf2911e846a75abb0c1c9b5c9ec76 Mon Sep 17 00:00:00 2001 From: xeonvs <11463419+xeonvs@users.noreply.github.com> Date: Wed, 2 Sep 2026 10:06:40 +0200 Subject: [PATCH 05/15] docs: document constrained target reviews --- PLANS.md | 6 +- README.md | 10 ++- docs/configuration.md | 19 ++++-- docs/gitlab.md | 22 ++++++- docs/operations.md | 12 ++-- docs/review-context.md | 10 +-- docs/security.md | 21 +++--- examples/gitlab/README.md | 7 +- .../modes/unprotected-target.gitlab-ci.yml | 7 ++ examples/gitlab/ocr-review.gitlab-ci.yml | 2 + tests/test_environment_contract.py | 2 + tests/test_integration_contracts.py | 4 +- tests/test_operations_docs.py | 65 +++++++++++++++++-- 13 files changed, 150 insertions(+), 37 deletions(-) create mode 100644 examples/gitlab/modes/unprotected-target.gitlab-ci.yml diff --git a/PLANS.md b/PLANS.md index 1ef0817..c48dd6f 100644 --- a/PLANS.md +++ b/PLANS.md @@ -74,8 +74,10 @@ attribution, detached-pipeline diagnostics, and OCR 1.11.2 compatibility. binds source/target/protection in receipt v7, renders the validated limitation, and cannot reach the approval executor. Hostile, orchestration, DLP-summary, and immutable-target regressions cover these boundaries. -6. [ ] Implement #168 summary projection, complete public documentation, and synthetic examples - as one complete documentation contract commit. +6. [x] Implement #168 summary projection, complete public documentation, and synthetic examples + as one complete documentation contract commit. The secure default, constrained capability matrix, + two-MR and one-MR setup paths, static limitation, fail-closed diagnostics, and separate GitLab + merge-policy boundary are now explicit and contract-tested. 7. [ ] Implement #170 qualification, OCR 1.11.2 pins, Rules probes, and `maintenance` plus `rules` Towncrier entries. 8. [ ] Review every commit and the complete `origin/main..HEAD` range, run all deterministic local diff --git a/README.md b/README.md index b159135..5514508 100644 --- a/README.md +++ b/README.md @@ -62,7 +62,7 @@ On a successful rerun, the toolkit replaces untouched OCR-only notes instead of Suppression uses both the GitLab diff position and a stable finding fingerprint, so ordinary line shifts do not normally bring the same bug back. A materially changed finding can still receive a new discussion. See [GitLab review operations](docs/operations.md) for the complete lifecycle, posting modes, permissions, failure behavior, and Mermaid state diagram. -After every current review note publishes, the GitLab adapter can add a conservative approval bound to receipt v6's exact reviewed source SHA and merge-request author. This write is enabled by default; set `OCR_AUTO_APPROVE=false` when the bot must remain comment-only. DLP-clean metadata, generic discussions, protected same-revision CI outcomes, and adapter records do not independently block approval, while degraded metadata, DLP rejection, required context degradation, admitted remediation history, legacy receipts, publication filtering, any direct external MCP, author movement, or bot self-authorship prevents an approval write. A CI status is review context, never approval authority. GitLab approval rules and protected-branch policy remain authoritative. The toolkit only adds an eligible approval; it never removes an existing approval when a later review is ineligible or disabled. +After every current review note publishes, the GitLab adapter can add a conservative approval bound to receipt v7's exact reviewed source SHA, target SHA/protection state, and merge-request author. This write is enabled by default; set `OCR_AUTO_APPROVE=false` when the bot must remain comment-only. DLP-clean metadata, generic discussions, protected same-revision CI outcomes, and adapter records do not independently block approval, while an unprotected target, degraded metadata, DLP rejection, required context degradation, admitted remediation history, legacy receipts, publication filtering, any direct external MCP, author movement, or bot self-authorship prevents an approval write. A CI status is review context, never approval authority. GitLab approval rules, Code Owners, and protected-branch policy remain authoritative merge policy. They are not prerequisites for OCR execution. The toolkit only adds an eligible approval; it never removes an existing approval when a later review is ineligible or disabled. Accepted tradeoffs can be recorded in `.opencodereview/accepted-decisions.md`; the evidence collector supplies only applicable target-ref decisions and never lets a source change self-authorize its review. Root and nested target `AGENTS.md`/`CLAUDE.md` guidance is similarly exposed through the existing evidence MCP with deterministic scope and precedence, while any guidance touched by the merge request is excluded. See [Accepted project decisions](docs/configuration.md#accepted-project-decisions) and [Target project guidance](docs/configuration.md#target-project-guidance) for formats and trust boundaries. @@ -79,8 +79,10 @@ The shipped Repository Evidence Engine reads immutable base/head Git objects, st 1. Configure protected/masked `GITLAB_API_TOKEN` and LLM variables in GitLab. 2. Pin and checksum the OCR binary. -3. Install this package. -4. Run the four public helper stages around `ocr review`: +3. Keep `OCR_GITLAB_TARGET_PROTECTION_MODE=required` unless the project deliberately permits an unprotected target in limited, comment-only mode. +4. Merge repository review configuration, including the Rules path, into the target branch. +5. Enable the CI review job in a later merge request and install this package. +6. Run the four public helper stages around `ocr review`: ```console ocr-ci preflight @@ -91,6 +93,8 @@ ocr-ci post --result /tmp/ocr-result.json --stderr /tmp/ocr-stderr.log See the [GitLab mode matrix](examples/gitlab/README.md), the complete [`ocr-review.gitlab-ci.yml`](examples/gitlab/ocr-review.gitlab-ci.yml) pipeline, the [GitLab setup guide](docs/gitlab.md), and [GitLab review operations](docs/operations.md). +The two-merge-request setup is recommended because repository Rules are loaded only from the captured target commit. On the default protected-target path, a supported one-merge-request alternative may add the Rules and CI job together when that integration merge request does not need review: its first run stops before OCR with a setup-pending note, retrying the same merge request cannot change the captured target Rules, and later merge requests work after it merges. An actually unprotected target with missing target Rules fails closed without promoting the source copy or promising setup-pending status. If the status identity is missing or mismatched, the toolkit likewise uses the generic fail-closed failure note. A green advisory job or pipeline does not prove that OCR or its model executed. + ## Configuration and safety Configuration is environment-driven. The [configuration reference](docs/configuration.md) documents supported `OCR_*`, `CI_*`, `GITLAB_*`, and MCP inputs. Posting requires `GITLAB_API_TOKEN`; job tokens and legacy aliases are deliberately unsupported. diff --git a/docs/configuration.md b/docs/configuration.md index 16a8fb6..e914f2d 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -87,6 +87,7 @@ GitLab supplies the `CI_*` values in merge-request pipelines. The operator suppl | `CI_PIPELINE_ID` | GitLab predefined | No | Omitted | Optional bounded invocation identity stored as non-authoritative evidence. | | `CI_JOB_ID` | GitLab predefined | No | Omitted | Optional bounded invocation identity stored as non-authoritative evidence. | | **`CI_PIPELINE_SOURCE`** | GitLab predefined / example rules | Yes for example job selection | None | The public example runs review jobs only for `merge_request_event`. | +| `OCR_GITLAB_TARGET_PROTECTION_MODE` | Operator / GitLab snapshot acquisition | No | `required` | Closed selector: `required` rejects an unprotected target before OCR; `unprotected` permits it only in the constrained mode described below. An explicitly assigned empty, whitespace, mixed-case, malformed, or unknown value fails closed. | ## Example-local variables @@ -135,7 +136,15 @@ Generic external MCP is privileged operator configuration, not safe author-trigg Use direct composition only for reviewed narrow read-only tools, dedicated least-privilege credentials, server-enforced resource authorization, bounded server responses, and data acceptable for both model egress and OCR-session retention. A direct tool must be safe in both phases. Do not expose generic search, arbitrary URL/ID fetch, recursive traversal, writes, or broad service credentials to references that merge-request text can select. Treat command, endpoint, environment, headers, setup, descriptions, schemas, arguments, and responses according to their separate executable or untrusted boundaries. Local-profile `setup` runs as operator-owned shell configuration in the analyzed repository; keep it empty unless explicitly reviewed. Do not expose raw endpoint, setup, or credential values through toolkit diagnostics; OCR may emit operator-owned setup or transport details, so keep those values non-sensitive and retain OCR stderr privately. -An unavailable optional server or tool/protocol error can degrade while OCR continues. Check the private OCR stderr and result rather than assuming configured context was used. Receipt v6 stores the complete bounded configured capability inventory (server, `builtin|stdio|remote` transport, and allowlisted tool names) plus positive per-server use counts. It never stores commands, URLs, headers, setup, arguments, results, or repository/provider content. A count proves only that OCR recorded a call; it does not prove object authorization, completeness, content safety, response use, or correct model judgment. The mandatory evidence MCP remains independently required. Every configured direct external MCP makes a review comment-only even when unused; server-authored tool annotations are not an enforceable same-session read-only guarantee. Direct external MCP remains privileged operator configuration. M5 external records use the separate toolkit-owned broker described in [Bounded review context](review-context.md), so provider schemas and arbitrary arguments never enter OCR. +An unavailable optional server or tool/protocol error can degrade while OCR continues. Check the private OCR stderr and result rather than assuming configured context was used. Receipt v7 stores the complete bounded configured capability inventory (server, `builtin|stdio|remote` transport, and allowlisted tool names) plus positive per-server use counts. It never stores commands, URLs, headers, setup, arguments, results, or repository/provider content. A count proves only that OCR recorded a call; it does not prove object authorization, completeness, content safety, response use, or correct model judgment. The mandatory evidence MCP remains independently required. Every configured direct external MCP makes a protected-target review comment-only even when unused and is rejected entirely for an actually unprotected target; server-authored tool annotations are not an enforceable same-session read-only guarantee. Direct external MCP remains privileged operator configuration. M5 external records use the separate toolkit-owned broker described in [Bounded review context](review-context.md), so provider schemas and arbitrary arguments never enter OCR. + +### GitLab target-protection selector + +`OCR_GITLAB_TARGET_PROTECTION_MODE` is operator-owned and parsed before policy acquisition or OCR execution. If it is unset, `required` preserves the secure default: GitLab must report the captured target branch as protected. The exact opt-in `unprotected` permits an unprotected target; it does not force limited behavior when GitLab reports that target as protected. Explicit empty strings and every value other than exact lowercase `required` or `unprotected` fail closed. + +When the opt-in meets an actually unprotected target, the toolkit binds the exact source SHA, target SHA, and `unprotected` state in receipt v7 and enforces a constrained review. Context `off` and bounded untrusted `metadata` are allowed. `enriched`, any configured `OCR_REVIEW_CONTEXT_ADAPTERS_JSON` value (including an empty value or `[]`), protected-policy discussion/remediation/CI acquisition, direct external MCP, and inherited external OCR MCP configuration are rejected before OCR. The toolkit-owned immutable repository-evidence MCP remains mandatory. Repository Rules are required and are read only as bounded untrusted model guidance from the exact captured target SHA; source Rules cannot authorize their own review. Structured target guidance and accepted decisions are omitted. No target-derived input can enable tools, external acquisition, suppression, posting authority, or approval. + +An unprotected receipt is structurally automatic-approval-ineligible regardless of `OCR_AUTO_APPROVE`; the approval executor and GitLab approval endpoint are not reached. A fully validated receipt alone adds *The target branch was not protected in GitLab. This review ran in limited, comment-only mode.* immediately after the normal primary status line. The line does not make complete coverage partial and does not alter clean, findings, warning, partial, budget-stopped, failed, or publication-filtered outcome semantics. A protected target running under the permissive setting keeps normal protected-policy behavior and does not show the line. Missing, malformed, legacy, or contradictory receipt state cannot authorize it. ### Review-context selector @@ -208,15 +217,15 @@ The initial policy is fixed: zero findings, or at most three findings whose severity is exactly `low` and category is exactly `style`, `documentation`, or `maintainability`, are eligible. Missing, unknown, differently cased, or non-string metadata blocks approval, as do warnings, failed or waived coverage, -partial/budget outcomes, any receipt other than v5, degraded selected metadata, any configured direct external MCP, required context degradation, a DLP-rejected selected source, admitted remediation context, -and findings omitted by `OCR_MAX_POST_COMMENTS`. For receipt v6, complete metadata, complete non-remediation enrichment, private-only sanitization, and the built-in evidence/context MCP are not blockers. GitLab posting also revalidates the receipt-bound source SHA and author ID, and skips without writing when the author changed or the toolkit user authored the merge request. There are intentionally no +partial/budget outcomes, any receipt other than v7, an unprotected target, degraded selected metadata, any configured direct external MCP, required context degradation, a DLP-rejected selected source, admitted remediation context, +and findings omitted by `OCR_MAX_POST_COMMENTS`. For receipt v7 on a protected target, complete metadata, complete non-remediation enrichment, private-only sanitization, and the built-in evidence/context MCP are not blockers. GitLab posting also revalidates the receipt-bound source SHA and author ID, and skips without writing when the author changed or the toolkit user authored the merge request. There are intentionally no environment variables for policy thresholds or category lists in this release. `ocr-ci review --result PATH --stderr PATH -- ...` executes OCR without posting, creates private artifacts, and prints a bounded redacted stderr excerpt to the CI log when OCR fails. It accepts only a regular, single-link result artifact and, after a successful ordinary OCR process, atomically replaces that artifact with an owner-only copy containing the toolkit's bounded MCP-use receipt. For a local diagnosis only, `--preserve-private-artifacts` retains the owner-only isolated OCR home and repository-local review artifacts and leaves the OCR result without a posting receipt. It also writes `.review-context/private-dlp-decisions.json` using schema `ocr.private-dlp-decisions/v1`: up to 1,000 rejected keys or values have only their bounded JSON path, scope/action, public reason, detector subtype, value type, character/byte/line counts, and SHA-256; `truncated` plus `omitted_decisions` report any remaining decisions. The sidecar never contains the rejected value, replaces unsafe key names with ``, collapses excessive path depth to ``, and can correlate repeated technical values by digest so a local operator can distinguish likely false positives from genuine PII, secret, limit, or laundering detections. The retained result and other paths can still contain repository, provider, model, tool-argument, tool-result, and credential-adjacent data: inspect them locally, never upload or post them, and delete them after diagnosis. Ordinary execution removes a stale sidecar and never creates a new one. A validated GitLab merge-request profile rejects this flag before OCR execution; CI detection variables do not authorize it. `OCR_POST_ERROR_DETAILS=1` separately opts into including the same safe stderr excerpt in the GitLab failure note; leave it unset when diagnostics should remain runner-only. ## Repository evidence -`ocr-ci review` owns this lifecycle. Before OCR starts it collects the exact immutable `--from`/`--to` refs (or the parent/commit pair selected by `--commit`), writes bounded redacted schema-versioned evidence, builds OCR's MCP registry with the mandatory evidence entry plus each independently configured optional server, reads the registry back, self-queries the evidence summary/list/get contract, and supplies the matching compact bootstrap to OCR. Those parent-process preflight calls are not counted as model use. The same preflight-qualified OCR executable first receives the exact production refs, rules, selection options, and background under `review --preview`; there is no toolkit threshold setting or duplicated OCR threshold constant. An exact recognized OCR soft background diagnostic becomes a toolkit-authored numeric `ocr.toolkit-advisory/v1` value only after publication DLP and appears in the bounded CI log plus GitLab Technical details. It is not an OCR warning, receipt or DLP input, coverage signal, telemetry field, or approval blocker. An exact recognized hard character/file-size rejection stops before model execution and leaves only an identity-bound numeric `ocr.pre-execution-status/v2` outcome for static GitLab reporting; the private path and raw OCR diagnostic are not published. Unknown preview failures fail closed through the generic diagnostic path, and the actual review independently revalidates the background. During OCR, the built-in MCP atomically records only completed `summary`, `list`, `get`, `search`, and `coverage` counts without arguments, queries, IDs, paths, results, or content. The parent reads and removes private action receipt v2 before cleanup. Receipt v6 is approval-valid only when the primary-tool action subtotal and the two dedicated-tool counts exactly match OCR's three `tool_calls.by_tool` entries; missing, malformed, raced, or mismatched attribution is explicitly unavailable rather than zero and cannot authorize approval. Since OCR 1.9.9, `--background-file` takes precedence over inline `--background`, so `ocr-ci review` rejects caller forms of both options, including split and `--option=value` syntax, and remains the sole owner of the bootstrap input; caller `--preview` is likewise rejected because the toolkit owns this gate. OCR 1.10.0 adds `--output`, but `ocr-ci review` rejects its long, equals, short, and attached forms because the toolkit must remain the sole owner of the private result descriptor, atomic parsing, cleanup, and posting handoff. The 1.9.10 stage-grouped terminal retry report remains private and does not enter toolkit telemetry, receipts, DLP, findings, severity, outcomes, or approval. A completed OCR review is accepted only when structured `tool_calls.by_tool` proves at least one `ocr_toolkit_evidence` call; a legitimately skipped no-supported-files review remains exempt. +`ocr-ci review` owns this lifecycle. Before OCR starts it collects the exact immutable `--from`/`--to` refs (or the parent/commit pair selected by `--commit`), writes bounded redacted schema-versioned evidence, builds OCR's MCP registry with the mandatory evidence entry plus each independently configured optional server, reads the registry back, self-queries the evidence summary/list/get contract, and supplies the matching compact bootstrap to OCR. Those parent-process preflight calls are not counted as model use. The same preflight-qualified OCR executable first receives the exact production refs, rules, selection options, and background under `review --preview`; there is no toolkit threshold setting or duplicated OCR threshold constant. An exact recognized OCR soft background diagnostic becomes a toolkit-authored numeric `ocr.toolkit-advisory/v1` value only after publication DLP and appears in the bounded CI log plus GitLab Technical details. It is not an OCR warning, receipt or DLP input, coverage signal, telemetry field, or approval blocker. An exact recognized hard character/file-size rejection stops before model execution and leaves only an identity-bound numeric `ocr.pre-execution-status/v2` outcome for static GitLab reporting; the private path and raw OCR diagnostic are not published. Unknown preview failures fail closed through the generic diagnostic path, and the actual review independently revalidates the background. During OCR, the built-in MCP atomically records only completed `summary`, `list`, `get`, `search`, and `coverage` counts without arguments, queries, IDs, paths, results, or content. The parent reads and removes private action receipt v2 before cleanup. Receipt v7 is approval-valid only when the primary-tool action subtotal and the two dedicated-tool counts exactly match OCR's three `tool_calls.by_tool` entries; missing, malformed, raced, or mismatched attribution fails review finalization before a normal publishable result exists. Since OCR 1.9.9, `--background-file` takes precedence over inline `--background`, so `ocr-ci review` rejects caller forms of both options, including split and `--option=value` syntax, and remains the sole owner of the bootstrap input; caller `--preview` is likewise rejected because the toolkit owns this gate. OCR 1.10.0 adds `--output`, but `ocr-ci review` rejects its long, equals, short, and attached forms because the toolkit must remain the sole owner of the private result descriptor, atomic parsing, cleanup, and posting handoff. The 1.9.10 stage-grouped terminal retry report remains private and does not enter toolkit telemetry, receipts, DLP, findings, severity, outcomes, or approval. A completed OCR review is accepted only when structured `tool_calls.by_tool` proves at least one `ocr_toolkit_evidence` call; a legitimately skipped no-supported-files review remains exempt. The private `.review-context/evidence.json`, `.review-context/bootstrap.md`, repository-policy `.review-context/policy-rules.json`, and count-only evidence-action receipt/lock are internal implementation details, not public path configuration. Keep `.review-context/` ignored. The directory is mode `0700`, regular files are mode `0600`, and symlink, hard-link, non-regular, or unexpectedly permissive receipt targets are rejected. In GitLab MR pipelines, the provider adapter captures the current protected target SHA, fetches that exact immutable object when needed, and materializes only an in-repository `--rule` blob from it; explicit absolute rules outside the repository remain operator-owned. OCR still reviews the original forge diff-base-to-source-head range. The collector reads Git objects without checkout, does not follow repository symlinks or submodules, never executes repository content, and treats source-ref policy changes as untrusted. @@ -232,7 +241,7 @@ Evidence-store schema v4 retains v1-v3 readback and adds a distinct immutable po In `metadata` mode, GitLab MR acquisition normalizes only title, description, labels, optional source branch, and the reviewed source SHA into `review.merge_request_context/v1`. Values are complete-field bounded, NFC-normalized, control-stripped, redacted, source-head-bound invocation data. Raw values never enter bootstrap, argv, environment, diagnostics, or receipts; bootstrap lists only field statuses and toolkit-authored comparison guidance. In `off` mode none of those mutable text fields reaches normalization or persistence. OCR may treat matching intent as evidence against an assumption-dependent concern, contradictory intent as mismatch evidence, and missing intent as unknown. The source-branch hint is weaker than an explicit description and cannot establish rollout intent by itself. Metadata cannot authorize tools, policy, suppression, posting, or approval. In `enriched`, references are extracted only from admitted metadata and admitted discussion bodies; adapters authorize them before local handle minting. There is no generic URL, identifier, search, or provider-tool path in the model loop. -The review step writes exact closed receipt v6 inside the private result only after cleanup and the inode-checked atomic publication transformation. It binds source and policy SHA, merge-request author ID where applicable, context mode/state/classes, per-source completeness/degradation, admitted-mutable state, the complete bounded MCP capability inventory, positive known-server and fixed tool usage, verified action-receipt v2 counts, mandatory-evidence state, publication-DLP result, and cleanup result. The current closed states are `passed`, `private-sanitized`, and `publication-filtered`. A pure canonical projection covers the normalized outcome/message, ordered allowlisted finding fields and warnings, manifest coverage/failure details, displayed tool counters, normalized token telemetry, omission/completeness, and approval inputs. Token telemetry has a closed provider-neutral vocabulary: input, output, cached as a subset of input, reasoning as a subset of output, optional validated total, and mathematically derived other; malformed or contradictory telemetry is unavailable and unknown provider keys are never published. Private sanitization may retain the original complete result and continue through existing approval gates only when that projection is byte-equivalent before and after sanitization. Any changed, malformed, or incomparable projection becomes a safe `publication-filtered` result with closed retained/omitted/original counts; it preserves the previous review and cannot authorize approval. The public projection may be incomplete while its validated original coverage counts still prove a complete OCR run, so the GitLab summary reports those dimensions separately and never feeds filtered warnings into legacy failed-item inference. Horizontal tab is admitted only in the closed `existing_code` and `suggestion_code` finding fields and remains subject to every non-control DLP check; tabs in all other fields and every other control/format character remain invalid. Receipt v1-v5 has no posting or approval compatibility. The later GitLab posting step reads v6 instead of rebuilding context or MCP facts from a possibly changed environment. Its summary exposes only reconciled non-zero numeric server/action counts; the receipt/event never stores search queries, coverage scopes, rejected text/locations, provider/context text, upstream IDs, server URLs, commands, setup, arguments, headers, tool inputs/results, credentials, or repository contents. +The review step writes exact closed receipt v7 inside the private result only after cleanup and the inode-checked atomic publication transformation. It binds source, policy, and target SHA, the actual closed `protected|unprotected` target state, merge-request author ID where applicable, context mode/state/classes, per-source completeness/degradation, admitted-mutable state, the complete bounded MCP capability inventory, positive known-server and fixed tool usage, verified action-receipt v2 counts, mandatory-evidence state, publication-DLP result, and cleanup result. The current closed states are `passed`, `private-sanitized`, and `publication-filtered`. A pure canonical projection covers the normalized outcome/message, ordered allowlisted finding fields and warnings, manifest coverage/failure details, displayed tool counters, normalized token telemetry, omission/completeness, and approval inputs. Token telemetry has a closed provider-neutral vocabulary: input, output, cached as a subset of input, reasoning as a subset of output, optional validated total, and mathematically derived other; malformed or contradictory telemetry is unavailable and unknown provider keys are never published. Private sanitization may retain the original complete result and continue through existing approval gates only when that projection is byte-equivalent before and after sanitization. Any changed, malformed, or incomparable projection becomes a safe `publication-filtered` result with closed retained/omitted/original counts; it preserves the previous review and cannot authorize approval. The public projection may be incomplete while its validated original coverage counts still prove a complete OCR run, so the GitLab summary reports those dimensions separately and never feeds filtered warnings into legacy failed-item inference. Horizontal tab is admitted only in the closed `existing_code` and `suggestion_code` finding fields and remains subject to every non-control DLP check; tabs in all other fields and every other control/format character remain invalid. Receipt v1-v6 has no posting or approval compatibility. The later GitLab posting step reads v7 instead of rebuilding context or MCP facts from a possibly changed environment. Its summary exposes only reconciled non-zero numeric server/action counts; the receipt/event never stores search queries, coverage scopes, rejected text/locations, provider/context text, upstream IDs, server URLs, commands, setup, arguments, headers, tool inputs/results, credentials, or repository contents. ### Accepted project decisions diff --git a/docs/gitlab.md b/docs/gitlab.md index a7475e4..070873e 100644 --- a/docs/gitlab.md +++ b/docs/gitlab.md @@ -18,6 +18,8 @@ The complete variable inventory, owner, requirement, exact default, and behavior The public pipeline stores the OCR binary checksum as the non-secret `OCR_SHA256` pin. Store actual credentials as masked, protected CI variables; do not place their values in YAML, command arguments, repository evidence, or the generated bootstrap. GitLab job tokens are not accepted for posting. +The pipeline explicitly sets `OCR_GITLAB_TARGET_PROTECTION_MODE: "required"`. This is the secure default: an unprotected target is rejected before OCR or model execution, and an explicit empty, malformed, mixed-case, or unknown value also fails closed. Set exact `unprotected` only when the project deliberately permits unprotected targets. That setting does not constrain a target GitLab reports as protected; normal protected behavior remains available. When the actual target is unprotected, receipt v7 enforces limited, comment-only review regardless of `OCR_AUTO_APPROVE`. + `OCR_REVIEW_LANGUAGE` defaults to `English`; `Russian` is one example of an explicit review language. The example pins its qualified OCR release and sets `OCR_REVIEW_EFFORT=medium`, allowing two review rounds; `low` and `high` explicitly select one or three. OCR may stop early when a round adds no finding. OCR 1.11.0 scales its 15-minute per-subtask base to 15/30/45 minutes for low/medium/high, so the example allows 45 minutes at the GitLab job boundary. `OCR_MAX_TOOLS=0` selects the embedded template default `100`. Values `1-49` report normalization to `50` but remain effectively `100`; explicit `50` also cannot lower the template, and only a value above `100` raises the cap. `OCR_MAX_TOKENS_BUDGET` defaults to `0`, meaning unlimited; a positive budget may stop dispatch and produce an explicitly partial, automatic-approval-ineligible review. `OCR_LLM_MAX_COMPLETION_TOKENS` defaults to unset and separately controls only the provider request's completion/output cap. Select an explicit value only from the deployment's provider/model contract; the toolkit does not recommend or hardcode a provider-specific cap. OCR 1.11.0 semantically groups related changed files before review and filters candidates per group. Its private grouping and other-files prompts use status-first `STATUS path (+N/-M)` entries; the toolkit does not parse that inventory in production. Grouping, filtering, and multiple rounds can increase provider requests, latency, and token cost, while the manifest and aggregate budget continue to report completeness. Grouping, grace-round, provider-native reasoning/thinking, and tool-choice request state are retained only inside OCR's isolated private session. Group labels, paths, reasoning, signed/encrypted native payloads, and request controls remain untrusted: the toolkit does not publish or use them for severity, fingerprints, lifecycle commands, receipts, DLP counts, telemetry, tool/token summaries, or approval. OCR's separately configured telemetry may export high-cardinality repository-derived values, so keep `OCR_TELEMETRY_ENABLED=false` unless the exporter and retention policy are approved. @@ -34,6 +36,7 @@ The collapsed technical details keep aggregate input/output/cache token usage se | --- | --- | --- | --- | | [Identity only](../examples/gitlab/modes/identity-only.gitlab-ci.yml) | No MR title, description, labels, source branch, or discussion text | GitLab identity/policy reads before OCR | May approve when every deterministic gate passes | | [Metadata](../examples/gitlab/modes/metadata.gitlab-ci.yml) | Bounded MR title, description, labels, and source branch | GitLab reads before OCR | May approve when DLP and every other gate pass | +| [Unprotected target](../examples/gitlab/modes/unprotected-target.gitlab-ci.yml) | `off` or bounded `metadata`; exact-target Rules are untrusted guidance | Built-in immutable evidence only; no protected policy, adapters, or external MCP | Structurally comment-only when the actual target is unprotected | | [Enriched discussions](../examples/gitlab/modes/enriched-discussions.gitlab-ci.yml) | Metadata plus protected-policy generic discussions and verified remediation threads | Stable double-read GitLab snapshot before OCR | Recipe is explicitly comment-only | | [Enriched adapters](../examples/gitlab/modes/enriched-adapters.gitlab-ci.yml) | Metadata plus protected-policy discussion and adapter records | Fixed authorize-and-resolve protocol before OCR | Recipe is explicitly comment-only | | [Same-revision CI outcomes](../examples/gitlab/context/policy-ci-outcomes.json) | Protected-policy exact check names and path prefixes | Twice-read exact-head GitLab pipeline/job metadata; no logs or artifacts | Context only; never suppression or approval authority | @@ -43,6 +46,21 @@ The [bounded-context recipes](../examples/gitlab/context/) contain protected pol Direct external MCP is a different and more privileged boundary. GitLab MR execution accepts it only as remote HTTPS. Tool names, descriptions, schemas, model-chosen arguments, and results enter OCR and its private session. Use dedicated least-privilege credentials and service-side tenant/object/field/operation authorization. Do not expose generic search, arbitrary URL or identifier fetch, writes, workflow tools, or broad service credentials. +For an actually unprotected target, only `off` and bounded untrusted `metadata` context are supported. `enriched`, every present adapter setting, protected-policy discussion/remediation/CI acquisition, direct external MCP, and inherited external OCR MCP are rejected before OCR rather than silently downgraded. The mandatory toolkit-owned repository-evidence MCP remains available because it reads immutable Git objects and has no provider network or arbitrary identifier path. The configured repository Rules file is required and loaded only from the exact captured target SHA as bounded untrusted model guidance. Accepted decisions and structured target guidance are omitted; Rules cannot authorize tools, acquisition, suppression, posting, or approval. + +The normal review outcome remains authoritative. A valid unprotected receipt adds the static line *The target branch was not protected in GitLab. This review ran in limited, comment-only mode.* after the primary status line without turning complete coverage into partial coverage. Legacy, malformed, or contradictory receipts cannot produce that trusted limitation state. + +## First integration sequence + +Use two merge requests for the recommended setup: + +1. merge `examples/gitlab/rules.json` or the chosen repository Rules path and any target-owned review configuration into the target branch; +2. enable the OCR CI job in a later merge request, after the captured target commit contains that configuration. + +If review of the integration merge request itself is unnecessary, adding Rules and the CI job together is supported under the default protected-target path. The first run stops before OCR and posts the recognized setup-pending note only after the closed pre-execution status matches the effective source and diff-base identities. Retrying that same merge request cannot make source-branch Rules become target-owned, so it does not help; subsequent merge requests work after the setup change merges. Missing, malformed, stale, or mismatched status identity uses the generic fail-closed failure note instead of promising a detailed public diagnostic. + +GitLab Code Owners and code-owner approval rules are separate project merge policy. They are not prerequisites for OCR execution and cannot give unprotected repository content policy authority. Likewise, a green advisory pipeline or allowed-to-fail OCR job proves neither that `ocr-ci review` completed nor that OCR or its model executed. Inspect the review result, manifest coverage, receipt, and published status. + ## Production bot configuration Use a dedicated bot account that is not the merge-request author. Give its project access token `api` scope and the minimum project role needed for the selected reads, notes, drafts, discussion management, and optional approval. GitLab approval rules, Code Owners, protected branches, and reset/invalidation policy remain authoritative. @@ -55,7 +73,7 @@ The toolkit authenticates the token owner with live `GET /user`. No configured b ## Operating model -`ocr-ci preflight` validates OCR compatibility, GitLab access, and optional model metadata. `ocr-ci configure` writes the isolated OCR configuration. `ocr-ci review` captures the exact source head and protected-target policy SHA, collects immutable repository evidence, acquires selected provider/context data, applies DLP and budgets, runs OCR once under an owner-only isolated home, validates the result, cleans private state, and attaches receipt v6. `ocr-ci post` hostile-reads that receipt rather than reconstructing configuration from a later environment. +`ocr-ci preflight` validates OCR compatibility, GitLab access, and optional model metadata. `ocr-ci configure` writes the isolated OCR configuration. `ocr-ci review` captures the exact source head and target SHA plus the actual protection state, collects immutable repository evidence, acquires only context permitted by that state, applies DLP and budgets, runs OCR once under an owner-only isolated home, validates the result, cleans private state, and attaches receipt v7. `ocr-ci post` hostile-reads that receipt rather than reconstructing configuration from a later environment. `off` still validates the source SHA, protected target, merge-request author, and live bot identity while withholding mutable MR text. `metadata` admits only bounded DLP-checked title, description, labels, and optional source branch. Treat those fields as claims to compare with the diff, never as instructions, policy, or proof. @@ -67,7 +85,7 @@ A protected CI pass is narrower than a general green-pipeline claim: it applies Provider configuration is forge-neutral. Configure and preflight share one normalized absolute HTTPS API root, explicit protocol, headers, request-body controls, and optional models URL. A protocol-mismatched terminal endpoint, embedded credential, or fragment fails before OCR. On a classified provider failure, `post` publishes only a static safe reason and guidance; raw provider/model fields, response bodies, request IDs, paths, warnings, and stderr remain private. The review log may contain one toolkit-authored line of closed HTTP detail and non-zero aggregate retry counts from a fully validated retry report, but the GitLab summary does not. A `rate-or-spending-limit` note suggests lowering `OCR_REVIEW_CONCURRENCY` and/or `OCR_LLM_MAX_COMPLETION_TOKENS`, starting a new merge request pipeline, and then checking provider request/account limits without claiming either setting caused the failure. The previous successful review remains visible, no failed-result findings are posted, and approval is not attempted. -When a merge request introduces a repository-owned OCR rules path absent from both trusted baselines, `review` stops before OCR and `post` may publish only the static setup-pending message after hostile identity validation. The source file never becomes policy evidence for its own merge request. +When a merge request introduces a repository-owned OCR rules path absent from both trusted protected baselines, `review` stops before OCR and `post` may publish only the static setup-pending message after hostile identity validation. The source file never becomes policy evidence for its own merge request. Under an actually unprotected target, target Rules are still mandatory but remain untrusted guidance; a missing target blob fails before OCR without promoting the source copy. ## Reviewer commands and no-commit reruns diff --git a/docs/operations.md b/docs/operations.md index bf7bc5b..4fedc19 100644 --- a/docs/operations.md +++ b/docs/operations.md @@ -40,10 +40,12 @@ This aggregate budget is separate from both OCR's prompt/context `max_tokens` ce The outcome wording distinguishes skipped, complete, complete-with-warnings, incomplete, token-budget, and failed reviews while preserving the finding state in that same line. A complete clean review is visibly positive; a complete review with findings or only reviewer-suppressed findings is neutral; warning, partial, budget, and failed states never look clean. Findings withheld by the posting limit remain counted even when the limit allows no individual finding note. Recommended focus areas ranks only its copy of already-published findings by the closed severity, category, safe repository location, and stable-identity order before its existing display cap; inline and fallback discussion order, suppression, counts, security focus, and approval policy remain unchanged. OCR 1.8.5 and later manifest failures provide the canonical failed-file receipt; legacy warnings are a bounded fallback, and `summary.files_reviewed` is never treated as proof of successful coverage. Technical details label the aggregate as all OCR tool calls and retain the existing inline format while listing every admitted non-zero count for the closed native/context/evidence review set. An empty admitted list produces no tool-call line. The counts describe review activity, not per-tool token consumption: one read or search can return a different amount of context from another. Dynamic external tool names remain private; toolkit-verified MCP-server calls stay in their separate aggregate, and built-in evidence `summary`/`list`/`get` plus dedicated `search`/`coverage` counts appear only after exact action-receipt v2 and OCR by-tool reconciliation. Zero action counters, queries, scopes, IDs, token counters, and configured-but-unused MCP servers are omitted. Token usage renders only validated input/output/cached/reasoning/total/derived-other buckets; malformed or contradictory counters are unavailable and unknown provider keys are not published. Status and aggregate semantic-category emoji are enabled by default and can be disabled together with `OCR_POST_EMOJI=false`; finding labels remain text unless their separate badge mode is enabled. +When a fully validated receipt v7 records an actually unprotected target, the summary adds exactly *The target branch was not protected in GitLab. This review ran in limited, comment-only mode.* immediately after the primary status line. It appears for clean, findings, warnings, partial, budget-stopped, failed-result, and publication-filtered summaries without replacing or reclassifying the canonical outcome. The limitation is a trust and approval boundary, not a coverage result. A protected target does not show it merely because the operator allowed unprotected targets; legacy, malformed, or contradictory receipt state cannot show it as trusted status. + ## Automatic approval lifecycle `OCR_AUTO_APPROVE=true` is the default. Approval is a separate transaction only -after every current review note publishes. A review is eligible only with exact closed receipt v6, a supported complete manifest, no warnings, failures, waivers, token-budget stop, or omitted findings, no configured direct external MCP, no degraded metadata, no DLP-rejected selected source, no required context degradation, no admitted remediation context, and at most three findings. Receipt v1-v5 is rejected by posting and approval. Receipt v6 binds reviewed source/policy SHA, merge-request author ID, context mode/state, bounded configured MCP inventory and positive use, per-source completeness/degradation, admitted-mutable state, fixed context-tool use, mandatory evidence state, publication DLP, and cleanup. The receipt's admitted-mutable state is the comment-only signal for an admitted remediation thread; DLP-clean metadata, generic discussions, protected same-revision CI outcomes, and adapter records do not set it. `private-sanitized` remains eligible only when its canonical publication/approval projection is byte-equivalent; `publication-filtered` is partial and ineligible. Complete `metadata` context, complete non-remediation enrichment, and the built-in evidence/context MCP do not independently block approval. Every finding must have +after every current review note publishes. A review is eligible only with exact closed receipt v7 for a protected target, a supported complete manifest, no warnings, failures, waivers, token-budget stop, or omitted findings, no configured direct external MCP, no degraded metadata, no DLP-rejected selected source, no required context degradation, no admitted remediation context, and at most three findings. Receipt v1-v6 is rejected by posting and approval. Receipt v7 binds reviewed source, policy, and target SHA, actual `protected|unprotected` target state, merge-request author ID, context mode/state, bounded configured MCP inventory and positive use, per-source completeness/degradation, admitted-mutable state, fixed context-tool use, mandatory evidence state, publication DLP, and cleanup. An actually unprotected target is structurally ineligible: orchestration does not call the approval executor, regardless of `OCR_AUTO_APPROVE`. The receipt's admitted-mutable state is the comment-only signal for an admitted remediation thread; DLP-clean metadata, generic discussions, protected same-revision CI outcomes, and adapter records do not set it. `private-sanitized` remains eligible only when its canonical publication/approval projection is byte-equivalent; `publication-filtered` is partial and ineligible. Complete `metadata` context, complete non-remediation enrichment, and the built-in evidence/context MCP do not independently block approval on a protected target. Every finding must have severity exactly `low` and category exactly `style`, `documentation`, or `maintainability`. A complete zero-finding review is eligible. Four findings, malformed metadata, or any other severity/category are not eligible. @@ -128,9 +130,9 @@ Suppression checks both the recorded inline position and compatible fingerprints ## OCR diagnostics -Run OCR through `ocr-ci review --result PATH --stderr PATH -- ...`. This wrapper does not post to GitLab: it creates private artifacts, acquires enriched context when selected, asks the exact resolved and preflight-qualified OCR executable to preview the production refs/rules/selection/background without an LLM, then runs the model review only if OCR accepts that background. OCR owns the current recommendation and rejection thresholds; the toolkit has no threshold setting. A recognized soft diagnostic is reduced to a toolkit-authored `ocr.toolkit-advisory/v1` enum and two positive character counts. It is attached after publication DLP, rendered only with an exact receipt v6 in Technical details, and does not change warnings, coverage, DLP counts, telemetry, or automatic approval. A recognized hard character/file-size rejection stops before the model and lets `ocr-ci post` publish only a static numeric failure summary; the OCR path and raw diagnostic remain private. Unknown preview failures use the generic fail-closed diagnostic path. The ordinary review still validates the same background, the wrapper validates the complete output, and context/session/configuration data is removed. On an unclassified ordinary failure it prints only a bounded redacted stderr excerpt to the runner log; a classified provider failure keeps that excerpt private. Pass the paths and captured exit code to `ocr-ci post` afterward. Set `OCR_POST_ERROR_DETAILS=1` only when the generic path's safe excerpt should also appear in the merge-request failure note. Cleanup uncertainty blocks result publication. DLP atomically converts unsafe publication output into a safe `completed_with_errors` subset, but sanitizes unsafe private-only result fields without discarding an otherwise valid manifest or finding set. Safe findings are posted, unsafe finding content/warnings and unsafe optional fields are omitted, previous OCR comments remain, and matching prior findings are consumed one-for-one rather than duplicated. Receipt v6 and the `ocr.publication-dlp-signal/v2` marker distinguish `private-sanitized`, where the canonical published and approval-relevant projection is unchanged, from approval-ineligible `publication-filtered`, where the public projection is incomplete even when the receipt proves OCR coverage was complete. Posting uses only the validated original coverage kind and counts for status and Technical details; it never reconstructs or republishes rejected values. Horizontal tabs survive only in `existing_code` and `suggestion_code`; the unchanged values pass the same secret, PII, forbidden-value, laundering, and budget checks, while tabs elsewhere and all other control/format characters remain blocking. The same count-only JSON is logged as `OCR toolkit telemetry event` for optional CI collection/alerting. It is not an OTLP/network exporter and contains no rejected value or location. Never interpret a filtered subset as a full publishable review or as proof that OCR coverage was partial. +Run OCR through `ocr-ci review --result PATH --stderr PATH -- ...`. This wrapper does not post to GitLab: it creates private artifacts, acquires enriched context when selected, asks the exact resolved and preflight-qualified OCR executable to preview the production refs/rules/selection/background without an LLM, then runs the model review only if OCR accepts that background. OCR owns the current recommendation and rejection thresholds; the toolkit has no threshold setting. A recognized soft diagnostic is reduced to a toolkit-authored `ocr.toolkit-advisory/v1` enum and two positive character counts. It is attached after publication DLP, rendered only with an exact receipt v7 in Technical details, and does not change warnings, coverage, DLP counts, telemetry, or automatic approval. A recognized hard character/file-size rejection stops before the model and lets `ocr-ci post` publish only a static numeric failure summary; the OCR path and raw diagnostic remain private. Unknown preview failures use the generic fail-closed diagnostic path. The ordinary review still validates the same background, the wrapper validates the complete output, and context/session/configuration data is removed. On an unclassified ordinary failure it prints only a bounded redacted stderr excerpt to the runner log; a classified provider failure keeps that excerpt private. Pass the paths and captured exit code to `ocr-ci post` afterward. Set `OCR_POST_ERROR_DETAILS=1` only when the generic path's safe excerpt should also appear in the merge-request failure note. Cleanup uncertainty blocks result publication. DLP atomically converts unsafe publication output into a safe `completed_with_errors` subset, but sanitizes unsafe private-only result fields without discarding an otherwise valid manifest or finding set. Safe findings are posted, unsafe finding content/warnings and unsafe optional fields are omitted, previous OCR comments remain, and matching prior findings are consumed one-for-one rather than duplicated. Receipt v7 and the `ocr.publication-dlp-signal/v2` marker distinguish `private-sanitized`, where the canonical published and approval-relevant projection is unchanged, from approval-ineligible `publication-filtered`, where the public projection is incomplete even when the receipt proves OCR coverage was complete. Posting uses only the validated original coverage kind and counts for status and Technical details; it never reconstructs or republishes rejected values. Horizontal tabs survive only in `existing_code` and `suggestion_code`; the unchanged values pass the same secret, PII, forbidden-value, laundering, and budget checks, while tabs elsewhere and all other control/format characters remain blocking. The same count-only JSON is logged as `OCR toolkit telemetry event` for optional CI collection/alerting. It is not an OTLP/network exporter and contains no rejected value or location. Never interpret a filtered subset as a full publishable review or as proof that OCR coverage was partial. -OCR 1.11.1 may add group labels, file membership, grouping-task records, round diagnostics, readable reasoning, and provider-native signed or encrypted replay payloads to its private session data. Safe values remain private; the isolated OCR home is removed before publication. Unknown private-only result fields are sanitized through the existing DLP owner, while any value reaching a canonical finding or warning makes publication incomplete and approval-ineligible. Reasoning, native payloads, and `tool_choice` are deliberately absent from the canonical finding/posting projection, receipt v6, DLP counts, toolkit telemetry, tool/token summary, and approval inputs. If they appear inside receipt v6, the receipt is invalid and approval fails closed. Caller `--output`/`-o` is rejected before preview: only `ocr-ci review --result` owns the result descriptor and posting handoff. +OCR 1.11.1 may add group labels, file membership, grouping-task records, round diagnostics, readable reasoning, and provider-native signed or encrypted replay payloads to its private session data. Safe values remain private; the isolated OCR home is removed before publication. Unknown private-only result fields are sanitized through the existing DLP owner, while any value reaching a canonical finding or warning makes publication incomplete and approval-ineligible. Reasoning, native payloads, and `tool_choice` are deliberately absent from the canonical finding/posting projection, receipt v7, DLP counts, toolkit telemetry, tool/token summary, and approval inputs. If they appear inside receipt v7, the receipt is invalid and approval fails closed. Caller `--output`/`-o` is rejected before preview: only `ocr-ci review --result` owns the result descriptor and posting handoff. When OCR exits nonzero with a valid bounded `ocr.llm-retry-report/v1`, the toolkit reads only its closed error class, failure phase, terminal outcome, HTTP status, and schema counters. It maps those facts to `authentication`, `authorization`, `rate-or-spending-limit`, `overloaded`, `timeout`, `network`, `endpoint-or-model-not-found`, `request-rejected`, `provider-unavailable`, `invalid-response`, `cancelled`, `mixed`, or `unknown`, then writes a completely toolkit-authored note. A runtime `404` remains `endpoint-or-model-not-found` because safely distinguishing the endpoint from the model would require trusting the raw response body. @@ -140,10 +142,12 @@ The `rate-or-spending-limit` note keeps that intentionally broad public reason a Raw provider/model identities, response bodies, error codes and messages, request IDs, paths, warnings, and stderr remain in owner-only private artifacts for a classified provider failure. `OCR_POST_ERROR_DETAILS=1` cannot add them to that note or the numeric line. Normal findings from the failed result are ignored, the previous successful review is preserved, and automatic approval is not attempted. Missing, oversized, malformed, or internally contradictory retry reports keep the existing generic failure path instead of guessing a classification. -For a local diagnosis, add `--preserve-private-artifacts` before the `--` separator. The command retains the isolated OCR home plus repository-local private evidence/context artifacts, prints only their paths, and deliberately leaves the raw OCR result without receipt v6; do not pass that result to `ocr-ci post`. It writes `.review-context/private-dlp-decisions.json` with value-free bounded JSON paths, closed reason and detector subtype, size units, and SHA-256 for up to 1,000 rejected keys/values, plus explicit truncation and omitted-decision counts. Use matching digests to identify one repeated technical value and inspect the retained raw result locally before deciding whether a conservative PII match is a false positive; the sidecar itself is not proof that content is safe. These owner-only files can contain source/provider context, prompts, model responses, tool arguments/results, and generated runtime configuration. Inspect them locally, keep them out of commits and shared artifacts, then delete them after extracting the needed evidence. Ordinary runs do not retain this attribution. The authoritative GitLab merge-request profile rejects the flag before OCR execution and performs normal cleanup; an arbitrary `CI=true` value neither grants nor blocks the local mode. +For a local diagnosis, add `--preserve-private-artifacts` before the `--` separator. The command retains the isolated OCR home plus repository-local private evidence/context artifacts, prints only their paths, and deliberately leaves the raw OCR result without receipt v7; do not pass that result to `ocr-ci post`. It writes `.review-context/private-dlp-decisions.json` with value-free bounded JSON paths, closed reason and detector subtype, size units, and SHA-256 for up to 1,000 rejected keys/values, plus explicit truncation and omitted-decision counts. Use matching digests to identify one repeated technical value and inspect the retained raw result locally before deciding whether a conservative PII match is a false positive; the sidecar itself is not proof that content is safe. These owner-only files can contain source/provider context, prompts, model responses, tool arguments/results, and generated runtime configuration. Inspect them locally, keep them out of commits and shared artifacts, then delete them after extracting the needed evidence. Ordinary runs do not retain this attribution. The authoritative GitLab merge-request profile rejects the flag before OCR execution and performs normal cleanup; an arbitrary `CI=true` value neither grants nor blocks the local mode. Two pre-execution outcomes have narrower static reporting. When the merge request introduces the configured repository-owned OCR rules path and that exact path is absent from both immutable policy-side baselines, `review` verifies only that the source object is a bounded regular blob, writes a closed private status, and stops before OCR. When installed OCR rejects the generated background during preview, the status instead carries the closed character/file-size reason plus actual, limit, and unit. `post` hostile-validates either v2 status against the current source and diff-base identities and renders only toolkit-authored text. A successful retry replaces only an earlier toolkit-owned setup-pending note; background rejection and generic failures never replace previous review findings or summaries. Neither outcome includes the rules/background path or raw stderr and neither trusts repository/provider display text. Malformed, stale, unsafe, unknown, or identity-mismatched state falls back to the generic failure note. `OCR_POST_EMOJI=false` removes the heading emoji; `OCR_POST_ERROR_DETAILS` does not add detail to recognized static outcomes. +The setup-pending case is the supported one-merge-request installation path, not a completed review. Retrying the same merge request does not help because the captured target commit still lacks target-owned Rules; merge the setup change and use a later merge request. In detached pipelines, the recognized note uses the same validated effective reviewed SHA as execution: a valid non-zero MR SHA takes precedence, while an absent or all-zero MR SHA may use valid `CI_COMMIT_SHA`. If neither identity is valid or the status does not match it, the generic fail-closed note is intentional. A green advisory job still does not prove OCR/model execution; require a valid result, complete manifest coverage where expected, reconciled receipt/action counts, and the published review state. + ## GitLab identity and permissions Use a dedicated project access token with `api` scope and at least the Developer role. Store it in `GITLAB_API_TOKEN`. The toolkit needs to read merge-request notes, discussions, diff refs, approval state, and the current token identity; create and delete its own notes or drafts; publish drafts; resolve discussions requested by reviewers; and, unless opted out, approve as that dedicated identity. GitLab must separately consider the identity eligible under the project's approval rules. diff --git a/docs/review-context.md b/docs/review-context.md index 989f4b2..39bbb0a 100644 --- a/docs/review-context.md +++ b/docs/review-context.md @@ -10,7 +10,9 @@ Open Code Review Toolkit can enrich one validated forge review with bounded merg - `metadata` additionally admits bounded title, description, labels, and source-branch text. - `enriched` requires a validated GitLab merge-request environment and a valid protected-target policy. It includes the same metadata projection plus selected stable GitLab discussions, verified toolkit-owned remediation threads, protected same-revision CI outcomes, and policy-recognized external records. Missing or invalid policy stops the review before OCR. -The lifecycle is fixed: capture the protected-target SHA; load policy from that immutable object; acquire and authorize records; normalize, DLP-check, and atomically commit the private context store; run one OCR review in an isolated home; serve only local handles; remove session, adapter, and context artifacts; then validate/project the complete OCR result and attach receipt v6 through one inode-checked atomic replacement. A cleanup or publication-validation failure blocks ordinary result publication. +`OCR_GITLAB_TARGET_PROTECTION_MODE` is a separate closed selector. Unset `required` rejects an unprotected target before OCR. Exact `unprotected` is an explicit opt-in, but constrains only a target GitLab actually reports unprotected. Such a run allows context `off` or bounded untrusted `metadata`; rejects `enriched`, any adapter setting, discussion/remediation/CI policy acquisition, and direct or inherited external MCP; preserves only the built-in immutable evidence MCP; and omits accepted decisions plus structured project guidance. Required repository Rules come from the exact target SHA as untrusted model guidance and cannot enable a tool, acquisition, suppression, posting permission, or approval. A protected target keeps the normal lifecycle under either valid operator setting. + +The protected lifecycle is fixed: capture the protected-target SHA; load policy from that immutable object; acquire and authorize records; normalize, DLP-check, and atomically commit the private context store; run one OCR review in an isolated home; serve only local handles; remove session, adapter, and context artifacts; then validate/project the complete OCR result and attach receipt v7 through one inode-checked atomic replacement. A constrained unprotected run skips protected-policy acquisition and the context store entirely. Receipt v7 binds the exact source SHA, target/policy SHA, and actual protection state; unprotected is structurally approval-ineligible. A cleanup or publication-validation failure blocks ordinary result publication. ## Protected-target policy @@ -127,7 +129,7 @@ Candidates are extracted only from admitted merge-request metadata and admitted Projection fields are sorted unique lists. `model`, `publish`, and `retain` must each be subsets of `retrieve`. Retention is limited to `state`, `count`, `digest`, `version`, and `expiry`; it cannot retain text, upstream identifiers, URLs, commands, transport data, or personal display data. Retrieval, model egress, publication, and retention are deliberately separate decisions. -Policy `ocr.review-context-policy/v1` remains accepted for existing protected configurations and supports aggregate budgets, `forge_discussions`, and references. Policy `ocr.review-context-policy/v2` adds optional `remediation_threads`. Policy `ocr.review-context-policy/v3` adds optional protected `ci_outcomes`; v1/v2 reject that selector instead of interpreting it with weaker semantics. New CI examples use v3 while existing discussion/remediation examples remain valid v2. This compatibility is for reviewed policy documents, not persisted runtime state: reviews and stores are ephemeral, and the private store accepts only `ocr.context-store/v2`. Adapter frames and receipt v6 likewise require their exact schema. Discriminators prevent an old or different field set from inheriting current authorization or approval meaning; there is no store or receipt migration path. +Policy `ocr.review-context-policy/v1` remains accepted for existing protected configurations and supports aggregate budgets, `forge_discussions`, and references. Policy `ocr.review-context-policy/v2` adds optional `remediation_threads`. Policy `ocr.review-context-policy/v3` adds optional protected `ci_outcomes`; v1/v2 reject that selector instead of interpreting it with weaker semantics. New CI examples use v3 while existing discussion/remediation examples remain valid v2. This compatibility is for reviewed policy documents, not persisted runtime state: reviews and stores are ephemeral, and the private store accepts only `ocr.context-store/v2`. Adapter frames and receipt v7 likewise require their exact schema. Discriminators prevent an old or different field set from inheriting current authorization or approval meaning; there is no store or receipt migration path. ## Operator adapter allowlist @@ -197,11 +199,11 @@ Both tools read the already committed local store. They have no network, subproc After OCR exits, the toolkit applies separate publication-sink and private-retention projections against forbidden/non-publishable context, configured secrets, closed PII patterns, controls, Markdown destinations, and Unicode/HTML/Markdown laundering forms. Publication sinks are exactly the result values the posting owner can render: finding fields, warnings, outcome message, displayed tool names, and manifest failure path/reason fields. Other OCR metadata remains private but is independently scanned and sanitized before persistence; it cannot make safe publication sinks partial merely because an opaque SHA, UUID, item identity, or bare build number resembles a phone number. The conservative detector can still classify a separator-bearing technical identifier as phone-like. Ordinary receipts intentionally expose only closed aggregate counts; an explicit local preservation run adds a private value-free path/subtype/size/hash decision sidecar so the operator can diagnose that false-positive class against the separately retained raw result without weakening the filter or disclosing the value. The checks compare whole values and normalized contiguous excerpts of at least 24 characters; a work-bound uncertainty filters or sanitizes the affected unit. This does not claim detection of shorter arbitrary excerpts or semantic paraphrases, and it cannot reverse data already sent to the model. -An unsafe result is neither retained raw nor discarded wholesale. In the same inode-checked atomic transformation used to attach the receipt, unsafe publication content produces a safe `completed_with_errors` projection with independently passed finding/warning fields, fixed tool-use counters, original closed coverage counts, and closed DLP reason/retained/omitted counts. Unsafe `content` removes its finding; an unsafe optional finding field is removed without discarding safe finding content. Horizontal tab is permitted only in `existing_code` and `suggestion_code`, where the unchanged value still passes every secret, PII, forbidden-value, laundering, and budget detector before it is retained. When every publication sink is safe and only private result metadata fails retention DLP, the unsafe private fields are replaced with static non-sensitive values while the original valid status, manifest, warnings, and findings remain. Unsafe values and their locations are never retained. Receipt v6 calls this `private-sanitized` only when a pure canonical publication/approval projection is byte-equivalent before and after sanitization; normal approval evaluation then applies. Any changed, malformed, or incomparable projection is `publication-filtered`, preserves the previous review, emits only closed counts, and cannot automatically approve. Its public projection is incomplete, but posting uses the validated original coverage kind/counts so complete OCR coverage is not relabelled as partial and filtered warnings cannot synthesize failed items. +An unsafe result is neither retained raw nor discarded wholesale. In the same inode-checked atomic transformation used to attach the receipt, unsafe publication content produces a safe `completed_with_errors` projection with independently passed finding/warning fields, fixed tool-use counters, original closed coverage counts, and closed DLP reason/retained/omitted counts. Unsafe `content` removes its finding; an unsafe optional finding field is removed without discarding safe finding content. Horizontal tab is permitted only in `existing_code` and `suggestion_code`, where the unchanged value still passes every secret, PII, forbidden-value, laundering, and budget detector before it is retained. When every publication sink is safe and only private result metadata fails retention DLP, the unsafe private fields are replaced with static non-sensitive values while the original valid status, manifest, warnings, and findings remain. Unsafe values and their locations are never retained. Receipt v7 calls this `private-sanitized` only when a pure canonical publication/approval projection is byte-equivalent before and after sanitization; normal approval evaluation then applies. Any changed, malformed, or incomparable projection is `publication-filtered`, preserves the previous review, emits only closed counts, and cannot automatically approve. Its public projection is incomplete, but posting uses the validated original coverage kind/counts so complete OCR coverage is not relabelled as partial and filtered warnings cannot synthesize failed items. The summary contains distinct private-sanitization and publication-filtering details with an exact `ocr.publication-dlp-signal/v2` HTML marker containing only low-cardinality counts. The posting command emits the same JSON as an `OCR toolkit telemetry event` log line so CI log collectors can alert without a new runtime network/exporter path. OCR remains authoritative for provider/token/request telemetry; the toolkit does not add an OTLP client, arbitrary telemetry endpoint, raw value/path, or mandatory external export. The explicit local `ocr-ci review --preserve-private-artifacts` diagnostic retains owner-only raw session/context state, adds a value-free `ocr.private-dlp-decisions/v1` path/reason/detector/size/hash attribution sidecar, and deliberately emits no posting receipt. It is local evidence for investigating conservative false positives, not a CI or publication artifact; the validated GitLab MR profile rejects it. -Receipt v6 stores only closed review/policy identities, context mode, per-source completeness and degradation counts, admitted-mutable state, fixed evidence/context tool-use counts, publication-DLP result, and cleanup result. It does not store context text, provider IDs, URLs, commands, arguments, headers, adapter results, personal display data, or transport diagnostics. Receipt v1-v5 is rejected; ephemeral results have no migration path. +Receipt v7 stores only closed source/target/policy identities, actual `protected|unprotected` target state, context mode, per-source completeness and degradation counts, admitted-mutable state, fixed evidence/context tool-use counts, publication-DLP result, and cleanup result. It does not store context text, provider IDs, URLs, commands, arguments, headers, adapter results, personal display data, or transport diagnostics. Receipt v1-v6 is rejected; ephemeral results have no migration path. Only a fully validated unprotected receipt adds the static italic comment-only limitation; that line does not change result completeness or status. A protected target allowed by the permissive operator setting has no limitation line. Every existing manifest, coverage, warning, omission, finding, source-SHA, author, provider, and self-approval gate remains. Degraded selected metadata, a DLP-rejected selected source, required-source degradation, and any admitted remediation record make the run ineligible. DLP-clean generic discussions and adapter records do not independently block approval; optional non-DLP degradation remains visible and cannot prove absence. A complete enriched run without admitted remediation is not blocked solely by the selected mode. Direct operator MCP is a separate privileged boundary and remains comment-only. diff --git a/docs/security.md b/docs/security.md index a7773f5..5f45b7b 100644 --- a/docs/security.md +++ b/docs/security.md @@ -17,13 +17,13 @@ An operator controls CI configuration, direct MCP servers, adapter commands/endp ### Trust transitions 1. Repository paths and immutable Git blobs enter bounded collection and parsing. -2. Provider MR identity always enters closed validation. Optional bounded metadata enters normalization in `metadata` or `enriched`; target-derived rules, guidance, decisions, and enriched-context policy come only from the captured protected-target SHA. +2. Provider MR identity always enters closed validation. The operator's exact `required|unprotected` selector and GitLab's actual target-protection response are separate inputs. Optional bounded metadata enters normalization in `metadata` or `enriched`; privileged target-derived guidance, decisions, and enriched-context policy come only from the captured protected-target SHA. In constrained unprotected mode, only exact-target Rules remain, as untrusted model guidance without policy authority. 3. In `enriched`, the GitLab owner acquires a repeated bounded discussion snapshot. Deterministic recognizers emit syntax-only candidates; the broker combines protected policy with the operator adapter allowlist, and the adapter authorizes one tenant/object/field request before admission. 4. Normalized retrieval/model/publication/retention projections cross independent DLP decisions and an owner-only atomic context store. A handle is minted only for a committed record and binds run, policy, adapter, tenant, object digest, version, projection, and expiry. 5. Typed repository evidence crosses into the compact bootstrap and mandatory read-only evidence MCP. During enriched OCR, only the same built-in process's fixed `context_list`/`context_get` can read committed local handles; adapter/provider network paths are absent from the model loop. 6. Persisted evidence, context, results, OCR configuration, private pre-execution status, and receipts re-enter hostile parsing. OCR runs in a fresh isolated home; session, configuration, adapter, and context data crosses deterministic cleanup before a result becomes publishable. 7. In the direct-MCP GitLab profile, operator-configured external MCP is remote HTTPS only; developer-local execution may pass explicit stdio command/setup configuration. Server-authored descriptions and schemas cross into plan and main model context; model-generated arguments cross to allowed tools; textual responses cross back to the model and OCR session. This path is separate from M5 adapters. -8. The complete successful OCR result crosses independent publication DLP, deterministic GitLab publication, suppression, and receipt-v6 approval policy. On non-zero OCR exit with a valid bounded `ocr.llm-retry-report/v1`, only a closed provider-neutral reason may cross into a toolkit-authored failure note; one separate local line may contain closed protocol detail, a shared HTTP status, and bounded aggregate counts from the same strict parser. Raw result fields and stderr remain private, normal findings are ignored, and approval is unreachable. Missing or invalid retry diagnostics use the pre-existing generic failure boundary. +8. The complete successful OCR result crosses independent publication DLP, deterministic GitLab publication, suppression, and receipt-v7 approval policy. Receipt v7 binds exact source/target identity and actual protection state. An unprotected receipt can add only the static limitation and is structurally barred from the approval executor. On non-zero OCR exit with a valid bounded `ocr.llm-retry-report/v1`, only a closed provider-neutral reason may cross into a toolkit-authored failure note; one separate local line may contain closed protocol detail, a shared HTTP status, and bounded aggregate counts from the same strict parser. Raw result fields and stderr remain private, normal findings are ignored, and approval is unreachable. Missing or invalid retry diagnostics use the pre-existing generic failure boundary. 9. A release candidate crosses protected-base authorization, publication, provenance, and live readback. ### Security objectives @@ -38,7 +38,7 @@ An operator controls CI configuration, direct MCP servers, adapter commands/endp - Context policy cannot come from the source branch, context budgets cannot evict repository evidence, and model-facing context cannot add a network, arbitrary ID/URL, search, traversal, or write path. - Publication and retention are independent from retrieval/model egress. Cleanup uncertainty blocks publication. DLP selects exact posting sinks separately from private OCR metadata. Unsafe sinks produce an explicit safe partial result; unsafe private-only keys/values are removed or replaced before retention without discarding a still-valid manifest or safe findings. Both paths atomically destroy the rejected value/location and expose only closed counts. Publication filtering preserves prior review state, matches repeated fingerprints one-for-one, and blocks approval; private-only sanitization may continue through all existing approval gates only after exact canonical equivalence. - Classified provider failure reporting consumes only validated retry class, phase, terminal outcome, HTTP status, and schema counters. Provider/model identity, URL, response body, error code or message, request ID, path, warning text, token, and stderr cannot enter the static note or closed numeric line or become a receipt, DLP, telemetry, severity, finding, or approval signal. A status-derived label describes protocol state only; it cannot assert the provider's billing or admission cause. -- OCR semantic-group labels, path-derived group keys, file membership, and round diagnostics are untrusted private data. They may be DLP-sanitized in the owner-only result but cannot enter findings, GitLab text, receipt v6, fingerprints, severity, lifecycle commands, toolkit telemetry, or approval. Receipt extensions fail closed. +- OCR semantic-group labels, path-derived group keys, file membership, and round diagnostics are untrusted private data. They may be DLP-sanitized in the owner-only result but cannot enter findings, GitLab text, receipt v7, fingerprints, severity, lifecycle commands, toolkit telemetry, or approval. Receipt extensions fail closed. - Provider mutations bind reviewed identity where supported; ambiguous inline creates use one author-bound readback without retry, and unresolved ambiguity preserves prior state. - Secrets remain outside repository-controlled context, public notes, fixtures, and release artifacts. @@ -59,22 +59,23 @@ These observations establish the current safe-use envelope, not the safety of ar Server command, endpoint, setup, schemas, descriptions, arguments, and responses cross separate executable or untrusted boundaries. GitLab-MR external entries are remote HTTPS only and reject stdio/setup fields; local-profile `setup` remains operator-owned shell configuration executed in the analyzed repository. Existing OCR MCP configuration is hostile persisted input and is revalidated against the active profile. Raw endpoint, setup, and credential values are not safe diagnostic material. Toolkit diagnostics avoid them, but OCR may emit operator-owned transport details; keep those values non-sensitive and retain OCR stderr privately. Managed OAuth authenticates a client but does not authorize an object and remains conditional. -Receipt v6 records the bounded configured capability inventory and positive calls attributed from known tool names. For the fixed evidence MCP only, an owner-only atomic count receipt can further attribute completed `summary`, `list`, `get`, `search`, and `coverage` actions after exact reconciliation with OCR's three built-in evidence-tool totals; absence, malformed content, races, failed tool attempts, or mismatch remains unavailable and does not weaken the mandatory-summary gate or add an approval blocker. The receipt contains no arguments, IDs, paths, results, or provider content. Its publication state is exactly `passed`, `private-sanitized`, or `publication-filtered`; only a byte-equivalent canonical publication/approval projection permits private sanitization to continue through the existing approval gates. Every direct external MCP makes the review comment-only, independent of use; server-authored tool annotations are not used to upgrade that policy. The receipt is operational attribution, not security evidence for resource authorization, completeness, response safety, actual semantic use, or model output. Security severity still depends on demonstrated reachability and impact: prompt-like or Markdown text is not code execution without a privileged action path, and same-owner artifact tampering is not an ordinary-contributor escalation without a lower-privilege writer. +Receipt v7 records the bounded configured capability inventory and positive calls attributed from known tool names. For the fixed evidence MCP only, an owner-only atomic count receipt further attributes completed `summary`, `list`, `get`, `search`, and `coverage` actions after exact reconciliation with OCR's three built-in evidence-tool totals; absence, malformed content, races, failed tool attempts, or mismatch fails normal review finalization before publication. The receipt contains no arguments, IDs, paths, results, or provider content. Its publication state is exactly `passed`, `private-sanitized`, or `publication-filtered`; only a byte-equivalent canonical publication/approval projection permits private sanitization to continue through the existing approval gates. Every direct external MCP makes a protected-target review comment-only, independent of use, and is rejected for an actually unprotected target; server-authored tool annotations are not used to upgrade that policy. The receipt is operational attribution, not security evidence for resource authorization, completeness, response safety, actual semantic use, or model output. Security severity still depends on demonstrated reachability and impact: prompt-like or Markdown text is not code execution without a privileged action path, and same-owner artifact tampering is not an ordinary-contributor escalation without a lower-privilege writer. ## Preserved current safety properties - Repository reads are bounded, rooted, symlink-aware, immutable-object reads that exclude common dependency/build trees and never execute repository content. - Review context uses a closed `off|metadata|enriched` selector. `off` retains only validated source/protected-target/author identities; `metadata` admits bounded MR fields; `enriched` requires the immutable protected policy and admits only stable bounded discussion, CI-outcome, and adapter projections. Source policy, unknown fields/classes, raw display identities, arbitrary URLs/IDs, tokens, and ambient environment values cannot expand it. +- Target protection uses a separate closed selector. Unset means exact `required`; explicit empty or unknown values fail closed. Exact `unprotected` changes behavior only when GitLab reports the actual target unprotected. That run permits `off|metadata`, built-in immutable evidence, and required exact-target Rules as untrusted guidance, while rejecting enriched acquisition, any adapter setting, direct or inherited external MCP, accepted decisions, and structured target guidance before OCR. - Protected CI outcome context is acquired only from authenticated GitLab project APIs, bound to the exact reviewed SHA, normalized twice, and scoped by exact check names/path prefixes from protected policy v3. Logs, artifacts, URLs, raw IDs, users, runners, variables, provider payloads, and repository-authored status text never enter the model/store/receipt. Passing CI is contextual execution evidence only; it cannot suppress findings, prove unrelated absence, alter severity/lifecycle, or authorize approval. - Generated Markdown neutralizes controls and GitLab quick actions. Actionable suggestions require exact reviewed-head proof; unverifiable replacements retain prose only. - Result and provider reads have byte limits; notes enforce character and UTF-8 byte limits. Position-bearing inline creates reserve independent unguessable markers, classify closed outcomes, and perform at most one complete author-bound reconciliation read with no retry. - Publication DLP admits ordinary horizontal tab only in `existing_code` and `suggestion_code`. The unchanged code value is retained only after it passes the existing size, secret, PII, forbidden-value, and laundering checks; tabs elsewhere and vertical tab, form feed, NUL, bidi/format controls, and other unsupported controls remain blocking. - LLM provider URLs are normalized by one forge-neutral owner shared by configuration and preflight; only credential-free absolute HTTPS roots or protocol-compatible terminal endpoints are accepted. Explicit protocol remains authoritative, and ambiguous auxiliary URL derivation fails closed. -- Automatic approval binds the exact synchronized reviewed head and MR author from receipt v6, skips self-approval, and never removes an existing approval. Partial, warning, non-v6, publication-filtered, omitted, degraded metadata, DLP-rejected selected-source, required context degradation, admitted remediation-context, or direct external-MCP runs are ineligible. -- Built-in evidence search operates only on the already DLP-admitted store. Queries are bounded literal NFKC/case-folded tokens; regex, wildcards, operators, controls, bidi/format characters, broad empty search, and query/result echo are rejected. Results expose only existing stable IDs and closed record metadata. Coverage can authorize a negative claim only for one exact complete base/head scope with zero records and no truncation. Private action receipt v2 retains only five counters; receipt v6 requires exact by-tool reconciliation before automatic approval. +- Automatic approval binds the exact synchronized reviewed head, target SHA/protection state, and MR author from receipt v7, skips self-approval, and never removes an existing approval. An unprotected target cannot reach the approval executor. Partial, warning, non-v7, publication-filtered, omitted, degraded metadata, DLP-rejected selected-source, required context degradation, admitted remediation-context, or direct external-MCP runs are ineligible. +- Built-in evidence search operates only on the already DLP-admitted store. Queries are bounded literal NFKC/case-folded tokens; regex, wildcards, operators, controls, bidi/format characters, broad empty search, and query/result echo are rejected. Results expose only existing stable IDs and closed record metadata. Coverage can authorize a negative claim only for one exact complete base/head scope with zero records and no truncation. Private action receipt v2 retains only five counters; receipt v7 requires exact by-tool reconciliation before automatic approval. - Human replies are ownership boundaries. Merge-request source SHA, protected-target policy SHA, and merge-result SHA remain distinct. - The evidence engine stores recursively redacted typed facts/deltas in owner-only files and serves a closed bounded network-independent MCP. Absence supports a negative claim only for applicable complete scope. -- OCR rules, decisions, and guidance come only from captured protected-target blobs; source changes cannot create policy. Full guidance remains untrusted context, not authority. +- OCR rules, decisions, and guidance come only from captured target blobs; source changes cannot create policy. On a protected target they retain their existing bounded contracts. On an unprotected target only Rules are admitted, solely as untrusted guidance; decisions and structured guidance are omitted. None can authorize a privileged capability. - External finding images are disabled by default and, when enabled, use closed normalized display metadata only. - Finding fingerprints, independent write markers, complete pre-run snapshots, and explicit immutable current-run identities constrain repeated runs. Rollback deletes only recorded IDs absent from the baseline; secrets and credential-shaped values are redacted before operational output. @@ -86,7 +87,7 @@ Ansible Galaxy requirements use the same immutable-object boundary. Relative inc ## M5 bounded enrichment boundary -The v0.7.0 implementation extended the v0.6.3 selector/approval foundation with protected policy, stable GitLab discussions, deterministic recognizers, provider-neutral adapters, a separate context store, opaque handles, fixed context tools, publication DLP, isolated OCR sessions, and receipt v4. Receipt v6 now supersedes that result/posting contract without changing the established M5 acquisition boundary. The public [bounded review-context contract](review-context.md), engineering [M5 checkpoint](engineering/m5_context_contracts.md), and [test-evidence matrix](engineering/test_evidence_matrix.md) distinguish deterministic production-owner evidence from the final real-OCR and stable-release gates. +The v0.7.0 implementation extended the v0.6.3 selector/approval foundation with protected policy, stable GitLab discussions, deterministic recognizers, provider-neutral adapters, a separate context store, opaque handles, fixed context tools, publication DLP, isolated OCR sessions, and receipt v4. Receipt v7 now supersedes that result/posting contract without changing the established M5 acquisition boundary. The public [bounded review-context contract](review-context.md), engineering [M5 checkpoint](engineering/m5_context_contracts.md), and [test-evidence matrix](engineering/test_evidence_matrix.md) distinguish deterministic production-owner evidence from the final real-OCR and stable-release gates. ### Data flow and trust transitions @@ -118,7 +119,7 @@ Policy v2 adds optional toolkit-owned remediation threads without changing these - **TOCTOU/replay/cache poisoning:** bind snapshots and handles to version/ETag or digest, policy version, run, and expiry; use atomic store commits and uniform unavailable outcomes. - **PII/secret bypass and output laundering:** make retrieval, model egress, publication sinks, and private retention separate decisions. Minimize before model egress; publication DLP cannot undo prior disclosure. Scan decoded source and rendered forms so HTML comments/entities/tags, Markdown inline/reference/autolink destinations, controls, and Unicode formatting cannot hide exact protected values. Sink uncertainty yields a safe partial result; private-only uncertainty yields an atomically sanitized valid result or falls back to that partial form. Expose only low-cardinality receipt/summary/log signals in ordinary runs. The explicit local-only diagnostic switch retains owner-only raw private state, emits no receipt, and is rejected by the validated GitLab MR profile. - **Upstream session and telemetry privacy:** OCR 1.11.0 can retain readable reasoning, signed/encrypted provider-native replay payloads, tool arguments/results, sorted changed paths, model-produced group labels, and round counters in its owner-only session. Telemetry may also carry repository-derived high-cardinality grouping values. OCR telemetry is disabled by default, and the toolkit removes the isolated session home before publication. Enabling an exporter authorizes it to receive the fields OCR emits; content-logging disablement is not a promise that every identifier disappears. The toolkit neither ingests nor duplicates these values. -- **OCR advisory spoofing:** raw OCR output cannot supply `_ocr_toolkit_advisory`. The toolkit adds only the exact numeric `ocr.toolkit-advisory/v1` shape after publication DLP; posting requires a valid receipt v6 and rejects duplicate, extended, unknown, or malformed forms. The advisory cannot change warnings, coverage, DLP, telemetry, findings, or approval. +- **OCR advisory spoofing:** raw OCR output cannot supply `_ocr_toolkit_advisory`. The toolkit adds only the exact numeric `ocr.toolkit-advisory/v1` shape after publication DLP; posting requires a valid receipt v7 and rejects duplicate, extended, unknown, or malformed forms. The advisory cannot change warnings, coverage, DLP, telemetry, findings, or approval. - **Local executable and subprocess confused deputy:** accept the OCR executable only through absolute search-path entries and reject resolution inside the reviewed repository. Stdio adapter deadlines cover request delivery as well as response/exit, use a clean environment and isolated directory, and terminate the process group on uncertainty. - **Marker and retry spoofing:** recognize ownership, run-summary, and setup identities only in the exact toolkit preamble. Repository/provider text containing marker lookalikes has no authority. Duplicate findings consume visible prior occurrences one-for-one; successful setup retries replace only previous toolkit setup-pending notes. - **Approval/suppression manipulation:** every admitted remediation thread blocks automatic approval; partial, required-degraded, or DLP-rejected context cannot prove absence or restore eligibility. Safe MR metadata, generic discussions, and adapter records do not themselves block an otherwise eligible receipt. Remediation text cannot change severity, prove a fix, suppress or resolve a finding, issue a lifecycle command, or authorize approval. Existing suppression, exact slash/mention commands, fingerprints, and discussion ownership remain separate deterministic consumers. @@ -133,6 +134,8 @@ Operators must maintain a dedicated AI-readable corpus and credentials whose ser Use a dedicated bot and least-privilege forge token. Protect/mask credentials; do not expose secrets to untrusted forks. Begin with manual execution for trusted contributors and enable posting/approval only after accepting this threat model. `OCR_AUTO_APPROVE=false` keeps the bot comment-only; forge approval rules and protected branches remain authoritative. +Keep `OCR_GITLAB_TARGET_PROTECTION_MODE=required` unless the project explicitly accepts the reduced repository-policy authority of an unprotected target. GitLab Code Owners and code-owner approval rules remain separate merge policy and are not OCR prerequisites. For first setup, merge Rules and target-owned configuration before enabling OCR in a later merge request. On the protected-target path, the supported one-MR alternative stops before model execution with setup-pending status until those Rules exist on the target; an actually unprotected target with missing target Rules uses the generic fail-closed path. Treat both notes as no-review states, and never infer model execution from a green advisory pipeline. + Pin the exact recommended Open Code Review release from the [compatibility manifest](../compatibility/ocr-support.json) and verify its checksum. Pin Python and Actions dependencies. Follow the M3 direct-MCP safe-use envelope above and the exact public [configuration](configuration.md) and [bounded-context](review-context.md) contracts. Optional remote finding images add a third-party rendering boundary. External finding images are disabled by default. Finding badges are presentation-only; keep text mode when a third-party image request is unacceptable. Enabling Shields.io does not send finding prose, repository paths, project identifiers, or arbitrary OCR metadata in the image URL, but ordinary viewer, proxy, and network metadata can reach that service. Toolkit-owned Git ignores process/global/system/repository/object-store overrides and replacement refs. Existing OCR configuration is hostile persisted input and is descriptor-read, regular-file and single-link checked, byte-bounded, JSON-object validated, and atomically replaced with owner-only permissions before/after update. diff --git a/examples/gitlab/README.md b/examples/gitlab/README.md index 1270994..8c3c176 100644 --- a/examples/gitlab/README.md +++ b/examples/gitlab/README.md @@ -1,6 +1,6 @@ # GitLab operating modes -[`ocr-review.gitlab-ci.yml`](ocr-review.gitlab-ci.yml) is the complete checksum-pinned pipeline. It defaults to identity-only review and contains the install, preflight, configure, review, and post lifecycle. Copy one mode file below into the pipeline's top-level `variables` mapping, or include exactly one file after reviewing its trust boundary. +[`ocr-review.gitlab-ci.yml`](ocr-review.gitlab-ci.yml) is the complete checksum-pinned pipeline. It defaults to identity-only review, explicitly keeps `OCR_GITLAB_TARGET_PROTECTION_MODE: "required"`, and contains the install, preflight, configure, review, and post lifecycle. Copy one mode file below into the pipeline's top-level `variables` mapping, or include exactly one file after reviewing its trust boundary. The pipeline pins OCR 1.11.0 and explicitly sets `OCR_REVIEW_EFFORT=medium`, which permits two review rounds. Use `low` for one round when latency/cost is the priority, or `high` for three only after accepting the additional provider work. OCR uses a 15-minute subtask base and scales it to 15/30/45 minutes for low/medium/high; the example's 45-minute job timeout accommodates every preset without adding another environment control. Semantic grouping and filtering are OCR behavior shared by every context mode; they do not change which merge-request text a mode admits. `OCR_MAX_TOOLS=0` selects the embedded template default `100`. Values `1-49` report normalization to `50` but remain effectively `100`; only values above `100` raise the per-subtask cap. `OCR_MAX_TOKENS_BUDGET` and the optional completion cap remain independent controls. @@ -8,10 +8,15 @@ The pipeline pins OCR 1.11.0 and explicitly sets `OCR_REVIEW_EFFORT=medium`, whi | --- | --- | --- | --- | --- | | Identity only | [`modes/identity-only.gitlab-ci.yml`](modes/identity-only.gitlab-ci.yml) | None | None | May remain enabled | | Metadata | [`modes/metadata.gitlab-ci.yml`](modes/metadata.gitlab-ci.yml) | Bounded title, description, labels, and source branch | None | May remain enabled when all receipt gates pass | +| Unprotected target | [`modes/unprotected-target.gitlab-ci.yml`](modes/unprotected-target.gitlab-ci.yml) | Bounded metadata plus exact-target Rules as untrusted guidance | No protected policy, adapters, or external MCP | Structurally comment-only when the actual target is unprotected | | Enriched discussions | [`modes/enriched-discussions.gitlab-ci.yml`](modes/enriched-discussions.gitlab-ci.yml) | Metadata plus protected-policy GitLab discussions and remediation threads | GitLab reads before OCR | Explicitly disabled in the recipe | | Enriched adapters | [`modes/enriched-adapters.gitlab-ci.yml`](modes/enriched-adapters.gitlab-ci.yml) | Metadata plus policy-selected discussion and adapter records | Fixed pre-OCR adapter protocol | Explicitly disabled in the recipe | | Direct MCP | [`modes/direct-mcp.gitlab-ci.yml`](modes/direct-mcp.gitlab-ci.yml) | Metadata plus model-selected tool results | Reviewed remote HTTPS MCP during OCR | Always comment-only | +The unprotected-target recipe is an explicit exception to the secure default. It allows only `off` or `metadata`, requires the configured Rules file to exist at the exact captured target SHA, omits accepted decisions and structured target guidance, rejects enriched context, every adapter setting, and direct or inherited external MCP, and cannot automatically approve. The limitation line does not make a complete result incomplete. A protected target keeps normal behavior even with the opt-in. + The enriched-discussions mode uses [`context/policy-discussions.json`](context/policy-discussions.json). The enriched-adapters mode uses [`context/policy-adapters.json`](context/policy-adapters.json) plus one matching adapter allowlist. Copy the selected policy to the fixed protected-target path `.opencodereview/review-context-policy.json`. Direct MCP is a different, more privileged boundary: its tool descriptions, schemas, model-chosen arguments, and results enter the OCR session. Do not combine mode files until the resulting union has been reviewed deliberately. +For first setup, merge Rules and any target-owned review configuration before enabling the review job in a later merge request. On the default protected-target path, if both are introduced together, the integration merge request stops before OCR with a setup-pending note; retrying it does not move Rules into the captured target, and only later merge requests run after merge. An actually unprotected target with missing target Rules, or missing or mismatched immutable status identity, uses the generic fail-closed note instead. A green advisory pipeline/job is not evidence that OCR or the model executed. GitLab Code Owners and approval rules remain separate merge policy, not OCR prerequisites. + [`accepted-decisions.md`](accepted-decisions.md) shows a target-branch decision document. Merge it as `.opencodereview/accepted-decisions.md` before relying on it in a later merge request; source-branch additions never authorize their own review. The usage walkthrough is in [Accepted project decisions](../../docs/configuration.md#accepted-project-decisions). diff --git a/examples/gitlab/modes/unprotected-target.gitlab-ci.yml b/examples/gitlab/modes/unprotected-target.gitlab-ci.yml new file mode 100644 index 0000000..2b22711 --- /dev/null +++ b/examples/gitlab/modes/unprotected-target.gitlab-ci.yml @@ -0,0 +1,7 @@ +variables: + # Explicitly permit an unprotected target; protected targets keep normal behavior. + OCR_GITLAB_TARGET_PROTECTION_MODE: "unprotected" + # Only "off" and bounded untrusted "metadata" are allowed for an actual unprotected target. + OCR_REVIEW_CONTEXT_MODE: "metadata" + # Defense in depth; receipt v7 also makes an actual unprotected target ineligible. + OCR_AUTO_APPROVE: "false" diff --git a/examples/gitlab/ocr-review.gitlab-ci.yml b/examples/gitlab/ocr-review.gitlab-ci.yml index 305fac3..cbcaa42 100644 --- a/examples/gitlab/ocr-review.gitlab-ci.yml +++ b/examples/gitlab/ocr-review.gitlab-ci.yml @@ -12,6 +12,8 @@ variables: OCR_SHA256: "1cdc7d1f776f1cdb69850130b930e40f64accc86ecaf09600573b3600456322f" OCR_POST_MODE: "draft" OCR_STRICT_POSTING: "true" + # Secure default: reject an unprotected GitLab target before OCR. + OCR_GITLAB_TARGET_PROTECTION_MODE: "required" # Default-on receipt-bound approval; external MCP always remains comment-only. OCR_AUTO_APPROVE: "true" # Identity-only by default; use "metadata" to admit bounded MR text fields. diff --git a/tests/test_environment_contract.py b/tests/test_environment_contract.py index 31f5612..9213f5b 100644 --- a/tests/test_environment_contract.py +++ b/tests/test_environment_contract.py @@ -66,6 +66,7 @@ "CI_PIPELINE_ID": "Omitted", "CI_JOB_ID": "Omitted", "CI_PIPELINE_SOURCE": "None", + "OCR_GITLAB_TARGET_PROTECTION_MODE": "required", } EXAMPLE_DEFAULTS = { @@ -305,6 +306,7 @@ def test_example_local_defaults_match_the_pipeline() -> None: "OCR_MAX_TOOLS": "0", "OCR_MAX_TOKENS_BUDGET": "0", "OCR_REVIEW_EFFORT": "medium", + "OCR_GITLAB_TARGET_PROTECTION_MODE": "required", }.items(): assert f'{name}: "{value}"' in workflow assert ( diff --git a/tests/test_integration_contracts.py b/tests/test_integration_contracts.py index fbdd5a1..56911cf 100644 --- a/tests/test_integration_contracts.py +++ b/tests/test_integration_contracts.py @@ -303,14 +303,14 @@ def test_public_docs_describe_the_established_m5_boundary() -> None: "ocr.context-adapter-response/v1", "context_list", "context_get", - "receipt v6", + "receipt v7", "schema_version", "no store or receipt migration path", "semantic paraphrase", ): assert contract in bounded for document in (configuration, gitlab, operations, security): - assert "receipt v6" in document + assert "receipt v7" in document assert "review-context.md" in document assert "M5's foundation is established in v0.7.0" in strategy assert "M5 Bounded review-context enrichment
established" in roadmap diff --git a/tests/test_operations_docs.py b/tests/test_operations_docs.py index b613d84..dac293e 100644 --- a/tests/test_operations_docs.py +++ b/tests/test_operations_docs.py @@ -360,9 +360,9 @@ def test_context_receipt_and_mcp_profile_contracts_are_public() -> None: assert "`metadata`" in document assert "`enriched`" in document assert 'OCR_REVIEW_CONTEXT_MODE: "off"' in example - assert "receipt v6" in configuration - assert "Receipt v1-v5" in configuration - assert "Receipt v1-v5" in operations + assert "receipt v7" in configuration + assert "Receipt v1-v6" in configuration + assert "Receipt v1-v6" in operations assert "complete `metadata` context" in operations.lower() assert "Every configured direct external MCP" in configuration assert "required context degradation" in operations @@ -371,7 +371,7 @@ def test_context_receipt_and_mcp_profile_contracts_are_public() -> None: assert "sole stdio exception" in configuration -def test_builtin_search_coverage_and_receipt_v6_boundaries_are_public() -> None: +def test_builtin_search_coverage_and_receipt_v7_boundaries_are_public() -> None: """Document efficient routing without exposing search or coverage arguments.""" configuration = CONFIGURATION.read_text(encoding="utf-8") @@ -392,7 +392,7 @@ def test_builtin_search_coverage_and_receipt_v6_boundaries_are_public() -> None: "absence_authoritative=true", "Stop once the required evidence is sufficient", "action receipt v2", - "Receipt v6", + "Receipt v7", ): assert phrase in configuration assert "DLP-admitted store" in security @@ -439,6 +439,7 @@ def test_production_bot_modes_and_current_contract_are_public() -> None: "enriched-discussions.gitlab-ci.yml", "identity-only.gitlab-ci.yml", "metadata.gitlab-ci.yml", + "unprotected-target.gitlab-ci.yml", } assert {path.name for path in mode_root.glob("*.yml")} == expected_modes for mode in expected_modes: @@ -448,6 +449,60 @@ def test_production_bot_modes_and_current_contract_are_public() -> None: assert "OCR_AUTO_APPROVE" in recipe +def test_unprotected_target_contract_is_complete_and_fail_closed() -> None: + configuration = CONFIGURATION.read_text(encoding="utf-8") + gitlab = GITLAB_GUIDE.read_text(encoding="utf-8") + operations = OPERATIONS.read_text(encoding="utf-8") + security = (PROJECT_ROOT / "docs" / "security.md").read_text(encoding="utf-8") + bounded = (PROJECT_ROOT / "docs" / "review-context.md").read_text(encoding="utf-8") + example = GITLAB_EXAMPLE.read_text(encoding="utf-8") + recipe = (GITLAB_EXAMPLES / "modes" / "unprotected-target.gitlab-ci.yml").read_text( + encoding="utf-8" + ) + + assert 'OCR_GITLAB_TARGET_PROTECTION_MODE: "required"' in example + assert 'OCR_GITLAB_TARGET_PROTECTION_MODE: "unprotected"' in recipe + assert 'OCR_REVIEW_CONTEXT_MODE: "metadata"' in recipe + assert 'OCR_AUTO_APPROVE: "false"' in recipe + + for document in (configuration, gitlab, security, bounded): + assert "OCR_GITLAB_TARGET_PROTECTION_MODE" in document + assert "required" in document + assert "unprotected" in document + assert "receipt v7" in document + for phrase in ( + "Explicit empty strings", + "Context `off` and bounded untrusted `metadata`", + "any configured `OCR_REVIEW_CONTEXT_ADAPTERS_JSON` value", + "direct external MCP", + "Structured target guidance and accepted decisions are omitted", + "approval executor and GitLab approval endpoint are not reached", + ): + assert phrase in configuration + limitation = ( + "The target branch was not protected in GitLab. " + "This review ran in limited, comment-only mode." + ) + assert limitation in configuration + assert limitation in gitlab + assert limitation in operations + assert "without turning complete coverage into partial coverage" in gitlab + assert "cannot reach the approval executor" in security + assert "does not change result completeness or status" in bounded + + for phrase in ( + "Use two merge requests for the recommended setup", + "Retrying that same merge request", + "generic fail-closed failure note", + "Code Owners and code-owner approval rules", + "green advisory pipeline", + ): + assert phrase in gitlab + assert "valid non-zero MR SHA takes precedence" in operations + assert "absent or all-zero MR SHA" in operations + assert "require a valid result, complete manifest coverage" in operations + + def test_examples_and_current_public_docs_use_product_oriented_language() -> None: current_documents = ( README, From f9464f104c823c8e2b3cd60fd02a13ebe1ab0618 Mon Sep 17 00:00:00 2001 From: xeonvs <11463419+xeonvs@users.noreply.github.com> Date: Wed, 2 Sep 2026 10:20:11 +0200 Subject: [PATCH 06/15] maintenance: qualify OCR 1.11.2 --- PLANS.md | 10 +- README.md | 10 +- changelog.d/170.maintenance.md | 7 + changelog.d/170.rules.md | 1 + compatibility/evidence/ocr-1.11.2.json | 238 +++++++++++++++++++++++ compatibility/ocr-support.json | 57 +++++- docs/compatibility.md | 10 + docs/configuration.md | 16 +- docs/operations.md | 6 +- examples/gitlab/README.md | 2 +- examples/gitlab/ocr-review.gitlab-ci.yml | 6 +- scripts/ocr_compat.py | 56 +++++- src/ocr_toolkit/preflight.py | 2 +- tests/test_environment_contract.py | 6 +- tests/test_ocr_compat.py | 61 +++--- tests/test_operations_docs.py | 4 +- tests/test_runtime_helpers.py | 2 +- 17 files changed, 427 insertions(+), 67 deletions(-) create mode 100644 changelog.d/170.maintenance.md create mode 100644 changelog.d/170.rules.md create mode 100644 compatibility/evidence/ocr-1.11.2.json diff --git a/PLANS.md b/PLANS.md index c48dd6f..c5dd469 100644 --- a/PLANS.md +++ b/PLANS.md @@ -78,8 +78,14 @@ attribution, detached-pipeline diagnostics, and OCR 1.11.2 compatibility. as one complete documentation contract commit. The secure default, constrained capability matrix, two-MR and one-MR setup paths, static limitation, fail-closed diagnostics, and separate GitLab merge-policy boundary are now explicit and contract-tested. -7. [ ] Implement #170 qualification, OCR 1.11.2 pins, Rules probes, and `maintenance` plus `rules` - Towncrier entries. +7. [x] Implement #170 qualification, OCR 1.11.2 pins, Rules probes, and `maintenance` plus `rules` + Towncrier entries. Hosted Linux workflow `33508349494` establishes the baseline qualification; + the independently checksum-verified Darwin arm64 rerun matches every baseline contract and its + expanded probe selects ten qualified extensions, binds `.mjs`/`.cjs` to exact JavaScript Rules + and `.cxx`/`.hxx` to exact C++ Rules, and keeps `.svh` excluded. Security coverage across + #167-#169 exercises the effective + SHA matrix, strict evidence-action receipt reconciliation, atomic receipt identity, unprotected + context/MCP rejection, static limitation provenance, and the unreachable approval executor. 8. [ ] Review every commit and the complete `origin/main..HEAD` range, run all deterministic local gates, and fix supported findings in separately reviewed signed commits. 9. [ ] Run exactly one Codex Security diff scan for `origin/main..HEAD`, validate attack paths, fix diff --git a/README.md b/README.md index 5514508..fde54ca 100644 --- a/README.md +++ b/README.md @@ -31,11 +31,11 @@ python -m pip install open-code-review-toolkit ``` The toolkit does not bundle OCR. Download the platform asset for exact -[Open Code Review 1.11.1](https://github.com/alibaba/open-code-review/releases/tag/v1.11.1), +[Open Code Review 1.11.2](https://github.com/alibaba/open-code-review/releases/tag/v1.11.2), verify it before installation against the [compatibility manifest](compatibility/ocr-support.json), and place the verified binary on `PATH`. The current Linux amd64 digest is -`1cdc7d1f776f1cdb69850130b930e40f64accc86ecaf09600573b3600456322f`; Darwin arm64 is -`5fdf72e51aae021ac7bf43d7b9dcb160f04880f623c66e8ada5e6ae5a92e172c`. The manifest owns +`fad3c62314478822c1e90f858f3e06b3f8dc5ff0034933927c3ecd7faa5d3731`; Darwin arm64 is +`8c1a58cacca6d82d4616f121e07bd7c586a2e6c88d5b6688e3bfedf135d17ff4`. The manifest owns the other platform hashes. Verify the installation without contacting an LLM provider: @@ -45,14 +45,14 @@ ocr --version ocr-ci --help ``` -`ocr --version` must report `open-code-review v1.11.1`; `ocr-ci --help` must exit +`ocr --version` must report `open-code-review v1.11.2`; `ocr-ci --help` must exit successfully. `ocr-ci preflight` is an operational configuration check, not the installation smoke test. The exact recommended OCR release and its verified asset checksums live in the [versioned compatibility manifest](compatibility/ocr-support.json). CI should pin that release and checksum before execution. The [versioned compatibility policy](docs/compatibility.md) records tested assets and evidence and describes the conservative Dependabot-like qualification workflow for later upstream releases. Review output defaults to English. `OCR_REVIEW_LANGUAGE` accepts another explicit language name when a project needs localized review output; for example, `OCR_REVIEW_LANGUAGE=Russian`. -The current OCR 1.11.1 integration defaults `OCR_REVIEW_EFFORT` to `medium` for two review rounds. `low` and `high` are explicit one- and three-round alternatives; see the [configuration reference](docs/configuration.md#review-effort) for cost, budget, and precedence boundaries. +The current OCR 1.11.2 integration defaults `OCR_REVIEW_EFFORT` to `medium` for two review rounds. `low` and `high` are explicit one- and three-round alternatives; see the [configuration reference](docs/configuration.md#review-effort) for cost, budget, and precedence boundaries. Stable distributions are published to [PyPI](https://pypi.org/project/open-code-review-toolkit/) and mirrored as checksum-listed, provenance-attested assets in the corresponding [GitHub Release](https://github.com/xeonvs/open-code-review-toolkit/releases). Development snapshots are published only to TestPyPI. diff --git a/changelog.d/170.maintenance.md b/changelog.d/170.maintenance.md new file mode 100644 index 0000000..9356328 --- /dev/null +++ b/changelog.d/170.maintenance.md @@ -0,0 +1,7 @@ +Target checksum-verified Open Code Review 1.11.2 as the sole accepted runtime for toolkit 0.9.0. + +Changed: built-in language routing admits `.mjs` and `.cjs` under JavaScript Rules and `.cxx` and `.hxx` under C++ Rules; `.svh` remains excluded. Hosted Linux and independent Darwin qualification use checksum-verified release artifacts. + +Unchanged: result and manifest schemas, inherited completion cap `16384`, explicit positive completion-cap transport, default `medium` effort, effective max-tools, private reasoning/session data, receipt v7, DLP, telemetry ownership, and approval authority. + +Deployment: install OCR 1.11.2 directly and verify the platform checksum from the compatibility manifest; OCR 1.11.1 is historical comparison evidence, not a runtime fallback. diff --git a/changelog.d/170.rules.md b/changelog.d/170.rules.md new file mode 100644 index 0000000..f3bbac8 --- /dev/null +++ b/changelog.d/170.rules.md @@ -0,0 +1 @@ +Route `.mjs` and `.cjs` through OCR 1.11.2's built-in JavaScript Rules and `.cxx` and `.hxx` through its C++ Rules; `.svh` remains excluded. diff --git a/compatibility/evidence/ocr-1.11.2.json b/compatibility/evidence/ocr-1.11.2.json new file mode 100644 index 0000000..26795d6 --- /dev/null +++ b/compatibility/evidence/ocr-1.11.2.json @@ -0,0 +1,238 @@ +{ + "assets": [ + { + "name": "opencodereview-darwin-amd64", + "sha256": "20bbf9ebcce58a1951068cb19fd27e012e42b24b18c4c7dff61d3d6870e3f5ea", + "size": 57749072 + }, + { + "name": "opencodereview-darwin-arm64", + "sha256": "8c1a58cacca6d82d4616f121e07bd7c586a2e6c88d5b6688e3bfedf135d17ff4", + "size": 54794130 + }, + { + "name": "opencodereview-linux-amd64", + "sha256": "fad3c62314478822c1e90f858f3e06b3f8dc5ff0034933927c3ecd7faa5d3731", + "size": 56008866 + }, + { + "name": "opencodereview-linux-arm64", + "sha256": "3d74d764d9a94e0113791a6f8a04da7aebb225838867d8445f6331fb23e26b99", + "size": 52691106 + }, + { + "name": "opencodereview-windows-amd64.exe", + "sha256": "6adfb158fbf82c2f2fed05f920b9b4eedd369a14e7f36d4f60bd08041f8248dc", + "size": 57467392 + }, + { + "name": "opencodereview-windows-arm64.exe", + "sha256": "5e82df7bad7cf2f35276bc50ec0d8c4a23dedf877543a73ef4fc51ef3e237933", + "size": 53436928 + }, + { + "name": "sha256sum.txt", + "sha256": "1d997d5ced335750e50df8af0d8a0ec8b9978937d1f26047b0a2114b7a8fa9e1", + "size": 572 + } + ], + "classification": "human-review-required", + "classification_reasons": [ + "release notes contain a material or ambiguous compatibility signal" + ], + "comparison_version": "1.11.1", + "contracts": { + "comment_thinking_probe": { + "additive_field_preserved": true, + "posting_exposes_thinking": false, + "result": "passed" + }, + "completion_cap_probe": { + "explicit": 4096, + "inherited": 16384, + "result": "passed", + "wire_field": "max_completion_tokens" + }, + "language_rule_probe": { + "excluded_extensions": [ + ".svh" + ], + "extensions": [ + ".cjs", + ".cxx", + ".hxx", + ".mjs", + ".pug", + ".sv", + ".v", + ".vh", + ".vhd", + ".vhdl" + ], + "result": "passed", + "rule_source": "system_builtin", + "selected": 10 + }, + "numeric_cli_probe": { + "max_tokens_budget": { + "cases": { + "invalid_below": { + "effective": null, + "input": -1, + "outcome": "rejected" + }, + "minimum": { + "effective": 1, + "input": 1, + "outcome": "accepted" + }, + "omitted": { + "effective": "unlimited", + "input": null, + "outcome": "accepted" + }, + "representative": { + "effective": 30000, + "input": 30000, + "outcome": "accepted" + }, + "sentinel": { + "effective": "unlimited", + "input": 0, + "outcome": "accepted" + } + }, + "maximum": null, + "owner": "ocr-cli" + }, + "max_tools": { + "cases": { + "invalid_below": { + "effective": null, + "input": -1, + "outcome": "rejected" + }, + "minimum": { + "effective": 100, + "input": 50, + "outcome": "accepted" + }, + "minimum_minus_one": { + "effective": 100, + "input": 49, + "outcome": "normalized", + "reported_normalization": 50 + }, + "omitted": { + "effective": 100, + "input": null, + "outcome": "accepted" + }, + "representative": { + "effective": 101, + "input": 101, + "outcome": "accepted" + }, + "sentinel": { + "effective": 100, + "input": 0, + "outcome": "accepted" + } + }, + "maximum": null, + "owner": "ocr-template-or-higher-cli", + "reported_minimum": 50 + }, + "result": "passed" + }, + "optional_capabilities": [ + "llm_result_identity", + "per_run_model_override", + "per_run_provider_override", + "review_effort", + "semantic_grouping" + ], + "preview_probe": { + "format": "json", + "path": "example.py", + "result": "passed", + "session_store_created": false + }, + "required_review_flags": [ + "--audience", + "--background-file", + "--effort", + "--format", + "--from", + "--max-tokens-budget", + "--max-tools", + "--preview", + "--rule", + "--to" + ], + "result_contract_probe": { + "additive_fields_allowed": true, + "comment_fields": [ + "category", + "content", + "end_line", + "existing_code", + "path", + "severity", + "start_line", + "thinking" + ], + "manifest_schema": "ocr.run-manifest/v1", + "normalized_outcome": "clean", + "result": "passed" + }, + "review_budget_probe": { + "budget": 30000, + "completed": 2, + "failed_budget": 1, + "grouping_requests": 0, + "grouping_strategy": "per_file", + "partial_findings_preserved": true, + "result": "passed", + "selected": 3 + }, + "semantic_grouping_probe": { + "default_effort": "medium", + "files": 4, + "filter_requests": 1, + "grouping_completion_cap": 16384, + "grouping_requests": 1, + "main_requests": 3, + "prior_finding_semantics": "filter_survivors_as_confirmed", + "recheck_instruction_requests": 3, + "result": "passed", + "review_rounds": 2 + }, + "small_change_grouping_probe": { + "grouping_requests": 0, + "high_churn": "per_file", + "low_churn": "bundle_all", + "result": "passed", + "single_file": "per_file", + "threshold_files": 4 + }, + "target_rule_selection_probe": { + "format": "json", + "from_to_unchanged": true, + "path": "synthetic-template.ocrfixture", + "result": "passed", + "source_exclusion": "unsupported_ext", + "target_selected": true + }, + "version_probe": "passed" + }, + "published_at": "2026-09-01T13:45:04Z", + "release_changes": "## 🚀 Features\n\n- feat(cmd, viewer): open viewer URL in default browser (#1108)\n\n## 🐛 Bug Fixes\n\n- fix(rules): route .cxx and .hxx files to C++ rules (#1123)\n- fix(rules): route JavaScript module files to JS rules (#1122)\n\n## 📖 Documentation\n\n- docs(mcp): document remote setup with Parallel Search example (#1107)\n- docs(zh-CN): improve branch range wording (#1124)\n\n## Other Changes\n\n- build: keep node_modules out of the Go package list via a module boundary (#1125)\n\n**Full Changelog**: https://github.com/alibaba/open-code-review/compare/v1.11.1...v1.11.2", + "release_notes_sha256": "949b338c7996d9aaba281a89081b55b8295919eb01d89311ad50438846e54690", + "result": "compatible", + "schema_version": 3, + "tag": "v1.11.2", + "tested_baseline_version": "1.11.1", + "upstream_repository": "alibaba/open-code-review", + "version": "1.11.2" +} diff --git a/compatibility/ocr-support.json b/compatibility/ocr-support.json index 18e8c41..a449981 100644 --- a/compatibility/ocr-support.json +++ b/compatibility/ocr-support.json @@ -1,6 +1,6 @@ { - "monitoring_floor": "1.11.1", - "recommended_version": "1.11.1", + "monitoring_floor": "1.11.2", + "recommended_version": "1.11.2", "releases": [ { "assets": [ @@ -1399,6 +1399,59 @@ "release_url": "https://github.com/alibaba/open-code-review/releases/tag/v1.11.1", "status": "tested", "version": "1.11.1" + }, + { + "assets": [ + { + "name": "opencodereview-darwin-amd64", + "sha256": "20bbf9ebcce58a1951068cb19fd27e012e42b24b18c4c7dff61d3d6870e3f5ea", + "size": 57749072 + }, + { + "name": "opencodereview-darwin-arm64", + "sha256": "8c1a58cacca6d82d4616f121e07bd7c586a2e6c88d5b6688e3bfedf135d17ff4", + "size": 54794130 + }, + { + "name": "opencodereview-linux-amd64", + "sha256": "fad3c62314478822c1e90f858f3e06b3f8dc5ff0034933927c3ecd7faa5d3731", + "size": 56008866 + }, + { + "name": "opencodereview-linux-arm64", + "sha256": "3d74d764d9a94e0113791a6f8a04da7aebb225838867d8445f6331fb23e26b99", + "size": 52691106 + }, + { + "name": "opencodereview-windows-amd64.exe", + "sha256": "6adfb158fbf82c2f2fed05f920b9b4eedd369a14e7f36d4f60bd08041f8248dc", + "size": 57467392 + }, + { + "name": "opencodereview-windows-arm64.exe", + "sha256": "5e82df7bad7cf2f35276bc50ec0d8c4a23dedf877543a73ef4fc51ef3e237933", + "size": 53436928 + }, + { + "name": "sha256sum.txt", + "sha256": "1d997d5ced335750e50df8af0d8a0ec8b9978937d1f26047b0a2114b7a8fa9e1", + "size": 572 + } + ], + "capabilities": [ + "llm_result_identity", + "per_run_model_override", + "per_run_provider_override", + "review_effort", + "semantic_grouping" + ], + "evidence": "compatibility/evidence/ocr-1.11.2.json", + "evidence_sha256": "b4aabea8626cf35102c563e2eaa678e950e53f464b3a1c1032ecb55083434559", + "human_conclusion": "Compatible after hosted checksum-verified Linux qualification in workflow 33508349494 and direct adjacent source review. OCR 1.11.2 corrects built-in language routing so .mjs/.cjs use JavaScript Rules and .cxx/.hxx use C++ Rules; .svh remains excluded. Result and manifest schemas, completion cap 16384, explicit positive completion-cap transport, default medium effort, effective max-tools, private session/reasoning boundaries, receipt v7, DLP, telemetry ownership, and approval authority remain unchanged. Viewer URL opening is not invoked by the toolkit; remote-MCP and localization changes are documentation-only; the nested pages Go module only excludes site node_modules from upstream Go package enumeration.", + "published_at": "2026-09-01T13:45:04Z", + "release_url": "https://github.com/alibaba/open-code-review/releases/tag/v1.11.2", + "status": "tested", + "version": "1.11.2" } ], "schema_version": 1, diff --git a/docs/compatibility.md b/docs/compatibility.md index b6f26af..029f213 100644 --- a/docs/compatibility.md +++ b/docs/compatibility.md @@ -99,6 +99,16 @@ The project `rule.json` layer now confines the file itself and referenced rule f Deploy toolkit 0.8.7 directly with OCR 1.11.1. The public Linux amd64 example verifies SHA-256 `1cdc7d1f776f1cdb69850130b930e40f64accc86ecaf09600573b3600456322f`; Darwin arm64 verifies `5fdf72e51aae021ac7bf43d7b9dcb160f04880f623c66e8ada5e6ae5a92e172c`; and upstream `sha256sum.txt` verifies `8760d31184c12f947c182fcb00114730707892524ddf1beac78fc415cb61b37b`. +### OCR 1.11.2 — toolkit 0.9.0 target + +OCR 1.11.2 is the sole runtime accepted by toolkit 0.9.0 preflight and pinned by its GitLab example. OCR 1.11.1 remains adjacent historical comparison evidence, not a supported fallback. Hosted workflow `33508349494`, issue #170, strict schema-v3 evidence, direct adjacent source review, and an independent checksum-verified Darwin arm64 no-LLM probe preserve the OCR result and manifest schemas, inherited completion cap `16384`, explicit positive completion-cap transport, default `medium` effort, effective max-tools, private reasoning/session boundary, receipt v7, DLP, toolkit telemetry ownership, and approval authority. The toolkit default remains unset; qualification does not recommend a provider-specific cap. + +Built-in language selection now routes JavaScript modules (`.mjs`, `.cjs`) through exact pattern `**/*.{ts,js,tsx,jsx,mjs,cjs}` and C++ sources and headers (`.cxx`, `.hxx`) through exact pattern `**/*.{cpp,cc,cxx,hpp,hxx}`. Qualification proves all four files are selected, resolves their Rules from `System built-in`, and preserves the established Pug, Verilog/SystemVerilog, and VHDL coverage. `.svh` remains excluded as `unsupported_ext` rather than being inferred from the SystemVerilog name. + +The viewer URL-opening change is outside the toolkit execution path. Remote-MCP setup and localization changes are documentation-only, and the nested `pages/go.mod` boundary only excludes documentation-site `node_modules` from upstream Go package enumeration. None changes the toolkit's MCP composition, provider transport, publication, receipt, or approval contract. + +Deploy toolkit 0.9.0 directly with OCR 1.11.2. The public Linux amd64 example verifies SHA-256 `fad3c62314478822c1e90f858f3e06b3f8dc5ff0034933927c3ecd7faa5d3731`; the independently verified Darwin arm64 artifact uses `8c1a58cacca6d82d4616f121e07bd7c586a2e6c88d5b6688e3bfedf135d17ff4`; and upstream `sha256sum.txt` verifies `1d997d5ced335750e50df8af0d8a0ec8b9978937d1f26047b0a2114b7a8fa9e1`. + The contiguous 1.9.7 through 1.9.8 promotion combines the repository-owned hosted Linux qualification with adjacent upstream source review. It also repeats the required 1.9.8 contract probes through the checksum-verified Darwin arm64 artifact. Multi-tool MCP initialization, fixed tool discovery and dispatch, and owner-only session persistence remain compatible with the M5 design: the toolkit may expose several fixed tools from its one built-in process, while an isolated `HOME` contains OCR's `.opencodereview/sessions` state for deterministic cleanup. Gemini and Bedrock are optional upstream provider capabilities that the toolkit neither selects nor configures; mirror-aware installation is outside the toolkit-owned download path; native severity retains the structured result fields already consumed; and human-audience progress routing does not affect the toolkit's agent-audience invocation. Therefore this promotion requires no toolkit CI behavior adaptation beyond version and checksum pins. These source observations establish capability only; the M5 production-path tests remain responsible for proving the toolkit's composition and containment. An automatic-safe result is not an automatic stable release. It must still pass a normal protected compatibility PR and a separate signed stable-release PR. If a dedicated OCR update bot credential is not configured, the workflow publishes the exact patch as an artifact and records the resume action in the issue; the default `GITHUB_TOKEN` is intentionally not used to create a PR that would fail to trigger the full protected workflow set. diff --git a/docs/configuration.md b/docs/configuration.md index e914f2d..f3eb5eb 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -22,7 +22,7 @@ These are the complete supported toolkit-owned runtime inputs. `Required` is sco | `OCR_LLM_VALIDATE_MODEL` | Operator / `ocr-ci preflight` | No | `false` | `true` validates through `/models`; `auto` may use the offline allowlist; false values skip validation. | | `OCR_LLM_MODELS_URL` | Operator / `ocr-ci preflight` | No | Derived from `OCR_LLM_URL` | Explicit absolute credential-free HTTPS metadata URL when validation is enabled or inference query parameters make derivation ambiguous. | | `OCR_LLM_ALLOWED_MODELS` | Operator / `ocr-ci preflight` | No | Empty list | Comma-separated exact model identifiers for offline or `auto` validation. | -| `OCR_TELEMETRY_ENABLED` | Operator / `ocr-ci configure` | No | `false` | Exact `true` enables OCR telemetry configuration; OCR 1.11.1 spans may include path-derived group keys, model-produced labels, and local grouping decisions. | +| `OCR_TELEMETRY_ENABLED` | Operator / `ocr-ci configure` | No | `false` | Exact `true` enables OCR telemetry configuration; OCR 1.11.2 spans may include path-derived group keys, model-produced labels, and local grouping decisions. | | `OCR_TELEMETRY_CONTENT_LOGGING` | Operator / `ocr-ci configure` | No | `false` | Exact `true` enables OCR content logging; keep disabled for private review data. | | `OCR_TELEMETRY_EXPORTER` | Operator / `ocr-ci configure` | No | Empty string | Exporter name written only when telemetry is enabled. | | `OCR_TELEMETRY_OTLP_ENDPOINT` | Operator / `ocr-ci configure` | No | Unset | OTLP endpoint written only when telemetry is enabled and the value is non-empty. | @@ -60,13 +60,13 @@ If `OCR_LLM_EXTRA_BODY` already owns that field, an exactly equal JSON integer i The toolkit does not derive this value from `/models.max_completion_tokens`. That metadata is a model capability boundary, not an account spending limit or proof of how a gateway reserves request cost. -The inherited value is version-owned and therefore changes with a qualified OCR upgrade. The toolkit observed `max_completion_tokens=58888` from OCR 1.9.10 and `16384` from OCR 1.10.0 through 1.11.1 when the variable was unset. Grouping requests use the same template-owned cap; an explicit toolkit override still applies to every protocol request. Deployments that require an invariant gateway-specific cap must set `OCR_LLM_MAX_COMPLETION_TOKENS` explicitly rather than depending on an OCR default. +The inherited value is version-owned and therefore changes with a qualified OCR upgrade. The toolkit observed `max_completion_tokens=58888` from OCR 1.9.10 and `16384` from OCR 1.10.0 through 1.11.2 when the variable was unset. Grouping requests use the same template-owned cap; an explicit toolkit override still applies to every protocol request. Deployments that require an invariant gateway-specific cap must set `OCR_LLM_MAX_COMPLETION_TOKENS` explicitly rather than depending on an OCR default. ### Review effort -`OCR_REVIEW_EFFORT` defaults to `medium` and is written to OCR's root `effort` configuration key. OCR 1.11.1 maps `low`, `medium`, and `high` to one, two, and three review rounds and scales its 15-minute subtask base to 15, 30, or 45 minutes. The environment is operator-owned; merge-request text cannot change it. An explicit caller `--effort` passed after `ocr-ci review --` has normal OCR CLI precedence over the generated config, while an unknown environment value fails configuration before preview or model execution. +`OCR_REVIEW_EFFORT` defaults to `medium` and is written to OCR's root `effort` configuration key. OCR 1.11.2 maps `low`, `medium`, and `high` to one, two, and three review rounds and scales its 15-minute subtask base to 15, 30, or 45 minutes. The environment is operator-owned; merge-request text cannot change it. An explicit caller `--effort` passed after `ocr-ci review --` has normal OCR CLI precedence over the generated config, while an unknown environment value fails configuration before preview or model execution. -OCR 1.11.1 may present filter-surviving comments to a later round as previously confirmed, but the toolkit does not accept that wording as validation. Its mandatory background prefix travels with every main request and requires prior/filter-surviving findings to remain unverified until current code, tests, or trusted evidence support them. Survival cannot change severity, suppress or resolve a finding, authorize approval, or enter a receipt as independent validation. +OCR 1.11.2 may present filter-surviving comments to a later round as previously confirmed, but the toolkit does not accept that wording as validation. Its mandatory background prefix travels with every main request and requires prior/filter-surviving findings to remain unverified until current code, tests, or trusted evidence support them. Survival cannot change severity, suppress or resolve a finding, authorize approval, or enter a receipt as independent validation. Effort controls review depth, not the prompt/context ceiling, per-call completion cap, aggregate token budget, or per-round tool limit. Semantic grouping and filtering can add requests even at `low`; higher effort can add further rounds until OCR stops early, reaches a coverage/budget boundary, or completes the configured depth. @@ -95,13 +95,13 @@ These names belong to `examples/gitlab/ocr-review.gitlab-ci.yml`; they are shell | Variable | Source / owner | Required | Exact default | Behavior | | --- | --- | --- | --- | --- | -| **`OCR_VERSION`** | Example pipeline | Yes | `v1.11.1` | Checksum-pinned recommended OCR binary release for toolkit 0.8.7. | -| **`OCR_SHA256`** | Example pipeline | Yes | `1cdc7d1f776f1cdb69850130b930e40f64accc86ecaf09600573b3600456322f` | Expected Linux AMD64 OCR binary digest. | +| **`OCR_VERSION`** | Example pipeline | Yes | `v1.11.2` | Checksum-pinned recommended OCR binary release for toolkit 0.9.0. | +| **`OCR_SHA256`** | Example pipeline | Yes | `fad3c62314478822c1e90f858f3e06b3f8dc5ff0034933927c3ecd7faa5d3731` | Expected Linux AMD64 OCR binary digest. | | **`OCR_TOOLKIT_VERSION`** | Example pipeline | Yes | `0.8.7` | Exact toolkit wheel release installed by the current published example. | | **`OCR_TOOLKIT_CHECKSUMS_URL`** | Example pipeline | Yes | Release URL derived from `OCR_TOOLKIT_VERSION` | Toolkit `SHA256SUMS` URL. | | `OCR_TOOLKIT_WHEEL` | Example shell | Computed | `open_code_review_toolkit-${OCR_TOOLKIT_VERSION}-py3-none-any.whl` | Exact wheel filename selected from the release. | | `OCR_TOOLKIT_WHEEL_SHA256` | Example shell | Computed | Matching value from `SHA256SUMS` | Digest checked before installing the toolkit wheel. | -| `OCR_MAX_TOOLS` | Example pipeline / OCR CLI | No | `0` | OCR 1.11.1 uses template default `100`; `1-49` reports normalization to `50` but remains effectively `100`, and only a value above `100` raises the cap. | +| `OCR_MAX_TOOLS` | Example pipeline / OCR CLI | No | `0` | OCR 1.11.2 uses template default `100`; `1-49` reports normalization to `50` but remains effectively `100`, and only a value above `100` raises the cap. | | `OCR_MAX_TOKENS_BUDGET` | Example pipeline / OCR CLI | No | `0` | Non-negative aggregate OCR token ceiling; `0` is unlimited. | ## Dynamic adapter and MCP inputs @@ -177,7 +177,7 @@ finish and OCR accounts the provider-reported input plus output tokens. `OCR_MAX_TOOLS` is independent of all three token controls. Leave the example default at `0` so OCR uses its embedded template limit of `100` per subtask. -OCR 1.11.1 reports values `1-49` as normalized to the minimum `50`, but both +OCR 1.11.2 reports values `1-49` as normalized to the minimum `50`, but both that target and explicit `50` remain below the template default and therefore remain effectively `100`. Use `101` or greater only when deliberately raising the loop cap. OCR 1.11.0 corrected stale help text for this already-qualified diff --git a/docs/operations.md b/docs/operations.md index 4fedc19..b960ecf 100644 --- a/docs/operations.md +++ b/docs/operations.md @@ -34,9 +34,9 @@ suppressed. `OCR_MAX_TOKENS_BUDGET` can set an aggregate input-plus-output token ceiling for the OCR diff review. The default `0` is unlimited. A positive ceiling is approximate rather than a hard billing cutoff because already-running work may complete; when it stops further dispatch, completed findings remain publishable and unreviewed files stay explicit as budget-attributed failed coverage. Such a run is partial and cannot automatically approve. -This aggregate budget is separate from both OCR's prompt/context `max_tokens` ceiling and the provider request's completion/output cap. The toolkit does not add an environment alias for OCR's prompt/context control. `OCR_LLM_MAX_COMPLETION_TOKENS` defaults to unset and, when set, overlays only the protocol-specific output field. The inherited OpenAI value was 58,888 in OCR 1.9.10 and is 16,384 in OCR 1.10.0 through 1.11.1, including grouping requests; an explicit toolkit override applies to all requests. A gateway may reserve cost against that requested maximum before generation even when the eventual response would be short. The `/models` capability value does not reveal an account spending limit or reservation policy, so the toolkit never selects the cap from it automatically. Set an explicit cap only from the deployment's provider/model contract when that deployment must not change with the qualified OCR version; the toolkit does not recommend or hardcode a provider-specific value. +This aggregate budget is separate from both OCR's prompt/context `max_tokens` ceiling and the provider request's completion/output cap. The toolkit does not add an environment alias for OCR's prompt/context control. `OCR_LLM_MAX_COMPLETION_TOKENS` defaults to unset and, when set, overlays only the protocol-specific output field. The inherited OpenAI value was 58,888 in OCR 1.9.10 and is 16,384 in OCR 1.10.0 through 1.11.2, including grouping requests; an explicit toolkit override applies to all requests. A gateway may reserve cost against that requested maximum before generation even when the eventual response would be short. The `/models` capability value does not reveal an account spending limit or reservation policy, so the toolkit never selects the cap from it automatically. Set an explicit cap only from the deployment's provider/model contract when that deployment must not change with the qualified OCR version; the toolkit does not recommend or hardcode a provider-specific value. -`OCR_REVIEW_EFFORT=medium` is the toolkit default for the qualified OCR release and permits two review rounds; `low` permits one and `high` permits three. OCR 1.11.1 multiplies its 15-minute subtask base by those rounds, yielding 15/30/45-minute limits; the GitLab example's 45-minute job timeout accommodates the largest preset. This is a review-depth choice, not one of the three token controls. OCR first groups related changed files and may make group-filter requests; additional rounds can add requests, latency, and cost, but may stop early when they add no finding. Below four changed files, OCR 1.11.1 skips the grouping LLM: low total churn is bundled and high churn stays per-file. The published GitLab example separately passes `OCR_MAX_TOOLS=0`, selecting the embedded template default `100`. The CLI reports `1-49` as normalized to `50`, but both that target and explicit `50` remain effectively `100`; only values above `100` raise the cap. OCR 1.11.0 corrects stale help text for the already-qualified behavior; OCR 1.11.1 leaves that runtime loop contract unchanged. Exhausting the effective limit, an aggregate budget stop, or incomplete manifest coverage remains explicit and approval-ineligible; increasing either effort or tools is never a way to hide partial coverage. +`OCR_REVIEW_EFFORT=medium` is the toolkit default for the qualified OCR release and permits two review rounds; `low` permits one and `high` permits three. OCR 1.11.2 multiplies its 15-minute subtask base by those rounds, yielding 15/30/45-minute limits; the GitLab example's 45-minute job timeout accommodates the largest preset. This is a review-depth choice, not one of the three token controls. OCR first groups related changed files and may make group-filter requests; additional rounds can add requests, latency, and cost, but may stop early when they add no finding. Below four changed files, OCR 1.11.2 skips the grouping LLM: low total churn is bundled and high churn stays per-file. The published GitLab example separately passes `OCR_MAX_TOOLS=0`, selecting the embedded template default `100`. The CLI reports `1-49` as normalized to `50`, but both that target and explicit `50` remain effectively `100`; only values above `100` raise the cap. OCR 1.11.0 corrects stale help text for the already-qualified behavior; OCR 1.11.2 leaves that runtime loop contract unchanged. Exhausting the effective limit, an aggregate budget stop, or incomplete manifest coverage remains explicit and approval-ineligible; increasing either effort or tools is never a way to hide partial coverage. The outcome wording distinguishes skipped, complete, complete-with-warnings, incomplete, token-budget, and failed reviews while preserving the finding state in that same line. A complete clean review is visibly positive; a complete review with findings or only reviewer-suppressed findings is neutral; warning, partial, budget, and failed states never look clean. Findings withheld by the posting limit remain counted even when the limit allows no individual finding note. Recommended focus areas ranks only its copy of already-published findings by the closed severity, category, safe repository location, and stable-identity order before its existing display cap; inline and fallback discussion order, suppression, counts, security focus, and approval policy remain unchanged. OCR 1.8.5 and later manifest failures provide the canonical failed-file receipt; legacy warnings are a bounded fallback, and `summary.files_reviewed` is never treated as proof of successful coverage. Technical details label the aggregate as all OCR tool calls and retain the existing inline format while listing every admitted non-zero count for the closed native/context/evidence review set. An empty admitted list produces no tool-call line. The counts describe review activity, not per-tool token consumption: one read or search can return a different amount of context from another. Dynamic external tool names remain private; toolkit-verified MCP-server calls stay in their separate aggregate, and built-in evidence `summary`/`list`/`get` plus dedicated `search`/`coverage` counts appear only after exact action-receipt v2 and OCR by-tool reconciliation. Zero action counters, queries, scopes, IDs, token counters, and configured-but-unused MCP servers are omitted. Token usage renders only validated input/output/cached/reasoning/total/derived-other buckets; malformed or contradictory counters are unavailable and unknown provider keys are not published. Status and aggregate semantic-category emoji are enabled by default and can be disabled together with `OCR_POST_EMOJI=false`; finding labels remain text unless their separate badge mode is enabled. @@ -132,7 +132,7 @@ Suppression checks both the recorded inline position and compatible fingerprints Run OCR through `ocr-ci review --result PATH --stderr PATH -- ...`. This wrapper does not post to GitLab: it creates private artifacts, acquires enriched context when selected, asks the exact resolved and preflight-qualified OCR executable to preview the production refs/rules/selection/background without an LLM, then runs the model review only if OCR accepts that background. OCR owns the current recommendation and rejection thresholds; the toolkit has no threshold setting. A recognized soft diagnostic is reduced to a toolkit-authored `ocr.toolkit-advisory/v1` enum and two positive character counts. It is attached after publication DLP, rendered only with an exact receipt v7 in Technical details, and does not change warnings, coverage, DLP counts, telemetry, or automatic approval. A recognized hard character/file-size rejection stops before the model and lets `ocr-ci post` publish only a static numeric failure summary; the OCR path and raw diagnostic remain private. Unknown preview failures use the generic fail-closed diagnostic path. The ordinary review still validates the same background, the wrapper validates the complete output, and context/session/configuration data is removed. On an unclassified ordinary failure it prints only a bounded redacted stderr excerpt to the runner log; a classified provider failure keeps that excerpt private. Pass the paths and captured exit code to `ocr-ci post` afterward. Set `OCR_POST_ERROR_DETAILS=1` only when the generic path's safe excerpt should also appear in the merge-request failure note. Cleanup uncertainty blocks result publication. DLP atomically converts unsafe publication output into a safe `completed_with_errors` subset, but sanitizes unsafe private-only result fields without discarding an otherwise valid manifest or finding set. Safe findings are posted, unsafe finding content/warnings and unsafe optional fields are omitted, previous OCR comments remain, and matching prior findings are consumed one-for-one rather than duplicated. Receipt v7 and the `ocr.publication-dlp-signal/v2` marker distinguish `private-sanitized`, where the canonical published and approval-relevant projection is unchanged, from approval-ineligible `publication-filtered`, where the public projection is incomplete even when the receipt proves OCR coverage was complete. Posting uses only the validated original coverage kind and counts for status and Technical details; it never reconstructs or republishes rejected values. Horizontal tabs survive only in `existing_code` and `suggestion_code`; the unchanged values pass the same secret, PII, forbidden-value, laundering, and budget checks, while tabs elsewhere and all other control/format characters remain blocking. The same count-only JSON is logged as `OCR toolkit telemetry event` for optional CI collection/alerting. It is not an OTLP/network exporter and contains no rejected value or location. Never interpret a filtered subset as a full publishable review or as proof that OCR coverage was partial. -OCR 1.11.1 may add group labels, file membership, grouping-task records, round diagnostics, readable reasoning, and provider-native signed or encrypted replay payloads to its private session data. Safe values remain private; the isolated OCR home is removed before publication. Unknown private-only result fields are sanitized through the existing DLP owner, while any value reaching a canonical finding or warning makes publication incomplete and approval-ineligible. Reasoning, native payloads, and `tool_choice` are deliberately absent from the canonical finding/posting projection, receipt v7, DLP counts, toolkit telemetry, tool/token summary, and approval inputs. If they appear inside receipt v7, the receipt is invalid and approval fails closed. Caller `--output`/`-o` is rejected before preview: only `ocr-ci review --result` owns the result descriptor and posting handoff. +OCR 1.11.2 may add group labels, file membership, grouping-task records, round diagnostics, readable reasoning, and provider-native signed or encrypted replay payloads to its private session data. Safe values remain private; the isolated OCR home is removed before publication. Unknown private-only result fields are sanitized through the existing DLP owner, while any value reaching a canonical finding or warning makes publication incomplete and approval-ineligible. Reasoning, native payloads, and `tool_choice` are deliberately absent from the canonical finding/posting projection, receipt v7, DLP counts, toolkit telemetry, tool/token summary, and approval inputs. If they appear inside receipt v7, the receipt is invalid and approval fails closed. Caller `--output`/`-o` is rejected before preview: only `ocr-ci review --result` owns the result descriptor and posting handoff. When OCR exits nonzero with a valid bounded `ocr.llm-retry-report/v1`, the toolkit reads only its closed error class, failure phase, terminal outcome, HTTP status, and schema counters. It maps those facts to `authentication`, `authorization`, `rate-or-spending-limit`, `overloaded`, `timeout`, `network`, `endpoint-or-model-not-found`, `request-rejected`, `provider-unavailable`, `invalid-response`, `cancelled`, `mixed`, or `unknown`, then writes a completely toolkit-authored note. A runtime `404` remains `endpoint-or-model-not-found` because safely distinguishing the endpoint from the model would require trusting the raw response body. diff --git a/examples/gitlab/README.md b/examples/gitlab/README.md index 8c3c176..7d0530e 100644 --- a/examples/gitlab/README.md +++ b/examples/gitlab/README.md @@ -2,7 +2,7 @@ [`ocr-review.gitlab-ci.yml`](ocr-review.gitlab-ci.yml) is the complete checksum-pinned pipeline. It defaults to identity-only review, explicitly keeps `OCR_GITLAB_TARGET_PROTECTION_MODE: "required"`, and contains the install, preflight, configure, review, and post lifecycle. Copy one mode file below into the pipeline's top-level `variables` mapping, or include exactly one file after reviewing its trust boundary. -The pipeline pins OCR 1.11.0 and explicitly sets `OCR_REVIEW_EFFORT=medium`, which permits two review rounds. Use `low` for one round when latency/cost is the priority, or `high` for three only after accepting the additional provider work. OCR uses a 15-minute subtask base and scales it to 15/30/45 minutes for low/medium/high; the example's 45-minute job timeout accommodates every preset without adding another environment control. Semantic grouping and filtering are OCR behavior shared by every context mode; they do not change which merge-request text a mode admits. `OCR_MAX_TOOLS=0` selects the embedded template default `100`. Values `1-49` report normalization to `50` but remain effectively `100`; only values above `100` raise the per-subtask cap. `OCR_MAX_TOKENS_BUDGET` and the optional completion cap remain independent controls. +The pipeline pins OCR 1.11.2 and explicitly sets `OCR_REVIEW_EFFORT=medium`, which permits two review rounds. Use `low` for one round when latency/cost is the priority, or `high` for three only after accepting the additional provider work. OCR uses a 15-minute subtask base and scales it to 15/30/45 minutes for low/medium/high; the example's 45-minute job timeout accommodates every preset without adding another environment control. Semantic grouping and filtering are OCR behavior shared by every context mode; they do not change which merge-request text a mode admits. `OCR_MAX_TOOLS=0` selects the embedded template default `100`. Values `1-49` report normalization to `50` but remain effectively `100`; only values above `100` raise the per-subtask cap. `OCR_MAX_TOKENS_BUDGET` and the optional completion cap remain independent controls. | Mode | Recipe | MR text admitted | External access | Automatic approval | | --- | --- | --- | --- | --- | diff --git a/examples/gitlab/ocr-review.gitlab-ci.yml b/examples/gitlab/ocr-review.gitlab-ci.yml index cbcaa42..3e0b9f6 100644 --- a/examples/gitlab/ocr-review.gitlab-ci.yml +++ b/examples/gitlab/ocr-review.gitlab-ci.yml @@ -6,10 +6,10 @@ default: image: python:3.12-slim variables: - OCR_VERSION: "v1.11.1" + OCR_VERSION: "v1.11.2" OCR_TOOLKIT_VERSION: "0.8.7" OCR_TOOLKIT_CHECKSUMS_URL: "https://github.com/xeonvs/open-code-review-toolkit/releases/download/v${OCR_TOOLKIT_VERSION}/SHA256SUMS" - OCR_SHA256: "1cdc7d1f776f1cdb69850130b930e40f64accc86ecaf09600573b3600456322f" + OCR_SHA256: "fad3c62314478822c1e90f858f3e06b3f8dc5ff0034933927c3ecd7faa5d3731" OCR_POST_MODE: "draft" OCR_STRICT_POSTING: "true" # Secure default: reject an unprotected GitLab target before OCR. @@ -46,7 +46,7 @@ open_code_review: - if: '$CI_PIPELINE_SOURCE == "merge_request_event"' when: on_success - when: never - # OCR 1.11.1 scales the 15-minute subtask base by low/medium/high rounds (15/30/45). + # OCR 1.11.2 scales the 15-minute subtask base by low/medium/high rounds (15/30/45). timeout: 45m script: - apt-get update && apt-get install -y --no-install-recommends ca-certificates curl git diff --git a/scripts/ocr_compat.py b/scripts/ocr_compat.py index cd0112e..38b24af 100644 --- a/scripts/ocr_compat.py +++ b/scripts/ocr_compat.py @@ -429,13 +429,35 @@ def validate_manifest(manifest: dict[str, Any], root: Path = ROOT) -> None: "threshold_files": 4, }: _fail(f"evidence does not qualify small-change grouping behavior for {version}") - if _version(version) >= (1, 11, 1) and contracts.get("language_rule_probe") != { + expected_language_probe = { "excluded_extensions": [".svh"], "extensions": [".pug", ".sv", ".v", ".vhd", ".vhdl", ".vh"], "result": "passed", "rule_source": "system_builtin", "selected": 6, - }: + } + if _version(version) >= (1, 11, 2): + expected_language_probe.update( + { + "extensions": [ + ".cjs", + ".cxx", + ".hxx", + ".mjs", + ".pug", + ".sv", + ".v", + ".vh", + ".vhd", + ".vhdl", + ], + "selected": 10, + } + ) + if ( + _version(version) >= (1, 11, 1) + and contracts.get("language_rule_probe") != expected_language_probe + ): _fail(f"evidence does not qualify built-in language rules for {version}") if contracts.get("completion_cap_probe") != { "explicit": 4_096, @@ -2004,7 +2026,7 @@ def preview(home_name: str, *extra: str) -> dict[str, Any] | str: } -def _language_rule_probe(binary: Path, directory: Path) -> dict[str, object]: +def _language_rule_probe(binary: Path, version: str, directory: Path) -> dict[str, object]: """Prove consumed built-in language selection and rule ownership without an LLM.""" root = directory / "language-rule-probe" @@ -2015,14 +2037,31 @@ def _language_rule_probe(binary: Path, directory: Path) -> dict[str, object]: _run(["git", "init", "--initial-branch=main"], cwd=repo, env=git_env) _run(["git", "config", "user.name", "Synthetic Reviewer"], cwd=repo, env=git_env) _run(["git", "config", "user.email", "reviewer@example.com"], cwd=repo, env=git_env) - supported_paths = ( + qualified_rules = { "views/page.pug", "rtl/module.v", "rtl/include.vh", "rtl/module.sv", "rtl/entity.vhd", "rtl/entity.vhdl", - ) + } + exact_patterns: dict[str, str] = {} + if _version(version) >= (1, 11, 2): + qualified_rules.update( + { + "src/module.mjs", + "src/module.cjs", + "native/source.cxx", + "native/header.hxx", + } + ) + exact_patterns = { + "src/module.mjs": "**/*.{ts,js,tsx,jsx,mjs,cjs}", + "src/module.cjs": "**/*.{ts,js,tsx,jsx,mjs,cjs}", + "native/source.cxx": "**/*.{cpp,cc,cxx,hpp,hxx}", + "native/header.hxx": "**/*.{cpp,cc,cxx,hpp,hxx}", + } + supported_paths = tuple(sorted(qualified_rules)) unsupported_path = "rtl/include.svh" paths = (*supported_paths, unsupported_path) for path in paths: @@ -2082,8 +2121,11 @@ def _language_rule_probe(binary: Path, directory: Path) -> dict[str, object]: or "Pattern: " not in output ): _fail("candidate did not resolve a qualified built-in language rule") + expected_pattern = exact_patterns.get(path) + if expected_pattern is not None and f"Pattern: {expected_pattern}\n" not in output: + _fail(f"candidate resolved the wrong built-in language rule for {path}") return { - "extensions": [".pug", ".sv", ".v", ".vhd", ".vhdl", ".vh"], + "extensions": sorted(Path(path).suffix for path in supported_paths), "excluded_extensions": [".svh"], "result": "passed", "rule_source": "system_builtin", @@ -2241,7 +2283,7 @@ def run_contracts(binary: Path, version: str, directory: Path) -> dict[str, Any] contracts["small_change_grouping_probe"] = _small_change_grouping_probe( binary, version, directory ) - contracts["language_rule_probe"] = _language_rule_probe(binary, directory) + contracts["language_rule_probe"] = _language_rule_probe(binary, version, directory) if _version(version) >= (1, 9, 10): contracts["completion_cap_probe"] = _completion_cap_probe(binary, version, directory) if thinking_probe is not None: diff --git a/src/ocr_toolkit/preflight.py b/src/ocr_toolkit/preflight.py index 203329c..6dbed1e 100644 --- a/src/ocr_toolkit/preflight.py +++ b/src/ocr_toolkit/preflight.py @@ -28,7 +28,7 @@ "Accept": "application/json", "User-Agent": "open-code-review-ci-preflight/1.0", } -EXPECTED_OCR_VERSION = "1.11.1" +EXPECTED_OCR_VERSION = "1.11.2" class PreflightError(Exception): diff --git a/tests/test_environment_contract.py b/tests/test_environment_contract.py index 9213f5b..45e669e 100644 --- a/tests/test_environment_contract.py +++ b/tests/test_environment_contract.py @@ -70,8 +70,8 @@ } EXAMPLE_DEFAULTS = { - "OCR_VERSION": "v1.11.1", - "OCR_SHA256": "1cdc7d1f776f1cdb69850130b930e40f64accc86ecaf09600573b3600456322f", + "OCR_VERSION": "v1.11.2", + "OCR_SHA256": "fad3c62314478822c1e90f858f3e06b3f8dc5ff0034933927c3ecd7faa5d3731", "OCR_TOOLKIT_VERSION": STABLE_TOOLKIT_VERSION, "OCR_TOOLKIT_CHECKSUMS_URL": "Release URL derived from `OCR_TOOLKIT_VERSION`", "OCR_TOOLKIT_WHEEL": "open_code_review_toolkit-${OCR_TOOLKIT_VERSION}-py3-none-any.whl", @@ -300,7 +300,7 @@ def test_example_local_defaults_match_the_pipeline() -> None: encoding="utf-8" ) for name, value in { - "OCR_VERSION": "v1.11.1", + "OCR_VERSION": "v1.11.2", "OCR_SHA256": EXAMPLE_DEFAULTS["OCR_SHA256"], "OCR_TOOLKIT_VERSION": STABLE_TOOLKIT_VERSION, "OCR_MAX_TOOLS": "0", diff --git a/tests/test_ocr_compat.py b/tests/test_ocr_compat.py index 1073ff5..9fec248 100644 --- a/tests/test_ocr_compat.py +++ b/tests/test_ocr_compat.py @@ -43,8 +43,8 @@ def test_committed_manifest_is_valid_and_has_recommended_tested_baseline() -> No module.validate_manifest(manifest, PROJECT_ROOT) - assert manifest["recommended_version"] == "1.11.1" - assert manifest["monitoring_floor"] == "1.11.1" + assert manifest["recommended_version"] == "1.11.2" + assert manifest["monitoring_floor"] == "1.11.2" assert [(item["version"], item["status"]) for item in manifest["releases"]] == [ ("1.7.17", "tested"), ("1.8.0", "tested"), @@ -74,6 +74,7 @@ def test_committed_manifest_is_valid_and_has_recommended_tested_baseline() -> No ("1.10.2", "tested"), ("1.11.0", "tested"), ("1.11.1", "tested"), + ("1.11.2", "tested"), ] @@ -158,9 +159,9 @@ def test_discovery_filters_known_prerelease_and_old_versions() -> None: def test_discovery_pages_until_the_monitoring_floor() -> None: module = load_script() manifest = module.load_json(MANIFEST) - first_page = [release("1.11.2")] + first_page = [release("1.11.3")] first_page.extend({"draft": True} for _ in range(module.MAX_RELEASES_PER_PAGE - 1)) - second_page = [release("1.11.1")] + second_page = [release("1.11.2")] requested: list[str] = [] def fake_request(url: str) -> list[dict[str, Any]]: @@ -170,14 +171,14 @@ def fake_request(url: str) -> list[dict[str, Any]]: with patched_attr(module, "_request_json", fake_request): unseen = module.discover_unseen(manifest) - assert [item["tag_name"] for item in unseen] == ["v1.11.2"] + assert [item["tag_name"] for item in unseen] == ["v1.11.3"] assert len(requested) == 2 def test_discovery_fails_when_bounded_pages_do_not_reach_floor() -> None: module = load_script() manifest = module.load_json(MANIFEST) - page = [release("1.11.2")] + page = [release("1.11.3")] page.extend({"draft": True} for _ in range(module.MAX_RELEASES_PER_PAGE - 1)) with patched_attr(module, "_request_json", lambda _url: page): @@ -222,14 +223,14 @@ def test_qualification_matrix_accepts_the_next_manual_patch() -> None: module = load_script() manifest = module.load_json(MANIFEST) - matrix = module.qualification_matrix(manifest, [release("1.11.2")]) + matrix = module.qualification_matrix(manifest, [release("1.11.3")]) assert matrix == { "include": [ { - "comparison_version": "1.11.1", - "tag": "v1.11.2", - "tested_baseline_version": "1.11.1", + "comparison_version": "1.11.2", + "tag": "v1.11.3", + "tested_baseline_version": "1.11.2", } ] } @@ -1147,7 +1148,7 @@ def messages(inventory: str) -> list[dict[str, str]]: ), version, ) - for version in ("1.11.0", "1.11.1") + for version in ("1.11.0", "1.11.1", "1.11.2") ] assert ( @@ -1177,9 +1178,9 @@ def test_schema_three_candidate_remains_chain_aware() -> None: manifest = module.load_json(MANIFEST) evidence = { "schema_version": 3, - "version": "1.11.2", - "tested_baseline_version": "1.11.1", - "comparison_version": "1.11.1", + "version": "1.11.3", + "tested_baseline_version": "1.11.2", + "comparison_version": "1.11.2", "result": "compatible", "classification": "automatic-safe", } @@ -1189,9 +1190,9 @@ def test_schema_three_candidate_remains_chain_aware() -> None: assert result == { "automatic_blockers": [], "classification": "automatic-safe", - "target_version": "1.11.2", - "tested_baseline_version": "1.11.1", - "versions": ["1.11.2"], + "target_version": "1.11.3", + "tested_baseline_version": "1.11.2", + "versions": ["1.11.3"], } @@ -1657,11 +1658,11 @@ def test_prepare_update_rejects_human_review_candidate(tmp_path: Path) -> None: module = load_script() evidence = { "schema_version": 2, - "version": "1.11.2", + "version": "1.11.3", "result": "compatible", "classification": "human-review-required", - "comparison_version": "1.11.1", - "tested_baseline_version": "1.11.1", + "comparison_version": "1.11.2", + "tested_baseline_version": "1.11.2", } with pytest.raises(module.CompatibilityError, match="bounded conclusion"): @@ -1680,8 +1681,8 @@ def test_prepare_update_requires_human_review_for_minor_transition() -> None: "version": "1.12.0", "result": "compatible", "classification": "automatic-safe", - "comparison_version": "1.11.1", - "tested_baseline_version": "1.11.1", + "comparison_version": "1.11.2", + "tested_baseline_version": "1.11.2", } with pytest.raises(module.CompatibilityError, match="explicit human review"): @@ -1740,11 +1741,11 @@ def test_prepare_update_rejects_conclusion_outside_evidence_chain() -> None: module = load_script() evidence = { "schema_version": 2, - "version": "1.11.2", + "version": "1.11.3", "result": "compatible", "classification": "automatic-safe", - "comparison_version": "1.11.1", - "tested_baseline_version": "1.11.1", + "comparison_version": "1.11.2", + "tested_baseline_version": "1.11.2", } with pytest.raises(module.CompatibilityError, match="only evidence versions"): @@ -1752,7 +1753,7 @@ def test_prepare_update_rejects_conclusion_outside_evidence_chain() -> None: manifest_path=MANIFEST, evidence=evidence, fragment_number=72, - human_conclusions={"1.11.3": "Synthetic unrelated conclusion."}, + human_conclusions={"1.11.4": "Synthetic unrelated conclusion."}, root=PROJECT_ROOT, ) @@ -1764,11 +1765,11 @@ def test_prepare_update_rejects_invalid_optional_reviewed_conclusion( module = load_script() evidence = { "schema_version": 2, - "version": "1.11.2", + "version": "1.11.3", "result": "compatible", "classification": "automatic-safe", - "comparison_version": "1.11.1", - "tested_baseline_version": "1.11.1", + "comparison_version": "1.11.2", + "tested_baseline_version": "1.11.2", } with pytest.raises(module.CompatibilityError, match="bounded plain text"): @@ -1776,7 +1777,7 @@ def test_prepare_update_rejects_invalid_optional_reviewed_conclusion( manifest_path=MANIFEST, evidence=evidence, fragment_number=72, - human_conclusions={"1.11.2": conclusion}, + human_conclusions={"1.11.3": conclusion}, root=PROJECT_ROOT, ) diff --git a/tests/test_operations_docs.py b/tests/test_operations_docs.py index dac293e..6fe3578 100644 --- a/tests/test_operations_docs.py +++ b/tests/test_operations_docs.py @@ -249,7 +249,7 @@ def test_completion_cap_and_provider_failure_boundaries_are_public() -> None: assert "provider-specific" in document for document in (configuration, operations, gitlab): assert "OCR_LLM_MAX_COMPLETION_TOKENS=4096" not in document - current_compatibility = compatibility.split("### OCR 1.11.1 — toolkit 0.8.7 target", 1)[1] + current_compatibility = compatibility.split("### OCR 1.11.2 — toolkit 0.9.0 target", 1)[1] assert "explicit positive completion-cap transport" in current_compatibility assert "default remains unset" in current_compatibility assert "provider-specific cap" in current_compatibility @@ -643,6 +643,7 @@ def test_ocr_compatibility_workflow_is_bounded_and_protected() -> None: "OCR 1.10.2 — toolkit 0.8.5 target", "OCR 1.11.0 — toolkit 0.8.6 target", "OCR 1.11.1 — toolkit 0.8.7 target", + "OCR 1.11.2 — toolkit 0.9.0 target", "ocr.toolkit-advisory/v1", "ocr.llm-retry-report/v1", "not toolkit telemetry", @@ -651,6 +652,7 @@ def test_ocr_compatibility_workflow_is_bounded_and_protected() -> None: "Deploy toolkit 0.8.5 directly with OCR 1.10.2", "Deploy toolkit 0.8.6 directly with OCR 1.11.0", "Deploy toolkit 0.8.7 directly with OCR 1.11.1", + "Deploy toolkit 0.9.0 directly with OCR 1.11.2", "max-tools runtime behavior is unchanged", "max_completion_tokens=16384", "do not install OCR 1.9.10 as an intermediate step", diff --git a/tests/test_runtime_helpers.py b/tests/test_runtime_helpers.py index 52f2400..aa5e9f5 100644 --- a/tests/test_runtime_helpers.py +++ b/tests/test_runtime_helpers.py @@ -1569,7 +1569,7 @@ def test_invalid_json_error_does_not_echo_secret_payload(self) -> None: class PreflightTests(unittest.TestCase): def test_validate_ocr_binary_accepts_supported_version(self) -> None: completed = subprocess.CompletedProcess( - args=["ocr", "--version"], returncode=0, stdout="ocr 1.11.1\n", stderr="" + args=["ocr", "--version"], returncode=0, stdout="ocr 1.11.2\n", stderr="" ) with ( patched_attr(preflight.shutil, "which", lambda _name: "/usr/bin/ocr"), From f310453241e21052baec26376312f166504ab902 Mon Sep 17 00:00:00 2001 From: xeonvs <11463419+xeonvs@users.noreply.github.com> Date: Wed, 2 Sep 2026 10:29:03 +0200 Subject: [PATCH 07/15] docs: reconcile v0.9.0 trust evidence --- PLANS.md | 5 ++++- ROADMAP.md | 7 +++---- docs/codex/TASKS_BACKLOG.md | 2 +- docs/engineering/m5_context_contracts.md | 10 ++++++---- docs/engineering/review_signal_ownership.md | 8 ++++---- docs/engineering/test_evidence_matrix.md | 9 +++++++++ docs/engineering/toolkit_strategy.md | 6 +++--- tests/test_integration_contracts.py | 3 +++ tests/test_operations_docs.py | 3 +++ tests/test_posting_helpers.py | 2 +- 10 files changed, 37 insertions(+), 18 deletions(-) diff --git a/PLANS.md b/PLANS.md index c5dd469..cacf962 100644 --- a/PLANS.md +++ b/PLANS.md @@ -87,7 +87,10 @@ attribution, detached-pipeline diagnostics, and OCR 1.11.2 compatibility. SHA matrix, strict evidence-action receipt reconciliation, atomic receipt identity, unprotected context/MCP rejection, static limitation provenance, and the unreachable approval executor. 8. [ ] Review every commit and the complete `origin/main..HEAD` range, run all deterministic local - gates, and fix supported findings in separately reviewed signed commits. + gates, and fix supported findings in separately reviewed signed commits. The holistic source, + test, and documentation review found no runtime defect; it reconciled the current receipt-v7, + source/target identity, action-integrity, and constrained-target evidence/status contracts. + Complete deterministic gates remain before this step closes. 9. [ ] Run exactly one Codex Security diff scan for `origin/main..HEAD`, validate attack paths, fix supported findings, and repeat holistic review plus deterministic validation. 10. [ ] Checksum-verify and atomically install PATH-effective OCR 1.11.2 with a rollback copy, then diff --git a/ROADMAP.md b/ROADMAP.md index 873b881..db37a2c 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -22,8 +22,7 @@ flowchart LR classDef planned fill:#57606a,stroke:#424a53,color:#ffffff classDef conditional fill:#9a6700,stroke:#7d4e00,color:#ffffff - class M0,M1,M2,M3,M4 established - class M5 next + class M0,M1,M2,M3,M4,M5 established class M6 planned class M7 conditional ``` @@ -35,7 +34,7 @@ flowchart LR | M2 Ecosystem and framework coverage | Established | Supply framework and template evidence selected from demonstrated use without creating framework-specific review engines. | Established evidence, snapshot/delta, scoped-completeness, and built-in MCP contracts. | Selected static plugins and template review rules have deterministic fixtures, bounds, provenance, component ownership, completeness, first-class source/target delta queries, installed-artifact validation, verified use through the existing built-in MCP, and independently read-back stable delivery. | | M3 External MCP hardening | Established | Qualify and document the safe-use envelope and residual limits of the shipped generic external-MCP composition boundary. | Existing external MCP and built-in composition plus BL-011 real-OCR qualification. | Canonical security and configuration guidance records the direct-composition trust boundaries, tool-name allowlist limits, server-owned object authorization, shared plan/main exposure, response/session persistence, failure degradation, and receipt non-claims observed with checksum-verified OCR and a real synthetic stdio peer. Managed OAuth remains conditional. | | M4 Policy and project guidance | Established | Supply relevant target-branch decisions and guidance without allowing self-whitelisting. | Evidence scoping and target/source snapshots. | Stable delivery independently proves backward-compatible structured decisions, bounded target-derived guidance, one read-only MCP lifecycle, and closure of the tracked release work. | -| M5 Bounded review-context enrichment | Established | Extend invocation evidence with bounded forge discussions, verified remediation history, protected same-revision CI outcomes, and optional external records through one provider-neutral, capability-constrained context lifecycle, without a second review engine. | Established M1, M3, and M4 boundaries plus the v0.7.0 BL-023 delivery. | v0.7.0 establishes bounded discussion/reference acquisition. The v0.8.0 release tree adds policy-v2 remediation selection, context-store v2 and fixed MCP projection, live bot-root/mention identity, DLP isolation, comment-only remediation, and provider-neutral reuse boundaries. v0.8.7 adds policy-v3 exact-head CI outcomes as scoped review evidence without suppression or approval authority. The owner waived the separate enriched OCR+LLM qualification, so no receipt proves model-time `context_list`/`context_get`, still-present/evidence-resolved scenarios, or receipt-level raw-data leakage inspection. Protected release publication and independent external readback remain mandatory delivery evidence but do not replace that absent qualification. | +| M5 Bounded review-context enrichment | Established | Extend invocation evidence with bounded forge discussions, verified remediation history, protected same-revision CI outcomes, and optional external records through one provider-neutral, capability-constrained context lifecycle, without a second review engine. | Established M1, M3, and M4 boundaries plus the v0.7.0 BL-023 delivery. | v0.7.0 establishes bounded discussion/reference acquisition. The v0.8.0 release tree adds policy-v2 remediation selection, context-store v2 and fixed MCP projection, live bot-root/mention identity, DLP isolation, comment-only remediation, and provider-neutral reuse boundaries. v0.8.7 adds policy-v3 exact-head CI outcomes as scoped review evidence without suppression or approval authority. Toolkit 0.9.0 adds receipt-v7 source/target/protection binding and a separate comment-only path for explicitly allowed unprotected targets; that path rejects protected policy, enrichment, adapters, external MCP, accepted decisions, and structured target guidance rather than claiming protected-policy equivalence. The owner waived the separate enriched OCR+LLM qualification, so no receipt proves model-time `context_list`/`context_get`, still-present/evidence-resolved scenarios, or receipt-level raw-data leakage inspection. Protected release publication and independent external readback remain mandatory delivery evidence but do not replace that absent qualification. | | M6 Profiles and quality measurement | Established / conditional | Keep the completed review-signal ownership audit current; add model-profile aliases only after demonstrated operational need. | The BL-017 audit establishes OCR/provider telemetry and toolkit lifecycle-signal ownership; a demonstrated alias need and owner-approved matrix are required only for profile implementation. | The audit concludes `no-new-layer`; any later model profiles remain conditional and independent from explicit coverage and budget controls. | | M7 Later and conditional work | Conditional | Activate routing, more ecosystems, fuzzing, configuration, forge adapters, or governance work only from demonstrated need. | Milestone-specific activation signals and stable preceding contracts. | Each item meets its own trigger and ships as a coherent validated slice without weakening core invariants. | @@ -46,7 +45,7 @@ flowchart LR - M2 is established through independently verified stable delivery of its framework plugins, template rules, scoped evidence, deltas, and built-in MCP projection. Conditional future ecosystem packs remain in M7 and do not reopen M2. - M4 is established through independently verified v0.6.0 artifacts and later protected-target identity improvements. M5 consumes but does not reopen its policy boundary. - M5's foundation is established by the v0.7.0 BL-023 release content; the v0.8.0 release tree completes its remediation/provider-neutral extension under an explicit owner waiver for the separate enriched OCR+LLM receipt. The waiver is a permanent evidence limitation, not a successful qualification: model-time `context_list`/`context_get`, still-present/evidence-resolved scenarios, and receipt-level raw-data leakage inspection remain unproven. Direct provider-specific MCP enforcement is not a second M5 path: external records stay behind the broker, while generic direct MCP retains the established M3 operator boundary. Its dependency graph is exactly `M1 -> M5`, `M3 -> M5`, and `M4 -> M5`; it is not a gate for M6 or M7. The protected release workflow, immutable receipt, and independent registry/GitHub readback remain mandatory external delivery closure and do not erase the waiver. -- The completed M6 BL-017 audit maps receipt v5, privacy-safe normalized token buckets, reconciled MCP/evidence-action counts, and OCR 1.10.0 grouping/round telemetry to their existing owners and concludes `no-new-layer`. M5 does not create a second telemetry implementation or exporter. BL-016 remains parked because OCR already exposes direct run-level selection; BL-018 remains conditional. +- The completed M6 BL-017 audit maps the versioned toolkit receipt, privacy-safe normalized token buckets, reconciled MCP/evidence-action counts, and OCR 1.10.0 grouping/round telemetry to their existing owners and concludes `no-new-layer`. Current receipt v7 adds immutable target/protection identity and exact action-receipt-v2 reconciliation without creating telemetry. M5 does not create a second telemetry implementation or exporter. BL-016 remains parked because OCR already exposes direct run-level selection; BL-018 remains conditional. - Versioned documentation remains a separate MCP integration: the toolkit supplies package/version evidence but does not store documentation. - Additional code-hosting adapters remain conditional and GitLab-first M5 does not depend on them. - Historical roadmap names, release plans, changelog entries, closed issues/PRs, and receipts retain their original identities. BL-022 is historical and is not reused. diff --git a/docs/codex/TASKS_BACKLOG.md b/docs/codex/TASKS_BACKLOG.md index e134b81..b3f4b42 100644 --- a/docs/codex/TASKS_BACKLOG.md +++ b/docs/codex/TASKS_BACKLOG.md @@ -44,7 +44,7 @@ M3 is established. BL-011 is complete and recorded above rather than retained as ## M6 Profiles and quality measurement -Provider/network telemetry remains outside M1 and M5. OCR owns token, cost, budget, provider-level review duration, request, and tool-call telemetry. The toolkit exposes only validated provider-neutral token buckets, distinguishes OCR-wide tool totals from verified MCP-server and count-only evidence-action use, and carries deterministic publication-DLP state in receipt v6, a parseable GitLab summary marker, and a structured local log event; it adds no exporter or endpoint. The 0.8.5 development line adds at most one bounded toolkit-authored CI failure diagnostic from closed retry-report enums and counts, not provider telemetry or an export path. M6 audits whether result-derived lifecycle signals need provider-neutral export/alert routing instead of duplicating OCR telemetry. +Provider/network telemetry remains outside M1 and M5. OCR owns token, cost, budget, provider-level review duration, request, and tool-call telemetry. The toolkit exposes only validated provider-neutral token buckets, distinguishes OCR-wide tool totals from verified MCP-server and count-only evidence-action use, and carries deterministic publication-DLP state in receipt v7, a parseable GitLab summary marker, and a structured local log event; it adds no exporter or endpoint. The 0.8.5 development line adds at most one bounded toolkit-authored CI failure diagnostic from closed retry-report enums and counts, not provider telemetry or an export path. M6 audits whether result-derived lifecycle signals need provider-neutral export/alert routing instead of duplicating OCR telemetry. ### BL-016: Evaluate explicit run-level model profiles diff --git a/docs/engineering/m5_context_contracts.md b/docs/engineering/m5_context_contracts.md index 4577c18..ada99fa 100644 --- a/docs/engineering/m5_context_contracts.md +++ b/docs/engineering/m5_context_contracts.md @@ -8,10 +8,12 @@ This checkpoint fixed the v0.7.0 production contracts before implementation and OCR performs one review and one model loop. The toolkit does not run contextual adjudication, merge a second model result, or expose provider-selected tool schemas. -## Protected policy +## Protected policy and constrained-target exception The only policy path is `.opencodereview/review-context-policy.json`. It is read as a bounded regular immutable Git blob from the captured protected-target policy SHA. A working-tree or source-branch file, symlink, submodule, missing/unsafe object, invalid UTF-8, duplicate JSON key, oversized input, unknown field/version, or impossible projection is rejected. Explicit `enriched` mode without a valid policy fails before OCR. +`OCR_GITLAB_TARGET_PROTECTION_MODE` defaults to `required`; explicit empty or unknown values fail closed. `unprotected` permits acquisition of an actually unprotected target only through a separate constrained path. That path is always comment-only, accepts only `off` or bounded untrusted `metadata`, rejects enriched context, adapters, policy acquisition, and direct or inherited external MCP before OCR, and omits accepted decisions and structured project guidance. It may use immutable built-in repository evidence and bounded Rules read from the exact target SHA. This exception does not treat an unprotected branch as a protected policy owner and does not weaken protected-target behavior under either setting. + Policy v1 remains accepted for existing discussion/reference configurations. Policy v2 adds `remediation_threads`. Policy v3 is required only when protected same-revision `ci_outcomes` are selected: ```json @@ -99,15 +101,15 @@ The bootstrap requires a model-recorded `ocr_toolkit_evidence(action=summary)` c OCR runs under a fresh owner-only isolated `HOME` containing only toolkit-validated OCR configuration/composition. One exact resolved executable from an absolute search-path entry, outside the reviewed repository, first receives one no-LLM preview and, only after background acceptance, one review with the same immutable range and selection/background inputs. Context acquisition finishes before preview/model execution; adapters and forge network paths are unavailable in the model loop. Ordinary runs remove preview output, the home, context store, adapter scratch space, and OCR session symlink-safely after success, failure, or interruption. Cleanup uncertainty makes the run non-publishable. An explicit local diagnostic may retain owner-only session/context state, but it creates no posting receipt; the authoritative GitLab MR profile rejects that exception before OCR starts and follows ordinary cleanup. -Publication validation runs after OCR and cleanup, within the same inode-checked atomic read/replace that attaches receipt v6. It compares both decoded source and rendered approximations against whole forbidden/non-publishable values and normalized contiguous excerpts of at least 24 characters. Closed checks cover nested HTML entities, comments/tags, inline/reference/autolink Markdown destinations, escapes/formatting, configured secrets, formatted-phone/email patterns, controls, and Unicode deception. Bare SHAs, build identifiers, and unformatted digit strings are not classified as phone numbers. A comparison that would exceed the fixed work bound is uncertainty. +Publication validation runs after OCR and cleanup, within the same inode-checked atomic read/replace that attaches receipt v7. It compares both decoded source and rendered approximations against whole forbidden/non-publishable values and normalized contiguous excerpts of at least 24 characters. Closed checks cover nested HTML entities, comments/tags, inline/reference/autolink Markdown destinations, escapes/formatting, configured secrets, formatted-phone/email patterns, controls, and Unicode deception. Bare SHAs, build identifiers, and unformatted digit strings are not classified as phone numbers. A comparison that would exceed the fixed work bound is uncertainty. The result has two explicit DLP projections. Publication sinks are exactly values the posting owner may render: outcome message, allowlisted finding fields, warnings, displayed tool names, and manifest-failure path/reason fields. If one is unsafe, the raw result is atomically replaced by an explicit safe filtered result containing independently passed findings/warnings plus closed reason, omission, original-coverage, and tool-use facts. Horizontal tab is admitted as a control character only in `existing_code` and `suggestion_code`; the unchanged code value is retained only after all remaining DLP checks pass. Non-rendered OCR metadata is a separate private-retention projection: unsafe keys are removed and unsafe string values receive stable non-reversible placeholders before the result is retained. When that sanitized result still satisfies the OCR result contract, its original status, manifest, and safe findings remain intact; a required structural-field loss falls back to the filtered form. Neither path retains the rejected value or its location. GitLab may publish the safe result with a filtered signal, while retaining the previous review, consuming prior matching fingerprints one-for-one, replacing only an earlier toolkit setup-pending note on retry, and blocking approval. Validated original coverage kind/counts remain presentation evidence distinct from public-projection completeness; filtered warnings never create legacy coverage failures. This is containment of deterministic exact-output classes, not a claim to detect shorter arbitrary excerpts, arbitrary semantic paraphrase, or undo content already sent to the model. -Receipt schema `ocr.toolkit-receipt/v6` stores only closed review/policy identities, context mode, per-source completeness and degradation counts, admitted-mutable flag, fixed tool usage, publication-DLP result, and cleanup result. Its publication states are `passed`, `private-sanitized`, and `publication-filtered`. Private sanitization keeps normal approval evaluation only when the canonical publication/approval projection is byte-equivalent; changed, malformed, or incomparable projections become safe partial results. The corresponding closed counts form an `ocr.publication-dlp-signal/v2` GitLab-summary marker and structured log event; neither is a new network telemetry exporter. The receipt/event never stores rejected text or locations, context text, upstream IDs, URLs, commands, arguments, headers, adapter results, personal display data, or transport diagnostics. v1-v5 result receipts are rejected rather than migrated. +Receipt schema `ocr.toolkit-receipt/v7` stores only closed source, policy, target, actual target-protection, and author identities; context mode, per-source completeness and degradation counts; admitted-mutable state; fixed tool usage with exact action-receipt-v2 reconciliation; publication-DLP result; and cleanup result. Policy and target SHA are identical because all target-derived material is read from that exact immutable commit, including the constrained Rules-only path. Its publication states are `passed`, `private-sanitized`, and `publication-filtered`. Private sanitization keeps normal approval evaluation only when the canonical publication/approval projection is byte-equivalent; changed, malformed, or incomparable projections become safe partial results. The corresponding closed counts form an `ocr.publication-dlp-signal/v2` GitLab-summary marker and structured log event; neither is a new network telemetry exporter. The receipt/event never stores rejected text or locations, context text, upstream IDs, URLs, commands, arguments, headers, adapter results, personal display data, or transport diagnostics. v1-v6 result receipts are rejected rather than migrated. A fully validated unprotected receipt alone authorizes the static public limitation; it is structurally approval-ineligible. Schema versions protect serialized trust boundaries; they are not a database-retention promise. The review result crosses from the review process/job to hostile posting readback, so its version prevents an older field set from inheriting newer approval guarantees. Policy and adapter versions similarly bind independent producers/consumers. Ephemeral evidence/context stores accept only their exact current schema and intentionally have no migration or upgrade path. -Automatic approval preserves every existing manifest, coverage, warning, omission, finding, exact-SHA, author, provider, and self-approval gate. Required-source degradation and any admitted remediation thread block approval. Optional non-DLP degradation is visible and cannot prove source absence. DLP-clean title, description, generic discussion, protected CI outcome, and adapter context do not block approval solely because they were inspected; CI status never authorizes approval. DLP rejection degrades its source and can never restore or enable approval. Remediation admission is an independent comment-only condition regardless of the reply's wording. +Automatic approval preserves every existing manifest, coverage, warning, omission, finding, exact-SHA, author, provider, and self-approval gate. Actual unprotected target state is an independent structural blocker checked before any approval mutation. Required-source degradation and any admitted remediation thread block approval. Optional non-DLP degradation is visible and cannot prove source absence. DLP-clean title, description, generic discussion, protected CI outcome, and adapter context do not block approval solely because they were inspected; CI status never authorizes approval. DLP rejection degrades its source and can never restore or enable approval. Remediation admission is an independent comment-only condition regardless of the reply's wording. ## Capability decision for OCR 1.9.8 diff --git a/docs/engineering/review_signal_ownership.md b/docs/engineering/review_signal_ownership.md index 7f22ee9..be74b98 100644 --- a/docs/engineering/review_signal_ownership.md +++ b/docs/engineering/review_signal_ownership.md @@ -9,14 +9,14 @@ a new telemetry API. | Signal | Authoritative source | Toolkit projection | Privacy and authority boundary | | --- | --- | --- | --- | -| Provider/model identity, request/session correlation, retries, latency, HTTP outcome, and cost | OCR and its configured provider telemetry | One closed provider-failure class for static GitLab text; on failure, at most one local toolkit-authored line may add closed protocol detail, one shared HTTP status, and non-zero bounded retry counters | Raw identities, request IDs, response text, provider codes, URLs, paths, warnings, and stderr do not enter the projection, GitLab notes, receipt v6, DLP signals, toolkit telemetry, or approval. HTTP detail does not prove a provider business cause. | +| Provider/model identity, request/session correlation, retries, latency, HTTP outcome, and cost | OCR and its configured provider telemetry | One closed provider-failure class for static GitLab text; on failure, at most one local toolkit-authored line may add closed protocol detail, one shared HTTP status, and non-zero bounded retry counters | Raw identities, request IDs, response text, provider codes, URLs, paths, warnings, and stderr do not enter the projection, GitLab notes, receipt v7, DLP signals, toolkit telemetry, or approval. HTTP detail does not prove a provider business cause. | | Prompt, completion, cached, reasoning, and total tokens | OCR result and OCR telemetry | Closed non-negative provider-neutral token buckets in the result summary and canonical publication comparison | Unknown fields are ignored; malformed or contradictory counters are unavailable. Token counts never authorize approval or automatic routing. | | Review effort and executed rounds | Operator-owned root `effort` config and OCR runtime | `OCR_REVIEW_EFFORT` writes one closed `low`, `medium`, or `high` setting; the toolkit does not publish inferred round telemetry | Merge-request content cannot select effort. Budget or incomplete coverage remains approval-ineligible through the existing result contract. | -| Semantic grouping, group file membership, per-group spans, and filter activity | OCR runtime and OCR telemetry | Additive private result fields may be DLP-sanitized; no group or round field enters receipt v6, GitLab text, fingerprints, severity, lifecycle commands, toolkit telemetry, or approval | Group labels are model-produced. Group keys are sorted changed paths, so both are untrusted and potentially high-cardinality. | -| Tool requests and MCP use | OCR result for aggregate tool calls; each toolkit MCP owner for verified local use | Bounded known-server counts and mandatory evidence-action attribution in receipt v6 and the summary | Tool names and counts are closed; arguments, paths, IDs, results, headers, and content are excluded. | +| Semantic grouping, group file membership, per-group spans, and filter activity | OCR runtime and OCR telemetry | Additive private result fields may be DLP-sanitized; no group or round field enters receipt v7, GitLab text, fingerprints, severity, lifecycle commands, toolkit telemetry, or approval | Group labels are model-produced. Group keys are sorted changed paths, so both are untrusted and potentially high-cardinality. | +| Tool requests and MCP use | OCR result for aggregate tool calls; each toolkit MCP owner for verified local use | Bounded known-server counts and mandatory exact action-receipt-v2 attribution in receipt v7 and the summary | Tool names and counts are closed; arguments, paths, IDs, results, headers, and content are excluded. | | Selection, completed/reused/failed/waived coverage, and aggregate-budget stop | OCR run manifest | Closed result outcome, summary, receipt validation, and approval blockers | Incomplete, malformed, failed, waived, or budget-stopped coverage fails closed; no duplicate toolkit budget metric is needed. | | Findings, severity, fingerprints, suppression, resolution, and repeated discussions | OCR findings plus toolkit-owned posting snapshots and human commands | GitLab discussions, summary, exact fingerprints, and closed lifecycle state | Remediation text and additive group metadata cannot change severity, prove resolution, suppress findings, or issue commands. | -| Context admission, degradation, mutation, and evidence use | Toolkit broker/store and fixed MCP receipts | Count-only receipt-v6 context/evidence state | Raw merge-request title, description, discussions, CI provider identities/payloads, rejected text, and record contents do not enter the receipt or telemetry event. | +| Context admission, degradation, mutation, and evidence use | Toolkit broker/store and fixed MCP receipts | Count-only receipt-v7 context/evidence state | Raw merge-request title, description, discussions, CI provider identities/payloads, rejected text, and record contents do not enter the receipt or telemetry event. | | Publication DLP and posting transaction state | Toolkit result projection and GitLab transaction owner | Receipt-v5 publication state, one parseable summary marker, and one local count-only log event | `private-sanitized` can preserve approval only when the canonical projection is unchanged; `publication-filtered` makes the public projection incomplete and blocks approval without relabelling independently complete OCR coverage. No rejected value or location is emitted. | | OCR compatibility qualification | Compatibility workflow and checksum-pinned evidence | Canonical issue plus bounded success or failure artifact | Public failure status contains only closed phase/reason/version/run fields. Raw qualification exceptions stay in the job log. | diff --git a/docs/engineering/test_evidence_matrix.md b/docs/engineering/test_evidence_matrix.md index a331945..c62ad6b 100644 --- a/docs/engineering/test_evidence_matrix.md +++ b/docs/engineering/test_evidence_matrix.md @@ -134,6 +134,15 @@ The M5 negative suite is an attacker matrix, not merely a replay of observed rev | New selection behavior stays at the OCR edge | exact no-LLM preview/rule resolver -> built-in OCR Rules | repository-relative/backslash `file_find` fallback and `.hbs`/`.mustache` rule selection work without changing toolkit evidence authority | adjacent source audit plus isolated checksum-verified Darwin version/help, rule readback, and soft/hard background previews | selection proves routing, not review finding quality or evidence-pack completion | proven | | Contextual security-signal classification | DLP-checked published finding -> closed metadata/term matcher -> reviewer guide | explicit vulnerability classes remain promoted while neutral injection domains do not increase the count or effort | issue #153 reproducer, closed positive/neutral separator matrix, deterministic ranking, and immutability regressions in `test_posting_helpers.py` | guide analytics only; severity, finding lifecycle, suppression, and approval inputs remain unchanged | proven | +## v0.9.0 source, action, and constrained-target integrity evidence + +| Requirement or boundary | Production owner and entry point | Required observable result | Evidence | Double boundary and claim limit | State | +| --- | --- | --- | --- | --- | --- | +| One effective reviewed source identity | `review_identity.effective_reviewed_sha` shared by review, posting, and pre-execution status validation | a lowercase 40-hex non-zero MR SHA wins; only an absent or all-zero MR SHA may use the exact `CI_COMMIT_SHA`; malformed populated values, stale receipts, and cross-boundary mismatches fail closed | strict scalar/type/value matrix, detached setup/background status cases, provider diff-ref assertions, and stale/malformed receipt cases in `test_gitlab_provider.py`, `test_posting_helpers.py`, and `test_review_runner.py` | environment and local-TLS fixtures prove deterministic parser and production-provider wiring, not GitLab's truthfulness or runner integrity | proven for deterministic identity selection and binding | +| Evidence action receipt cannot be forged or skipped | fixed evidence MCP owner -> owner-only action receipt v2 -> `review_receipt.verified_evidence_actions` -> inode-checked atomic result publication | missing, malformed, incomplete, unwritable, type-confused, or per-tool-mismatched action attribution blocks finalization before findings, advisories, or approval; generated receipt v7 is hostile-validated before publication | real local MCP action-receipt writes plus missing/malformed/incomplete/type/per-tool/producer-validation/advisory-order regressions in `test_evidence_mcp.py`, `test_review_runner.py`, and `test_posting_approval.py` | local protocol and hostile files prove toolkit ownership and reconciliation; they do not prove model judgment or an uncompromised same-user host | proven for exact action-integrity boundary | +| Explicit unprotected target remains constrained and comment-only | `providers.gitlab.acquire_review_snapshot` -> `review_runner` constrained preparation -> receipt-v7 validation -> posting/approval workflow | secure default remains protected-only; explicit unprotected mode accepts only actual unprotected state with `off` or bounded `metadata`, immutable repository evidence, and exact-target Rules; enriched context, adapters, protected policy, external MCP, accepted decisions, structured guidance, and approval mutation remain unreachable | real local HTTPS protection-state matrix, real immutable Git Rules reads, privileged-context/external-MCP rejection ordering, omitted target-policy records, hostile receipt fields, all outcome renderings, and unreachable approval-executor regressions across provider, evidence, runner, posting, and approval tests | controlled GitLab and OCR peers prove deterministic toolkit controls, receipt provenance, and call ordering; they do not establish branch governance, live GitLab merge policy, or semantic finding quality | proven for constrained toolkit path; operational protection remains external | +| Exact OCR 1.11.2 compatibility contract | compatibility gateway -> hosted Linux binary -> human-reviewed evidence promotion | prior result/manifest/budget/provider contracts remain compatible; `.mjs`/`.cjs` select JavaScript Rules, `.cxx`/`.hxx` select C++ Rules, and `.svh` remains excluded | hosted run 33508349494; checksum-pinned `compatibility/evidence/ocr-1.11.2.json`; independent checksum-verified Darwin arm64 no-LLM qualification | deterministic gateways and rule checks prove consumed structural and selection contracts, not provider-backed review quality | proven for exact qualified OCR contract | + ## Complete suite module audit Every top-level test module is classified below. A module can contain more than one evidence class; the strongest class applies only to the named boundary, never to all tests in that file. diff --git a/docs/engineering/toolkit_strategy.md b/docs/engineering/toolkit_strategy.md index bd12547..ae33e90 100644 --- a/docs/engineering/toolkit_strategy.md +++ b/docs/engineering/toolkit_strategy.md @@ -37,7 +37,7 @@ The legacy `context/*` Markdown renderer, its CLI/environment contract, and its The built-in evidence MCP is mandatory for ordinary evidence-backed reviews. External stdio and native HTTPS Streamable HTTP servers compose as independent optional entries; replacement mode may discard stale external entries but cannot remove or shadow the built-in server. The compact bootstrap is generated from the same validated capability composition that is written to OCR. -GitLab result normalization and posting are implemented behind provider-oriented modules. They bound and neutralize model-controlled text, use stable finding fingerprints, preserve human-owned discussions, and keep GitLab credentials outside OCR. Receipt v6 separates byte-equivalent private sanitization from publication filtering through one canonical publication/approval projection and reconciles the fixed summary/list/get/search/coverage action counters. OCR coverage, public-projection integrity, published findings, core advisories, reconciled count-only evidence use, privacy-safe normalized token buckets, multi-finding Recommended focus ranking, and collapsed technical details are separate implemented concepts. The current recommended and tested OCR baseline belongs in the operational compatibility contract, not this durable strategy. +GitLab result normalization and posting are implemented behind provider-oriented modules. They bound and neutralize model-controlled text, use stable finding fingerprints, preserve human-owned discussions, and keep GitLab credentials outside OCR. Receipt v7 separates byte-equivalent private sanitization from publication filtering through one canonical publication/approval projection, reconciles the fixed summary/list/get/search/coverage action counters, and binds the immutable source, target, and actual target-protection state. OCR coverage, public-projection integrity, published findings, core advisories, reconciled count-only evidence use, privacy-safe normalized token buckets, multi-finding Recommended focus ranking, and collapsed technical details are separate implemented concepts. The current recommended and tested OCR baseline belongs in the operational compatibility contract, not this durable strategy. ## Implemented Repository Evidence Engine @@ -113,9 +113,9 @@ Safe direct composition is therefore limited to reviewed narrow read-only tools, ## Bounded review-context enrichment -M5's foundation is established in v0.7.0. It extends the v0.6.3 selection/approval foundation with protected-target policy, stable GitLab discussions, deterministic references, provider-neutral adapters, a separate private context store, opaque handles, fixed `context_list`/`context_get`, isolated OCR sessions, publication DLP, receipt v4, and closed setup/CI-uncertainty outcomes. Receipt v5 in v0.7.1 superseded the result/posting contract without changing the established acquisition boundary. The v0.8.0 release tree adds policy v2 remediation selection, private context-store v2, authenticated toolkit-root ownership, fixed remediation projections, live-username mention commands, and explicit provider-neutral forge views. Toolkit 0.8.7 advances current output to receipt v6 and action receipt v2 for two additional fixed evidence search/coverage tools without reopening provider access or approval authority. It extends bounded invocation evidence without reopening M1/M4 or creating a second review engine. The owner waived the separate enriched OCR+LLM qualification: no receipt proves model-time `context_list`/`context_get`, still-present/evidence-resolved scenarios, or receipt-level raw provider-data leakage inspection. The protected release workflow and independent registry/GitHub readback remain mandatory delivery proof but do not substitute for that absent qualification. +M5's foundation is established in v0.7.0. It extends the v0.6.3 selection/approval foundation with protected-target policy, stable GitLab discussions, deterministic references, provider-neutral adapters, a separate private context store, opaque handles, fixed `context_list`/`context_get`, isolated OCR sessions, publication DLP, receipt v4, and closed setup/CI-uncertainty outcomes. Receipt v5 in v0.7.1 superseded the result/posting contract without changing the established acquisition boundary. The v0.8.0 release tree adds policy v2 remediation selection, private context-store v2, authenticated toolkit-root ownership, fixed remediation projections, live-username mention commands, and explicit provider-neutral forge views. Toolkit 0.8.7 advances output to receipt v6 and action receipt v2 for two additional fixed evidence search/coverage tools without reopening provider access or approval authority. Toolkit 0.9.0 advances the current result boundary to receipt v7 and adds a separately constrained path for an explicitly permitted unprotected GitLab target. That path is comment-only, rejects enriched context, adapters, protected policy acquisition, and direct or inherited external MCP before OCR, omits accepted decisions and structured project guidance, and exposes only immutable repository evidence plus bounded target Rules. It is not protected-policy equivalence and cannot authorize approval. The owner waived the separate enriched OCR+LLM qualification: no receipt proves model-time `context_list`/`context_get`, still-present/evidence-resolved scenarios, or receipt-level raw provider-data leakage inspection. The protected release workflow and independent registry/GitHub readback remain mandatory delivery proof but do not substitute for that absent qualification. -The target architecture acquires forge discussion snapshots and deterministic reference candidates before OCR. An immutable `.opencodereview/review-context-policy.json` read only from the captured protected-target SHA independently controls admission, retrieval, model egress, publication, and retention. Recognizers produce candidates but never authorize them. A provider adapter must authorize the exact tenant, canonical object, fields, and operation, retrieve a bounded version-bound projection, apply normalization and DLP, and atomically commit it to a run-local context store before an opaque unguessable handle is minted. Handles bind run, adapter, tenant, canonical object, projection, version or digest, policy version, expiry, and stored record without exposing the upstream identifier. +For protected targets, the target architecture acquires forge discussion snapshots and deterministic reference candidates before OCR. An immutable `.opencodereview/review-context-policy.json` read only from the captured protected-target SHA independently controls admission, retrieval, model egress, publication, and retention. Recognizers produce candidates but never authorize them. A provider adapter must authorize the exact tenant, canonical object, fields, and operation, retrieve a bounded version-bound projection, apply normalization and DLP, and atomically commit it to a run-local context store before an opaque unguessable handle is minted. Handles bind run, adapter, tenant, canonical object, projection, version or digest, policy version, expiry, and stored record without exposing the upstream identifier. During OCR, the model may list or read only minted handles through fixed toolkit-authored closed-schema tools projected by the existing toolkit MCP process. The brokered M5 context path adds no upstream search, arbitrary URL/ID fetch, external schema, redirect, traversal, write, or external network path to the model loop; separately configured direct operator MCP retains its existing privileged, comment-only boundary. Context budgets cannot evict repository evidence. Forge authors use provider-declared account classes and run-local pseudonyms rather than names, email, avatars, or profile URLs. Unknown identity, authorization, DLP, completeness, version, or OCR capability fails closed; unavailable, partial, stale, or mutated context stays visible and cannot prove absence. DLP-clean metadata, generic discussions, and adapter records do not independently block automatic approval; required-source degradation, any DLP rejection, or an admitted remediation thread does. Context cannot change policy, tools, permissions, lifecycle commands, suppression, posting authority, or approval. diff --git a/tests/test_integration_contracts.py b/tests/test_integration_contracts.py index 56911cf..6bae16a 100644 --- a/tests/test_integration_contracts.py +++ b/tests/test_integration_contracts.py @@ -313,7 +313,10 @@ def test_public_docs_describe_the_established_m5_boundary() -> None: assert "receipt v7" in document assert "review-context.md" in document assert "M5's foundation is established in v0.7.0" in strategy + assert "Toolkit 0.9.0 advances the current result boundary to receipt v7" in strategy + assert "It is not protected-policy equivalence" in strategy assert "M5 Bounded review-context enrichment
established" in roadmap + assert "Toolkit 0.9.0 adds receipt-v7 source/target/protection binding" in roadmap assert "DLP-clean metadata, generic discussions, and adapter records" in strategy assert "v0.8.0 release tree completes its remediation/provider-neutral extension" in roadmap assert "explicit owner waiver for the separate enriched OCR+LLM receipt" in roadmap diff --git a/tests/test_operations_docs.py b/tests/test_operations_docs.py index 6fe3578..ee34e23 100644 --- a/tests/test_operations_docs.py +++ b/tests/test_operations_docs.py @@ -133,6 +133,8 @@ def test_review_signal_audit_keeps_group_data_outside_toolkit_authority() -> Non for phrase in ( "Source-to-signal matrix", + "receipt v7", + "action-receipt-v2 attribution", "Group labels are model-produced", "sorted changed paths", "no exporter of its own", @@ -145,6 +147,7 @@ def test_review_signal_audit_keeps_group_data_outside_toolkit_authority() -> Non assert phrase in audit assert "Review measurement gaps (BL-017) | Completed and removed" in backlog assert "M6 Profiles and quality measurement | Established / conditional" in roadmap + assert "Current receipt v7 adds immutable target/protection identity" in roadmap def test_community_conduct_policy_has_a_private_enforcement_route() -> None: diff --git a/tests/test_posting_helpers.py b/tests/test_posting_helpers.py index 21967ec..6d834b4 100644 --- a/tests/test_posting_helpers.py +++ b/tests/test_posting_helpers.py @@ -89,7 +89,7 @@ def fake_api_request(*args: Any, **kwargs: Any) -> dict[str, int]: self.assertEqual(exit_code, 1) self.assertEqual(calls, []) - def test_invalid_v6_publication_state_never_reaches_normal_result_flow(self) -> None: + def test_invalid_v7_publication_state_never_reaches_normal_result_flow(self) -> None: notes: list[str] = [] result_data = { "status": "failed", From d830c6a3608a092a1f481b7a514f0ea863fd81f6 Mon Sep 17 00:00:00 2001 From: xeonvs <11463419+xeonvs@users.noreply.github.com> Date: Wed, 2 Sep 2026 10:32:57 +0200 Subject: [PATCH 08/15] plan: record deterministic release gates --- PLANS.md | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/PLANS.md b/PLANS.md index cacf962..e91a61f 100644 --- a/PLANS.md +++ b/PLANS.md @@ -86,11 +86,13 @@ attribution, detached-pipeline diagnostics, and OCR 1.11.2 compatibility. #167-#169 exercises the effective SHA matrix, strict evidence-action receipt reconciliation, atomic receipt identity, unprotected context/MCP rejection, static limitation provenance, and the unreachable approval executor. -8. [ ] Review every commit and the complete `origin/main..HEAD` range, run all deterministic local +8. [x] Review every commit and the complete `origin/main..HEAD` range, run all deterministic local gates, and fix supported findings in separately reviewed signed commits. The holistic source, test, and documentation review found no runtime defect; it reconciled the current receipt-v7, source/target identity, action-integrity, and constrained-target evidence/status contracts. - Complete deterministic gates remain before this step closes. + The complete suite passed with 1,438 tests and 397 subtests at 86.37% coverage; every scoped + risk floor passed. Compatibility, Towncrier draft, pinned Gitleaks, dependency audit, signatures, + diff checks, clean build/Twine, archive privacy, and separate wheel/sdist CLI smokes also passed. 9. [ ] Run exactly one Codex Security diff scan for `origin/main..HEAD`, validate attack paths, fix supported findings, and repeat holistic review plus deterministic validation. 10. [ ] Checksum-verify and atomically install PATH-effective OCR 1.11.2 with a rollback copy, then From 16896e84ebdbf1b473877c49d7bde27453e3633f Mon Sep 17 00:00:00 2001 From: xeonvs <11463419+xeonvs@users.noreply.github.com> Date: Wed, 2 Sep 2026 10:44:32 +0200 Subject: [PATCH 09/15] fix: harden review identity readback --- PLANS.md | 7 ++++++- src/ocr_toolkit/posting/workflow.py | 4 ++-- src/ocr_toolkit/review_identity.py | 4 ++-- tests/test_posting_approval.py | 2 ++ tests/test_posting_helpers.py | 9 ++++++++- 5 files changed, 20 insertions(+), 6 deletions(-) diff --git a/PLANS.md b/PLANS.md index e91a61f..ae70797 100644 --- a/PLANS.md +++ b/PLANS.md @@ -94,7 +94,12 @@ attribution, detached-pipeline diagnostics, and OCR 1.11.2 compatibility. risk floor passed. Compatibility, Towncrier draft, pinned Gitleaks, dependency audit, signatures, diff checks, clean build/Twine, archive privacy, and separate wheel/sdist CLI smokes also passed. 9. [ ] Run exactly one Codex Security diff scan for `origin/main..HEAD`, validate attack paths, fix - supported findings, and repeat holistic review plus deterministic validation. + supported findings, and repeat holistic review plus deterministic validation. Scan + `cecd81ce-bc01-42eb-bedf-a4a2a44a096c` completed with full changed-range coverage and no + reportable findings. Its two rejected candidates still identified useful fail-closed contract + hardening: surrounding whitespace must not normalize an inherited SHA, and summary/approval + identities must come only from a fully valid receipt. Both controls and hostile regressions are + included in the current remediation commit before the repeated deterministic gates. 10. [ ] Checksum-verify and atomically install PATH-effective OCR 1.11.2 with a rollback copy, then run exactly one configured-provider local OCR review of the complete exact range with context `off`, public Rules, concurrency 2, and owner-only artifacts. Inspect complete manifest coverage diff --git a/src/ocr_toolkit/posting/workflow.py b/src/ocr_toolkit/posting/workflow.py index 1cf1b97..8a3e97e 100644 --- a/src/ocr_toolkit/posting/workflow.py +++ b/src/ocr_toolkit/posting/workflow.py @@ -110,7 +110,7 @@ ) from ocr_toolkit.result_contract import OcrResultContractError, ReviewOutcome, parse_result_outcome from ocr_toolkit.review_identity import effective_reviewed_sha -from ocr_toolkit.review_receipt import receipt_review_identity, validated_review_identity +from ocr_toolkit.review_receipt import validated_review_identity # Kept as a module-level compatibility seam for tests and external monkey-patching. post_review_note = gitlab_api.post_review_note @@ -159,7 +159,7 @@ def mr_head_sha() -> str: def approval_receipt_identity(toolkit_metadata: Any) -> tuple[str, int | None]: """Return only validated-by-policy receipt identities for provider readback.""" - identity = receipt_review_identity(toolkit_metadata) + identity = validated_review_identity(toolkit_metadata) if identity is None: return "", None return identity.source_sha, identity.mr_author_id diff --git a/src/ocr_toolkit/review_identity.py b/src/ocr_toolkit/review_identity.py index e432966..109576d 100644 --- a/src/ocr_toolkit/review_identity.py +++ b/src/ocr_toolkit/review_identity.py @@ -20,7 +20,7 @@ def full_sha(value: object) -> str: def effective_reviewed_sha(environment: Mapping[str, str]) -> str: """Resolve the MR source head with the documented detached-pipeline fallback.""" - merge_request_sha = environment.get("CI_MERGE_REQUEST_SOURCE_BRANCH_SHA", "").strip() + merge_request_sha = environment.get("CI_MERGE_REQUEST_SOURCE_BRANCH_SHA", "") if merge_request_sha and merge_request_sha != ZERO_SHA: return full_sha(merge_request_sha) - return full_sha(environment.get("CI_COMMIT_SHA", "").strip()) + return full_sha(environment.get("CI_COMMIT_SHA", "")) diff --git a/tests/test_posting_approval.py b/tests/test_posting_approval.py index b0c0619..c967eb8 100644 --- a/tests/test_posting_approval.py +++ b/tests/test_posting_approval.py @@ -1001,6 +1001,8 @@ def test_receipt_identity_is_atomic_for_summary_and_approval(self) -> None: lambda value: value["review"].update({"source_sha": "invalid"}), lambda value: value["review"].update({"mr_author_id": None}), lambda value: value["review"].update({"mr_author_id": True}), + lambda value: value.update({"cleanup": {"result": "unknown"}}), + lambda value: value.update({"extra": True}), ): candidate = receipt_v7(author_id=41) mutate(candidate) diff --git a/tests/test_posting_helpers.py b/tests/test_posting_helpers.py index 6d834b4..42410b1 100644 --- a/tests/test_posting_helpers.py +++ b/tests/test_posting_helpers.py @@ -64,8 +64,15 @@ def test_effective_reviewed_sha_has_one_strict_detached_pipeline_fallback(self) ("d" * 40, valid, "d" * 40), ("B" * 40, valid, ""), ("bad", valid, ""), + (f" {valid}", commit, ""), + (f"{valid} ", commit, ""), + (f"{valid}\n", commit, ""), + (f"{'0' * 40} ", commit, ""), ("", "C" * 40, ""), ("", "bad", ""), + ("", f" {valid}", ""), + ("", f"{valid} ", ""), + ("", f"{valid}\n", ""), ): with self.subTest(merge_request_sha=merge_request_sha, commit_sha=commit_sha): environment = { @@ -2102,7 +2109,7 @@ def test_workflow_identity_helpers_fail_closed_on_ambiguous_values(self) -> None }, } ), - ("a" * 40, 41), + ("", None), ) with patched_env( From cb9f4d9f39305e4cfdae5d4c91be7138edd0c4e3 Mon Sep 17 00:00:00 2001 From: xeonvs <11463419+xeonvs@users.noreply.github.com> Date: Wed, 2 Sep 2026 10:49:20 +0200 Subject: [PATCH 10/15] plan: reconcile security review --- PLANS.md | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/PLANS.md b/PLANS.md index ae70797..b85f69c 100644 --- a/PLANS.md +++ b/PLANS.md @@ -93,13 +93,16 @@ attribution, detached-pipeline diagnostics, and OCR 1.11.2 compatibility. The complete suite passed with 1,438 tests and 397 subtests at 86.37% coverage; every scoped risk floor passed. Compatibility, Towncrier draft, pinned Gitleaks, dependency audit, signatures, diff checks, clean build/Twine, archive privacy, and separate wheel/sdist CLI smokes also passed. -9. [ ] Run exactly one Codex Security diff scan for `origin/main..HEAD`, validate attack paths, fix +9. [x] Run exactly one Codex Security diff scan for `origin/main..HEAD`, validate attack paths, fix supported findings, and repeat holistic review plus deterministic validation. Scan `cecd81ce-bc01-42eb-bedf-a4a2a44a096c` completed with full changed-range coverage and no reportable findings. Its two rejected candidates still identified useful fail-closed contract hardening: surrounding whitespace must not normalize an inherited SHA, and summary/approval identities must come only from a fully valid receipt. Both controls and hostile regressions are - included in the current remediation commit before the repeated deterministic gates. + included in the remediation commit. The post-remediation holistic review found no remaining + defect, and the repeated deterministic gates passed with 1,438 tests, 406 subtests, 86.35% + coverage, all scoped risk floors, compatibility, Towncrier, Gitleaks, dependency audit, + signatures, diff checks, clean build/Twine, archive privacy, and wheel/sdist CLI smokes green. 10. [ ] Checksum-verify and atomically install PATH-effective OCR 1.11.2 with a rollback copy, then run exactly one configured-provider local OCR review of the complete exact range with context `off`, public Rules, concurrency 2, and owner-only artifacts. Inspect complete manifest coverage From 782b205b8f8a2a5a2491c4f25b2b444a5e40c16b Mon Sep 17 00:00:00 2001 From: xeonvs <11463419+xeonvs@users.noreply.github.com> Date: Wed, 2 Sep 2026 12:35:24 +0200 Subject: [PATCH 11/15] fix: reconcile attempted evidence actions --- PLANS.md | 37 +- changelog.d/169.bugfix.md | 1 + docs/configuration.md | 4 +- docs/engineering/m5_context_contracts.md | 2 +- docs/engineering/review_signal_ownership.md | 2 +- docs/engineering/test_evidence_matrix.md | 2 +- docs/engineering/toolkit_strategy.md | 2 +- docs/operations.md | 2 +- docs/security.md | 4 +- src/ocr_toolkit/evidence/actions.py | 50 ++- src/ocr_toolkit/evidence/mcp.py | 32 +- src/ocr_toolkit/posting/formatting.py | 36 +- src/ocr_toolkit/posting/workflow.py | 22 +- src/ocr_toolkit/review_receipt.py | 97 ++++- src/ocr_toolkit/review_runner.py | 23 +- tests/support.py | 105 +++++ tests/test_evidence_actions.py | 175 ++++++++- tests/test_evidence_mcp.py | 150 ++++++- tests/test_gitlab_provider.py | 20 +- tests/test_operations_docs.py | 8 +- tests/test_posting_approval.py | 50 ++- tests/test_posting_helpers.py | 282 +++++++++----- tests/test_review_runner.py | 412 ++++++++++++++++++-- 23 files changed, 1236 insertions(+), 282 deletions(-) create mode 100644 changelog.d/169.bugfix.md diff --git a/PLANS.md b/PLANS.md index b85f69c..d8e837f 100644 --- a/PLANS.md +++ b/PLANS.md @@ -103,10 +103,32 @@ attribution, detached-pipeline diagnostics, and OCR 1.11.2 compatibility. defect, and the repeated deterministic gates passed with 1,438 tests, 406 subtests, 86.35% coverage, all scoped risk floors, compatibility, Towncrier, Gitleaks, dependency audit, signatures, diff checks, clean build/Twine, archive privacy, and wheel/sdist CLI smokes green. -10. [ ] Checksum-verify and atomically install PATH-effective OCR 1.11.2 with a rollback copy, then - run exactly one configured-provider local OCR review of the complete exact range with context - `off`, public Rules, concurrency 2, and owner-only artifacts. Inspect complete manifest coverage - and receipt/action attribution; fix findings and repeat deterministic validation only. +10. [ ] The checksum-verified PATH-effective OCR 1.11.2 installation and its rollback copy are + complete. The one permitted configured-provider review ran over exact range + `b9a0e54af7f39a1db21e2a8f4780761e74782bf8..cb9f4d9f39305e4cfdae5d4c91be7138edd0c4e3` + with context `off`, public Rules, and concurrency 2, then failed closed before publication because + OCR counts a dynamic MCP tool attempt before argument parsing/execution while private action + receipt v2 counted only completed calls. No publishable result or complete manifest survived, no + posting occurred. The owner subsequently authorized one repeat diagnostic OCR run with all raw + private artifacts retained; it must run only after the root fix and relevant deterministic gates. + The deterministic root remediation is complete with count-only action receipt v3: it authenticates + MCP-received attempts and completed counts separately, retains a closed unattributed-attempt + counter for malformed primary-tool actions and OCR-counted requests that fail argument parsing + before MCP dispatch, reconciles received counts as subsets of OCR's authoritative by-tool + attempts, requires a completed `summary` for mandatory evidence, and exposes only completed + actions as successful evidence use. + Hostile, malformed, failed-call, concurrency, receipt-readback, formatting, approval, and publication + regressions restore the complete #167-#169 chain and adversarially cover parser, persistence, + reconciliation, receipt, approval, and publication transitions. A completion is recorded only when + that same request durably recorded its attempt, so it cannot consume an unmatched attempt retained + from an earlier failed call. Normal posting now rejects every present incomplete or invalid receipt + before reading prior review state or publishing findings; only a genuinely absent receipt keeps the + compatible direct path. The complete deterministic suite passed with 1,468 tests and 407 subtests at + 86.31% coverage; compatibility, Towncrier, Gitleaks, dependency audit, signatures, diff checks, + deterministic double build, Twine, archive privacy, and clean wheel/sdist CLI smokes are green. The + owner-authorized retained-artifact OCR repeat remains pending. Close this gate only after its raw + result, terminal manifest coverage, action counters, and offline finalization all validate; do not + claim OCR completion or findings earlier. 11. [ ] Push the locally closed implementation, finish hosted checks and review with zero unresolved threads, mark the Draft PR ready, and exact-head squash merge. Delete the feature branch. 12. [ ] Verify the protected-main TestPyPI development publication, create `release/v0.9.0`, set @@ -126,6 +148,7 @@ staged diff for correctness, hostile inputs, DLP/privacy, and scope, then run `g Final deterministic gates are `scripts/quality.sh check`, compatibility validation, Towncrier draft, Gitleaks, build/Twine, archive/privacy checks, and clean wheel/sdist CLI smoke. OCR exit zero is not -completion without complete selected-item coverage and exact receipt/action reconciliation. A second -semantic local OCR run is prohibited. Stable delivery remains incomplete until external publication -and independent reconciliation are complete. +completion without complete selected-item coverage and exact receipt/action reconciliation. The one +owner-authorized repeat semantic OCR run must preserve every private intermediate artifact and may run +only after the root fix is committed; no additional semantic run is authorized. Stable delivery remains +incomplete until external publication and independent reconciliation are complete. diff --git a/changelog.d/169.bugfix.md b/changelog.d/169.bugfix.md new file mode 100644 index 0000000..7132dbc --- /dev/null +++ b/changelog.d/169.bugfix.md @@ -0,0 +1 @@ +Reconcile OCR 1.11.2's attempted built-in evidence tool counts with private action receipt v3 while retaining completed actions as the only source of successful evidence use and approval authority; failed or malformed calls cannot satisfy the mandatory summary. diff --git a/docs/configuration.md b/docs/configuration.md index f3eb5eb..d154f91 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -225,7 +225,7 @@ environment variables for policy thresholds or category lists in this release. ## Repository evidence -`ocr-ci review` owns this lifecycle. Before OCR starts it collects the exact immutable `--from`/`--to` refs (or the parent/commit pair selected by `--commit`), writes bounded redacted schema-versioned evidence, builds OCR's MCP registry with the mandatory evidence entry plus each independently configured optional server, reads the registry back, self-queries the evidence summary/list/get contract, and supplies the matching compact bootstrap to OCR. Those parent-process preflight calls are not counted as model use. The same preflight-qualified OCR executable first receives the exact production refs, rules, selection options, and background under `review --preview`; there is no toolkit threshold setting or duplicated OCR threshold constant. An exact recognized OCR soft background diagnostic becomes a toolkit-authored numeric `ocr.toolkit-advisory/v1` value only after publication DLP and appears in the bounded CI log plus GitLab Technical details. It is not an OCR warning, receipt or DLP input, coverage signal, telemetry field, or approval blocker. An exact recognized hard character/file-size rejection stops before model execution and leaves only an identity-bound numeric `ocr.pre-execution-status/v2` outcome for static GitLab reporting; the private path and raw OCR diagnostic are not published. Unknown preview failures fail closed through the generic diagnostic path, and the actual review independently revalidates the background. During OCR, the built-in MCP atomically records only completed `summary`, `list`, `get`, `search`, and `coverage` counts without arguments, queries, IDs, paths, results, or content. The parent reads and removes private action receipt v2 before cleanup. Receipt v7 is approval-valid only when the primary-tool action subtotal and the two dedicated-tool counts exactly match OCR's three `tool_calls.by_tool` entries; missing, malformed, raced, or mismatched attribution fails review finalization before a normal publishable result exists. Since OCR 1.9.9, `--background-file` takes precedence over inline `--background`, so `ocr-ci review` rejects caller forms of both options, including split and `--option=value` syntax, and remains the sole owner of the bootstrap input; caller `--preview` is likewise rejected because the toolkit owns this gate. OCR 1.10.0 adds `--output`, but `ocr-ci review` rejects its long, equals, short, and attached forms because the toolkit must remain the sole owner of the private result descriptor, atomic parsing, cleanup, and posting handoff. The 1.9.10 stage-grouped terminal retry report remains private and does not enter toolkit telemetry, receipts, DLP, findings, severity, outcomes, or approval. A completed OCR review is accepted only when structured `tool_calls.by_tool` proves at least one `ocr_toolkit_evidence` call; a legitimately skipped no-supported-files review remains exempt. +`ocr-ci review` owns this lifecycle. Before OCR starts it collects the exact immutable `--from`/`--to` refs (or the parent/commit pair selected by `--commit`), writes bounded redacted schema-versioned evidence, builds OCR's MCP registry with the mandatory evidence entry plus each independently configured optional server, reads the registry back, self-queries the evidence summary/list/get contract, and supplies the matching compact bootstrap to OCR. Those parent-process preflight calls are not counted as model use. The same preflight-qualified OCR executable first receives the exact production refs, rules, selection options, and background under `review --preview`; there is no toolkit threshold setting or duplicated OCR threshold constant. An exact recognized OCR soft background diagnostic becomes a toolkit-authored numeric `ocr.toolkit-advisory/v1` value only after publication DLP and appears in the bounded CI log plus GitLab Technical details. It is not an OCR warning, receipt or DLP input, coverage signal, telemetry field, or approval blocker. An exact recognized hard character/file-size rejection stops before model execution and leaves only an identity-bound numeric `ocr.pre-execution-status/v2` outcome for static GitLab reporting; the private path and raw OCR diagnostic are not published. Unknown preview failures fail closed through the generic diagnostic path, and the actual review independently revalidates the background. During OCR, the built-in MCP records count-only attempted and completed `summary`, `list`, `get`, `search`, and `coverage` actions without arguments, queries, IDs, paths, results, or content. Unknown or malformed MCP-dispatched primary-tool actions increment only a closed `unattributed` attempt counter. OCR also counts a dynamic tool request before parsing its JSON arguments; a parse failure never reaches the MCP owner, so receipt finalization adds only that count-only by-tool residual to `unattributed`. The parent reads and removes private action receipt v3 before cleanup. Receipt v7 is approval-valid only when MCP-received attempts do not exceed OCR's authoritative `tool_calls.by_tool` entries and every residual OCR attempt is accounted as unattributed; failed or malformed attempts cannot satisfy the mandatory completed `summary`, become successful evidence use, or authorize approval. Missing, malformed, raced, or mismatched attribution fails review finalization before a normal publishable result exists. Since OCR 1.9.9, `--background-file` takes precedence over inline `--background`, so `ocr-ci review` rejects caller forms of both options, including split and `--option=value` syntax, and remains the sole owner of the bootstrap input; caller `--preview` is likewise rejected because the toolkit owns this gate. OCR 1.10.0 adds `--output`, but `ocr-ci review` rejects its long, equals, short, and attached forms because the toolkit must remain the sole owner of the private result descriptor, atomic parsing, cleanup, and posting handoff. The 1.9.10 stage-grouped terminal retry report remains private and does not enter toolkit telemetry, receipts, DLP, findings, severity, outcomes, or approval. A completed OCR review is accepted only when structured `tool_calls.by_tool` proves at least one `ocr_toolkit_evidence` attempt and action receipt v3 proves a completed summary; a legitimately skipped no-supported-files review remains exempt. The private `.review-context/evidence.json`, `.review-context/bootstrap.md`, repository-policy `.review-context/policy-rules.json`, and count-only evidence-action receipt/lock are internal implementation details, not public path configuration. Keep `.review-context/` ignored. The directory is mode `0700`, regular files are mode `0600`, and symlink, hard-link, non-regular, or unexpectedly permissive receipt targets are rejected. In GitLab MR pipelines, the provider adapter captures the current protected target SHA, fetches that exact immutable object when needed, and materializes only an in-repository `--rule` blob from it; explicit absolute rules outside the repository remain operator-owned. OCR still reviews the original forge diff-base-to-source-head range. The collector reads Git objects without checkout, does not follow repository symlinks or submodules, never executes repository content, and treats source-ref policy changes as untrusted. @@ -241,7 +241,7 @@ Evidence-store schema v4 retains v1-v3 readback and adds a distinct immutable po In `metadata` mode, GitLab MR acquisition normalizes only title, description, labels, optional source branch, and the reviewed source SHA into `review.merge_request_context/v1`. Values are complete-field bounded, NFC-normalized, control-stripped, redacted, source-head-bound invocation data. Raw values never enter bootstrap, argv, environment, diagnostics, or receipts; bootstrap lists only field statuses and toolkit-authored comparison guidance. In `off` mode none of those mutable text fields reaches normalization or persistence. OCR may treat matching intent as evidence against an assumption-dependent concern, contradictory intent as mismatch evidence, and missing intent as unknown. The source-branch hint is weaker than an explicit description and cannot establish rollout intent by itself. Metadata cannot authorize tools, policy, suppression, posting, or approval. In `enriched`, references are extracted only from admitted metadata and admitted discussion bodies; adapters authorize them before local handle minting. There is no generic URL, identifier, search, or provider-tool path in the model loop. -The review step writes exact closed receipt v7 inside the private result only after cleanup and the inode-checked atomic publication transformation. It binds source, policy, and target SHA, the actual closed `protected|unprotected` target state, merge-request author ID where applicable, context mode/state/classes, per-source completeness/degradation, admitted-mutable state, the complete bounded MCP capability inventory, positive known-server and fixed tool usage, verified action-receipt v2 counts, mandatory-evidence state, publication-DLP result, and cleanup result. The current closed states are `passed`, `private-sanitized`, and `publication-filtered`. A pure canonical projection covers the normalized outcome/message, ordered allowlisted finding fields and warnings, manifest coverage/failure details, displayed tool counters, normalized token telemetry, omission/completeness, and approval inputs. Token telemetry has a closed provider-neutral vocabulary: input, output, cached as a subset of input, reasoning as a subset of output, optional validated total, and mathematically derived other; malformed or contradictory telemetry is unavailable and unknown provider keys are never published. Private sanitization may retain the original complete result and continue through existing approval gates only when that projection is byte-equivalent before and after sanitization. Any changed, malformed, or incomparable projection becomes a safe `publication-filtered` result with closed retained/omitted/original counts; it preserves the previous review and cannot authorize approval. The public projection may be incomplete while its validated original coverage counts still prove a complete OCR run, so the GitLab summary reports those dimensions separately and never feeds filtered warnings into legacy failed-item inference. Horizontal tab is admitted only in the closed `existing_code` and `suggestion_code` finding fields and remains subject to every non-control DLP check; tabs in all other fields and every other control/format character remain invalid. Receipt v1-v6 has no posting or approval compatibility. The later GitLab posting step reads v7 instead of rebuilding context or MCP facts from a possibly changed environment. Its summary exposes only reconciled non-zero numeric server/action counts; the receipt/event never stores search queries, coverage scopes, rejected text/locations, provider/context text, upstream IDs, server URLs, commands, setup, arguments, headers, tool inputs/results, credentials, or repository contents. +The review step writes exact closed receipt v7 inside the private result only after cleanup and the inode-checked atomic publication transformation. It binds source, policy, and target SHA, the actual closed `protected|unprotected` target state, merge-request author ID where applicable, context mode/state/classes, per-source completeness/degradation, admitted-mutable state, the complete bounded MCP capability inventory, positive known-server and fixed tool usage, verified action-receipt v3 attempted/completed counts, mandatory-evidence state, publication-DLP result, and cleanup result. The current closed states are `passed`, `private-sanitized`, and `publication-filtered`. A pure canonical projection covers the normalized outcome/message, ordered allowlisted finding fields and warnings, manifest coverage/failure details, displayed tool counters, normalized token telemetry, omission/completeness, and approval inputs. Token telemetry has a closed provider-neutral vocabulary: input, output, cached as a subset of input, reasoning as a subset of output, optional validated total, and mathematically derived other; malformed or contradictory telemetry is unavailable and unknown provider keys are never published. Private sanitization may retain the original complete result and continue through existing approval gates only when that projection is byte-equivalent before and after sanitization. Any changed, malformed, or incomparable projection becomes a safe `publication-filtered` result with closed retained/omitted/original counts; it preserves the previous review and cannot authorize approval. The public projection may be incomplete while its validated original coverage counts still prove a complete OCR run, so the GitLab summary reports those dimensions separately and never feeds filtered warnings into legacy failed-item inference. Horizontal tab is admitted only in the closed `existing_code` and `suggestion_code` finding fields and remains subject to every non-control DLP check; tabs in all other fields and every other control/format character remain invalid. Receipt v1-v6 has no posting or approval compatibility. A present incomplete or invalid v7 receipt is rejected before previous review state is read or normal findings are published; only a genuinely absent receipt retains the compatible direct-posting path. The later GitLab posting step reads a complete valid v7 receipt instead of rebuilding context or MCP facts from a possibly changed environment. Its summary exposes only reconciled non-zero numeric server/action counts; the receipt/event never stores search queries, coverage scopes, rejected text/locations, provider/context text, upstream IDs, server URLs, commands, setup, arguments, headers, tool inputs/results, credentials, or repository contents. ### Accepted project decisions diff --git a/docs/engineering/m5_context_contracts.md b/docs/engineering/m5_context_contracts.md index ada99fa..95eb6d5 100644 --- a/docs/engineering/m5_context_contracts.md +++ b/docs/engineering/m5_context_contracts.md @@ -105,7 +105,7 @@ Publication validation runs after OCR and cleanup, within the same inode-checked The result has two explicit DLP projections. Publication sinks are exactly values the posting owner may render: outcome message, allowlisted finding fields, warnings, displayed tool names, and manifest-failure path/reason fields. If one is unsafe, the raw result is atomically replaced by an explicit safe filtered result containing independently passed findings/warnings plus closed reason, omission, original-coverage, and tool-use facts. Horizontal tab is admitted as a control character only in `existing_code` and `suggestion_code`; the unchanged code value is retained only after all remaining DLP checks pass. Non-rendered OCR metadata is a separate private-retention projection: unsafe keys are removed and unsafe string values receive stable non-reversible placeholders before the result is retained. When that sanitized result still satisfies the OCR result contract, its original status, manifest, and safe findings remain intact; a required structural-field loss falls back to the filtered form. Neither path retains the rejected value or its location. GitLab may publish the safe result with a filtered signal, while retaining the previous review, consuming prior matching fingerprints one-for-one, replacing only an earlier toolkit setup-pending note on retry, and blocking approval. Validated original coverage kind/counts remain presentation evidence distinct from public-projection completeness; filtered warnings never create legacy coverage failures. This is containment of deterministic exact-output classes, not a claim to detect shorter arbitrary excerpts, arbitrary semantic paraphrase, or undo content already sent to the model. -Receipt schema `ocr.toolkit-receipt/v7` stores only closed source, policy, target, actual target-protection, and author identities; context mode, per-source completeness and degradation counts; admitted-mutable state; fixed tool usage with exact action-receipt-v2 reconciliation; publication-DLP result; and cleanup result. Policy and target SHA are identical because all target-derived material is read from that exact immutable commit, including the constrained Rules-only path. Its publication states are `passed`, `private-sanitized`, and `publication-filtered`. Private sanitization keeps normal approval evaluation only when the canonical publication/approval projection is byte-equivalent; changed, malformed, or incomparable projections become safe partial results. The corresponding closed counts form an `ocr.publication-dlp-signal/v2` GitLab-summary marker and structured log event; neither is a new network telemetry exporter. The receipt/event never stores rejected text or locations, context text, upstream IDs, URLs, commands, arguments, headers, adapter results, personal display data, or transport diagnostics. v1-v6 result receipts are rejected rather than migrated. A fully validated unprotected receipt alone authorizes the static public limitation; it is structurally approval-ineligible. +Receipt schema `ocr.toolkit-receipt/v7` stores only closed source, policy, target, actual target-protection, and author identities; context mode, per-source completeness and degradation counts; admitted-mutable state; fixed tool usage with complete action-receipt-v3 attempted/completed accounting; publication-DLP result; and cleanup result. Policy and target SHA are identical because all target-derived material is read from that exact immutable commit, including the constrained Rules-only path. Its publication states are `passed`, `private-sanitized`, and `publication-filtered`. Private sanitization keeps normal approval evaluation only when the canonical publication/approval projection is byte-equivalent; changed, malformed, or incomparable projections become safe partial results. The corresponding closed counts form an `ocr.publication-dlp-signal/v2` GitLab-summary marker and structured log event; neither is a new network telemetry exporter. The receipt/event never stores rejected text or locations, context text, upstream IDs, URLs, commands, arguments, headers, adapter results, personal display data, or transport diagnostics. v1-v6 result receipts are rejected rather than migrated. A fully validated unprotected receipt alone authorizes the static public limitation; it is structurally approval-ineligible. Schema versions protect serialized trust boundaries; they are not a database-retention promise. The review result crosses from the review process/job to hostile posting readback, so its version prevents an older field set from inheriting newer approval guarantees. Policy and adapter versions similarly bind independent producers/consumers. Ephemeral evidence/context stores accept only their exact current schema and intentionally have no migration or upgrade path. diff --git a/docs/engineering/review_signal_ownership.md b/docs/engineering/review_signal_ownership.md index be74b98..be310e0 100644 --- a/docs/engineering/review_signal_ownership.md +++ b/docs/engineering/review_signal_ownership.md @@ -13,7 +13,7 @@ a new telemetry API. | Prompt, completion, cached, reasoning, and total tokens | OCR result and OCR telemetry | Closed non-negative provider-neutral token buckets in the result summary and canonical publication comparison | Unknown fields are ignored; malformed or contradictory counters are unavailable. Token counts never authorize approval or automatic routing. | | Review effort and executed rounds | Operator-owned root `effort` config and OCR runtime | `OCR_REVIEW_EFFORT` writes one closed `low`, `medium`, or `high` setting; the toolkit does not publish inferred round telemetry | Merge-request content cannot select effort. Budget or incomplete coverage remains approval-ineligible through the existing result contract. | | Semantic grouping, group file membership, per-group spans, and filter activity | OCR runtime and OCR telemetry | Additive private result fields may be DLP-sanitized; no group or round field enters receipt v7, GitLab text, fingerprints, severity, lifecycle commands, toolkit telemetry, or approval | Group labels are model-produced. Group keys are sorted changed paths, so both are untrusted and potentially high-cardinality. | -| Tool requests and MCP use | OCR result for aggregate tool calls; each toolkit MCP owner for verified local use | Bounded known-server counts and mandatory exact action-receipt-v2 attribution in receipt v7 and the summary | Tool names and counts are closed; arguments, paths, IDs, results, headers, and content are excluded. | +| Tool requests and MCP use | OCR result for aggregate tool attempts; each toolkit MCP owner for authenticated local attempts and completions | Bounded known-server attempt counts and mandatory action-receipt-v3 attempted/completed accounting in receipt v7; the summary renders only completed named evidence actions | Tool names and counts are closed; unattributed attempts stay private, and arguments, paths, queries, scopes, IDs, results, headers, and content are excluded. | | Selection, completed/reused/failed/waived coverage, and aggregate-budget stop | OCR run manifest | Closed result outcome, summary, receipt validation, and approval blockers | Incomplete, malformed, failed, waived, or budget-stopped coverage fails closed; no duplicate toolkit budget metric is needed. | | Findings, severity, fingerprints, suppression, resolution, and repeated discussions | OCR findings plus toolkit-owned posting snapshots and human commands | GitLab discussions, summary, exact fingerprints, and closed lifecycle state | Remediation text and additive group metadata cannot change severity, prove resolution, suppress findings, or issue commands. | | Context admission, degradation, mutation, and evidence use | Toolkit broker/store and fixed MCP receipts | Count-only receipt-v7 context/evidence state | Raw merge-request title, description, discussions, CI provider identities/payloads, rejected text, and record contents do not enter the receipt or telemetry event. | diff --git a/docs/engineering/test_evidence_matrix.md b/docs/engineering/test_evidence_matrix.md index c62ad6b..f7e67ae 100644 --- a/docs/engineering/test_evidence_matrix.md +++ b/docs/engineering/test_evidence_matrix.md @@ -139,7 +139,7 @@ The M5 negative suite is an attacker matrix, not merely a replay of observed rev | Requirement or boundary | Production owner and entry point | Required observable result | Evidence | Double boundary and claim limit | State | | --- | --- | --- | --- | --- | --- | | One effective reviewed source identity | `review_identity.effective_reviewed_sha` shared by review, posting, and pre-execution status validation | a lowercase 40-hex non-zero MR SHA wins; only an absent or all-zero MR SHA may use the exact `CI_COMMIT_SHA`; malformed populated values, stale receipts, and cross-boundary mismatches fail closed | strict scalar/type/value matrix, detached setup/background status cases, provider diff-ref assertions, and stale/malformed receipt cases in `test_gitlab_provider.py`, `test_posting_helpers.py`, and `test_review_runner.py` | environment and local-TLS fixtures prove deterministic parser and production-provider wiring, not GitLab's truthfulness or runner integrity | proven for deterministic identity selection and binding | -| Evidence action receipt cannot be forged or skipped | fixed evidence MCP owner -> owner-only action receipt v2 -> `review_receipt.verified_evidence_actions` -> inode-checked atomic result publication | missing, malformed, incomplete, unwritable, type-confused, or per-tool-mismatched action attribution blocks finalization before findings, advisories, or approval; generated receipt v7 is hostile-validated before publication | real local MCP action-receipt writes plus missing/malformed/incomplete/type/per-tool/producer-validation/advisory-order regressions in `test_evidence_mcp.py`, `test_review_runner.py`, and `test_posting_approval.py` | local protocol and hostile files prove toolkit ownership and reconciliation; they do not prove model judgment or an uncompromised same-user host | proven for exact action-integrity boundary | +| Evidence action receipt cannot be forged or skipped | fixed evidence MCP owner -> owner-only action receipt v3 -> `review_receipt.verified_evidence_actions` -> inode-checked atomic result publication | MCP-received attempts cannot exceed OCR by-tool totals, OCR-preparse residuals become count-only unattributed attempts, and only completed actions establish successful use; missing, malformed, incomplete, unwritable, type-confused, overflowed, unattributed, completion-without-attempt, or per-tool-mismatched action attribution blocks finalization before findings, advisories, or approval; a failed or malformed attempt cannot satisfy the mandatory summary | real local MCP action-receipt writes plus missing/extra-key, old-schema, malformed/failed action, concurrency, bounds, type, per-tool, producer-validation, formatting, and approval regressions in `test_evidence_actions.py`, `test_evidence_mcp.py`, `test_review_runner.py`, `test_posting_helpers.py`, and `test_posting_approval.py` | local protocol and hostile files prove toolkit ownership and aggregate reconciliation; they do not prove model judgment, per-call identity, or an uncompromised same-user host | proven for exact attempt/completion integrity boundary | | Explicit unprotected target remains constrained and comment-only | `providers.gitlab.acquire_review_snapshot` -> `review_runner` constrained preparation -> receipt-v7 validation -> posting/approval workflow | secure default remains protected-only; explicit unprotected mode accepts only actual unprotected state with `off` or bounded `metadata`, immutable repository evidence, and exact-target Rules; enriched context, adapters, protected policy, external MCP, accepted decisions, structured guidance, and approval mutation remain unreachable | real local HTTPS protection-state matrix, real immutable Git Rules reads, privileged-context/external-MCP rejection ordering, omitted target-policy records, hostile receipt fields, all outcome renderings, and unreachable approval-executor regressions across provider, evidence, runner, posting, and approval tests | controlled GitLab and OCR peers prove deterministic toolkit controls, receipt provenance, and call ordering; they do not establish branch governance, live GitLab merge policy, or semantic finding quality | proven for constrained toolkit path; operational protection remains external | | Exact OCR 1.11.2 compatibility contract | compatibility gateway -> hosted Linux binary -> human-reviewed evidence promotion | prior result/manifest/budget/provider contracts remain compatible; `.mjs`/`.cjs` select JavaScript Rules, `.cxx`/`.hxx` select C++ Rules, and `.svh` remains excluded | hosted run 33508349494; checksum-pinned `compatibility/evidence/ocr-1.11.2.json`; independent checksum-verified Darwin arm64 no-LLM qualification | deterministic gateways and rule checks prove consumed structural and selection contracts, not provider-backed review quality | proven for exact qualified OCR contract | diff --git a/docs/engineering/toolkit_strategy.md b/docs/engineering/toolkit_strategy.md index ae33e90..d8c3a61 100644 --- a/docs/engineering/toolkit_strategy.md +++ b/docs/engineering/toolkit_strategy.md @@ -113,7 +113,7 @@ Safe direct composition is therefore limited to reviewed narrow read-only tools, ## Bounded review-context enrichment -M5's foundation is established in v0.7.0. It extends the v0.6.3 selection/approval foundation with protected-target policy, stable GitLab discussions, deterministic references, provider-neutral adapters, a separate private context store, opaque handles, fixed `context_list`/`context_get`, isolated OCR sessions, publication DLP, receipt v4, and closed setup/CI-uncertainty outcomes. Receipt v5 in v0.7.1 superseded the result/posting contract without changing the established acquisition boundary. The v0.8.0 release tree adds policy v2 remediation selection, private context-store v2, authenticated toolkit-root ownership, fixed remediation projections, live-username mention commands, and explicit provider-neutral forge views. Toolkit 0.8.7 advances output to receipt v6 and action receipt v2 for two additional fixed evidence search/coverage tools without reopening provider access or approval authority. Toolkit 0.9.0 advances the current result boundary to receipt v7 and adds a separately constrained path for an explicitly permitted unprotected GitLab target. That path is comment-only, rejects enriched context, adapters, protected policy acquisition, and direct or inherited external MCP before OCR, omits accepted decisions and structured project guidance, and exposes only immutable repository evidence plus bounded target Rules. It is not protected-policy equivalence and cannot authorize approval. The owner waived the separate enriched OCR+LLM qualification: no receipt proves model-time `context_list`/`context_get`, still-present/evidence-resolved scenarios, or receipt-level raw provider-data leakage inspection. The protected release workflow and independent registry/GitHub readback remain mandatory delivery proof but do not substitute for that absent qualification. +M5's foundation is established in v0.7.0. It extends the v0.6.3 selection/approval foundation with protected-target policy, stable GitLab discussions, deterministic references, provider-neutral adapters, a separate private context store, opaque handles, fixed `context_list`/`context_get`, isolated OCR sessions, publication DLP, receipt v4, and closed setup/CI-uncertainty outcomes. Receipt v5 in v0.7.1 superseded the result/posting contract without changing the established acquisition boundary. The v0.8.0 release tree adds policy v2 remediation selection, private context-store v2, authenticated toolkit-root ownership, fixed remediation projections, live-username mention commands, and explicit provider-neutral forge views. Toolkit 0.8.7 advanced output to receipt v6 and action receipt v2 for two additional fixed evidence search/coverage tools without reopening provider access or approval authority. Toolkit 0.9.0 advances the current result boundary to receipt v7, advances the private action receipt to v3 with separately authenticated attempted and completed counters, and adds a separately constrained path for an explicitly permitted unprotected GitLab target. That path is comment-only, rejects enriched context, adapters, protected policy acquisition, and direct or inherited external MCP before OCR, omits accepted decisions and structured project guidance, and exposes only immutable repository evidence plus bounded target Rules. It is not protected-policy equivalence and cannot authorize approval. The owner waived the separate enriched OCR+LLM qualification: no receipt proves model-time `context_list`/`context_get`, still-present/evidence-resolved scenarios, or receipt-level raw provider-data leakage inspection. The protected release workflow and independent registry/GitHub readback remain mandatory delivery proof but do not substitute for that absent qualification. For protected targets, the target architecture acquires forge discussion snapshots and deterministic reference candidates before OCR. An immutable `.opencodereview/review-context-policy.json` read only from the captured protected-target SHA independently controls admission, retrieval, model egress, publication, and retention. Recognizers produce candidates but never authorize them. A provider adapter must authorize the exact tenant, canonical object, fields, and operation, retrieve a bounded version-bound projection, apply normalization and DLP, and atomically commit it to a run-local context store before an opaque unguessable handle is minted. Handles bind run, adapter, tenant, canonical object, projection, version or digest, policy version, expiry, and stored record without exposing the upstream identifier. diff --git a/docs/operations.md b/docs/operations.md index b960ecf..be1f357 100644 --- a/docs/operations.md +++ b/docs/operations.md @@ -38,7 +38,7 @@ This aggregate budget is separate from both OCR's prompt/context `max_tokens` ce `OCR_REVIEW_EFFORT=medium` is the toolkit default for the qualified OCR release and permits two review rounds; `low` permits one and `high` permits three. OCR 1.11.2 multiplies its 15-minute subtask base by those rounds, yielding 15/30/45-minute limits; the GitLab example's 45-minute job timeout accommodates the largest preset. This is a review-depth choice, not one of the three token controls. OCR first groups related changed files and may make group-filter requests; additional rounds can add requests, latency, and cost, but may stop early when they add no finding. Below four changed files, OCR 1.11.2 skips the grouping LLM: low total churn is bundled and high churn stays per-file. The published GitLab example separately passes `OCR_MAX_TOOLS=0`, selecting the embedded template default `100`. The CLI reports `1-49` as normalized to `50`, but both that target and explicit `50` remain effectively `100`; only values above `100` raise the cap. OCR 1.11.0 corrects stale help text for the already-qualified behavior; OCR 1.11.2 leaves that runtime loop contract unchanged. Exhausting the effective limit, an aggregate budget stop, or incomplete manifest coverage remains explicit and approval-ineligible; increasing either effort or tools is never a way to hide partial coverage. -The outcome wording distinguishes skipped, complete, complete-with-warnings, incomplete, token-budget, and failed reviews while preserving the finding state in that same line. A complete clean review is visibly positive; a complete review with findings or only reviewer-suppressed findings is neutral; warning, partial, budget, and failed states never look clean. Findings withheld by the posting limit remain counted even when the limit allows no individual finding note. Recommended focus areas ranks only its copy of already-published findings by the closed severity, category, safe repository location, and stable-identity order before its existing display cap; inline and fallback discussion order, suppression, counts, security focus, and approval policy remain unchanged. OCR 1.8.5 and later manifest failures provide the canonical failed-file receipt; legacy warnings are a bounded fallback, and `summary.files_reviewed` is never treated as proof of successful coverage. Technical details label the aggregate as all OCR tool calls and retain the existing inline format while listing every admitted non-zero count for the closed native/context/evidence review set. An empty admitted list produces no tool-call line. The counts describe review activity, not per-tool token consumption: one read or search can return a different amount of context from another. Dynamic external tool names remain private; toolkit-verified MCP-server calls stay in their separate aggregate, and built-in evidence `summary`/`list`/`get` plus dedicated `search`/`coverage` counts appear only after exact action-receipt v2 and OCR by-tool reconciliation. Zero action counters, queries, scopes, IDs, token counters, and configured-but-unused MCP servers are omitted. Token usage renders only validated input/output/cached/reasoning/total/derived-other buckets; malformed or contradictory counters are unavailable and unknown provider keys are not published. Status and aggregate semantic-category emoji are enabled by default and can be disabled together with `OCR_POST_EMOJI=false`; finding labels remain text unless their separate badge mode is enabled. +The outcome wording distinguishes skipped, complete, complete-with-warnings, incomplete, token-budget, and failed reviews while preserving the finding state in that same line. A complete clean review is visibly positive; a complete review with findings or only reviewer-suppressed findings is neutral; warning, partial, budget, and failed states never look clean. Findings withheld by the posting limit remain counted even when the limit allows no individual finding note. Recommended focus areas ranks only its copy of already-published findings by the closed severity, category, safe repository location, and stable-identity order before its existing display cap; inline and fallback discussion order, suppression, counts, security focus, and approval policy remain unchanged. OCR 1.8.5 and later manifest failures provide the canonical failed-file receipt; legacy warnings are a bounded fallback, and `summary.files_reviewed` is never treated as proof of successful coverage. Technical details label the aggregate as all OCR tool calls and retain the existing inline format while listing every admitted non-zero count for the closed native/context/evidence review set. An empty admitted list produces no tool-call line. The counts describe review activity, not per-tool token consumption: one read or search can return a different amount of context from another. Dynamic external tool names remain private; reconciled MCP-server attempt totals stay in their separate aggregate, and completed built-in evidence `summary`/`list`/`get` plus dedicated `search`/`coverage` counts appear only after action-receipt v3 accounts for every MCP-received or OCR-preparse attempted call. Failed and malformed attempts remain private, cannot appear as completed evidence, and cannot satisfy the mandatory summary. Zero completed action counters, unattributed attempts, queries, scopes, IDs, token counters, and configured-but-unused MCP servers are omitted. A present but incomplete or invalid receipt is rejected before prior comments are inspected or normal findings are published; absence alone selects the compatible direct-posting path. Token usage renders only validated input/output/cached/reasoning/total/derived-other buckets; malformed or contradictory counters are unavailable and unknown provider keys are not published. Status and aggregate semantic-category emoji are enabled by default and can be disabled together with `OCR_POST_EMOJI=false`; finding labels remain text unless their separate badge mode is enabled. When a fully validated receipt v7 records an actually unprotected target, the summary adds exactly *The target branch was not protected in GitLab. This review ran in limited, comment-only mode.* immediately after the primary status line. It appears for clean, findings, warnings, partial, budget-stopped, failed-result, and publication-filtered summaries without replacing or reclassifying the canonical outcome. The limitation is a trust and approval boundary, not a coverage result. A protected target does not show it merely because the operator allowed unprotected targets; legacy, malformed, or contradictory receipt state cannot show it as trusted status. diff --git a/docs/security.md b/docs/security.md index 5f45b7b..e9a3ba9 100644 --- a/docs/security.md +++ b/docs/security.md @@ -59,7 +59,7 @@ These observations establish the current safe-use envelope, not the safety of ar Server command, endpoint, setup, schemas, descriptions, arguments, and responses cross separate executable or untrusted boundaries. GitLab-MR external entries are remote HTTPS only and reject stdio/setup fields; local-profile `setup` remains operator-owned shell configuration executed in the analyzed repository. Existing OCR MCP configuration is hostile persisted input and is revalidated against the active profile. Raw endpoint, setup, and credential values are not safe diagnostic material. Toolkit diagnostics avoid them, but OCR may emit operator-owned transport details; keep those values non-sensitive and retain OCR stderr privately. Managed OAuth authenticates a client but does not authorize an object and remains conditional. -Receipt v7 records the bounded configured capability inventory and positive calls attributed from known tool names. For the fixed evidence MCP only, an owner-only atomic count receipt further attributes completed `summary`, `list`, `get`, `search`, and `coverage` actions after exact reconciliation with OCR's three built-in evidence-tool totals; absence, malformed content, races, failed tool attempts, or mismatch fails normal review finalization before publication. The receipt contains no arguments, IDs, paths, results, or provider content. Its publication state is exactly `passed`, `private-sanitized`, or `publication-filtered`; only a byte-equivalent canonical publication/approval projection permits private sanitization to continue through the existing approval gates. Every direct external MCP makes a protected-target review comment-only, independent of use, and is rejected for an actually unprotected target; server-authored tool annotations are not used to upgrade that policy. The receipt is operational attribution, not security evidence for resource authorization, completeness, response safety, actual semantic use, or model output. Security severity still depends on demonstrated reachability and impact: prompt-like or Markdown text is not code execution without a privileged action path, and same-owner artifact tampering is not an ordinary-contributor escalation without a lower-privilege writer. +Receipt v7 records the bounded configured capability inventory and positive calls attributed from known tool names. For the fixed evidence MCP only, owner-only atomic action receipt v3 records authenticated attempts separately from completed `summary`, `list`, `get`, `search`, and `coverage` actions. MCP-received attempt totals cannot exceed OCR's three authoritative built-in evidence-tool totals; requests rejected during OCR argument parsing never reach MCP and their count-only residual is added to `unattributed`. Only completed actions establish successful evidence use, and malformed MCP-dispatched primary actions are likewise unattributed. Absence, malformed content, races, or mismatch fails normal review finalization before publication, and failed attempts cannot satisfy the mandatory summary or authorize approval. The receipt contains no arguments, IDs, paths, queries, scopes, results, or provider content. Its publication state is exactly `passed`, `private-sanitized`, or `publication-filtered`; only a byte-equivalent canonical publication/approval projection permits private sanitization to continue through the existing approval gates. Every direct external MCP makes a protected-target review comment-only, independent of use, and is rejected for an actually unprotected target; server-authored tool annotations are not used to upgrade that policy. The receipt is operational attribution, not security evidence for resource authorization, completeness, response safety, actual semantic use, or model output. Security severity still depends on demonstrated reachability and impact: prompt-like or Markdown text is not code execution without a privileged action path, and same-owner artifact tampering is not an ordinary-contributor escalation without a lower-privilege writer. ## Preserved current safety properties @@ -72,7 +72,7 @@ Receipt v7 records the bounded configured capability inventory and positive call - Publication DLP admits ordinary horizontal tab only in `existing_code` and `suggestion_code`. The unchanged code value is retained only after it passes the existing size, secret, PII, forbidden-value, and laundering checks; tabs elsewhere and vertical tab, form feed, NUL, bidi/format controls, and other unsupported controls remain blocking. - LLM provider URLs are normalized by one forge-neutral owner shared by configuration and preflight; only credential-free absolute HTTPS roots or protocol-compatible terminal endpoints are accepted. Explicit protocol remains authoritative, and ambiguous auxiliary URL derivation fails closed. - Automatic approval binds the exact synchronized reviewed head, target SHA/protection state, and MR author from receipt v7, skips self-approval, and never removes an existing approval. An unprotected target cannot reach the approval executor. Partial, warning, non-v7, publication-filtered, omitted, degraded metadata, DLP-rejected selected-source, required context degradation, admitted remediation-context, or direct external-MCP runs are ineligible. -- Built-in evidence search operates only on the already DLP-admitted store. Queries are bounded literal NFKC/case-folded tokens; regex, wildcards, operators, controls, bidi/format characters, broad empty search, and query/result echo are rejected. Results expose only existing stable IDs and closed record metadata. Coverage can authorize a negative claim only for one exact complete base/head scope with zero records and no truncation. Private action receipt v2 retains only five counters; receipt v7 requires exact by-tool reconciliation before automatic approval. +- Built-in evidence search operates only on the already DLP-admitted store. Queries are bounded literal NFKC/case-folded tokens; regex, wildcards, operators, controls, bidi/format characters, broad empty search, and query/result echo are rejected. Results expose only existing stable IDs and closed record metadata. Coverage can authorize a negative claim only for one exact complete base/head scope with zero records and no truncation. Private action receipt v3 retains only closed attempted/completed counters; receipt v7 requires complete OCR/MCP attempt accounting and a completed mandatory summary before automatic approval. - Human replies are ownership boundaries. Merge-request source SHA, protected-target policy SHA, and merge-result SHA remain distinct. - The evidence engine stores recursively redacted typed facts/deltas in owner-only files and serves a closed bounded network-independent MCP. Absence supports a negative claim only for applicable complete scope. - OCR rules, decisions, and guidance come only from captured target blobs; source changes cannot create policy. On a protected target they retain their existing bounded contracts. On an unprotected target only Rules are admitted, solely as untrusted guidance; decisions and structured guidance are omitted. None can authorize a privileged capability. diff --git a/src/ocr_toolkit/evidence/actions.py b/src/ocr_toolkit/evidence/actions.py index e429cb7..e3bcae1 100644 --- a/src/ocr_toolkit/evidence/actions.py +++ b/src/ocr_toolkit/evidence/actions.py @@ -13,8 +13,11 @@ from ocr_toolkit.evidence.store.atomic import atomic_write -ACTION_RECEIPT_SCHEMA = "ocr.evidence-action-receipt/v2" +ACTION_RECEIPT_SCHEMA = "ocr.evidence-action-receipt/v3" EVIDENCE_ACTIONS = ("summary", "list", "get", "search", "coverage") +UNATTRIBUTED_ACTION = "unattributed" +ACTION_RECEIPT_BUCKETS = (*EVIDENCE_ACTIONS, UNATTRIBUTED_ACTION) +ACTION_RECEIPT_STATES = ("attempted", "completed") MAX_ACTION_CALLS = 1_000_000_000 MAX_ACTION_RECEIPT_BYTES = 4_096 @@ -45,23 +48,35 @@ def _serialized_receipt_update(path: Path) -> Iterator[None]: os.close(descriptor) -def _validated_counts(value: Any) -> dict[str, int] | None: +def _validated_counts(value: Any) -> dict[str, dict[str, int]] | None: if not isinstance(value, dict) or set(value) != {"schema_version", "actions"}: return None actions = value.get("actions") if value.get("schema_version") != ACTION_RECEIPT_SCHEMA or not isinstance(actions, dict): return None - if set(actions) != set(EVIDENCE_ACTIONS): + if set(actions) != set(ACTION_RECEIPT_STATES): return None + if any(not isinstance(actions[state], dict) for state in ACTION_RECEIPT_STATES): + return None + attempted = actions["attempted"] + completed = actions["completed"] + if set(attempted) != set(ACTION_RECEIPT_BUCKETS) or set(completed) != set(EVIDENCE_ACTIONS): + return None + counts = (*attempted.values(), *completed.values()) if any( not isinstance(count, int) or isinstance(count, bool) or not 0 <= count <= MAX_ACTION_CALLS - for count in actions.values() + for count in counts ): return None - return {action: actions[action] for action in EVIDENCE_ACTIONS} + if any(completed[action] > attempted[action] for action in EVIDENCE_ACTIONS): + return None + return { + "attempted": {action: attempted[action] for action in ACTION_RECEIPT_BUCKETS}, + "completed": {action: completed[action] for action in EVIDENCE_ACTIONS}, + } -def read_action_receipt(path: Path) -> dict[str, int] | None: +def read_action_receipt(path: Path) -> dict[str, dict[str, int]] | None: """Read one bounded receipt or return unavailable for any unsafe shape.""" flags = os.O_RDONLY @@ -94,10 +109,10 @@ def read_action_receipt(path: Path) -> dict[str, int] | None: return _validated_counts(value) -def record_action(path: Path, action: object) -> None: - """Atomically increment one completed action without retaining arguments.""" +def record_action(path: Path, action: object, *, completed: bool = False) -> None: + """Atomically increment one attempted or completed action without retaining arguments.""" - if action not in EVIDENCE_ACTIONS: + if action not in ACTION_RECEIPT_BUCKETS or (completed and action not in EVIDENCE_ACTIONS): raise ValueError("evidence action is outside the closed receipt enum") with _serialized_receipt_update(path): counts = read_action_receipt(path) @@ -110,13 +125,22 @@ def record_action(path: Path, action: object) -> None: if exists and counts is None: raise ValueError("evidence action receipt is malformed") if counts is None: - counts = dict.fromkeys(EVIDENCE_ACTIONS, 0) - if counts[action] >= MAX_ACTION_CALLS: + counts = { + "attempted": dict.fromkeys(ACTION_RECEIPT_BUCKETS, 0), + "completed": dict.fromkeys(EVIDENCE_ACTIONS, 0), + } + state = "completed" if completed else "attempted" + if counts[state][action] >= MAX_ACTION_CALLS: raise ValueError("evidence action receipt exceeds the count bound") - counts[action] += 1 + if completed and counts["completed"][action] >= counts["attempted"][action]: + raise ValueError("completed evidence action has no authenticated attempt") + counts[state][action] += 1 payload = { "schema_version": ACTION_RECEIPT_SCHEMA, - "actions": {name: counts[name] for name in EVIDENCE_ACTIONS}, + "actions": { + "attempted": {name: counts["attempted"][name] for name in ACTION_RECEIPT_BUCKETS}, + "completed": {name: counts["completed"][name] for name in EVIDENCE_ACTIONS}, + }, } atomic_write( path, diff --git a/src/ocr_toolkit/evidence/mcp.py b/src/ocr_toolkit/evidence/mcp.py index 9cd1bfd..fe97c9c 100644 --- a/src/ocr_toolkit/evidence/mcp.py +++ b/src/ocr_toolkit/evidence/mcp.py @@ -22,7 +22,7 @@ tool_definitions, ) from ocr_toolkit.context.store import ContextStore -from ocr_toolkit.evidence.actions import record_action +from ocr_toolkit.evidence.actions import UNATTRIBUTED_ACTION, record_action from ocr_toolkit.evidence.model import CoverageRecord, EvidenceDelta, EvidenceRecord from ocr_toolkit.evidence.policy.schema import is_legacy_policy_value from ocr_toolkit.evidence.store import EvidenceStore, EvidenceStoreError @@ -785,21 +785,35 @@ def handle_request( return _error(request_id, -32602, "Invalid tool call") name = params.get("name") if isinstance(name, str) and name in {TOOL_NAME, SEARCH_TOOL_NAME, COVERAGE_TOOL_NAME}: + arguments = params.get("arguments", {}) + if name == TOOL_NAME: + action = arguments.get("action") if isinstance(arguments, dict) else None + attempt_action = ( + action + if isinstance(action, str) and action in {"summary", "list", "get"} + else UNATTRIBUTED_ACTION + ) + else: + action = "search" if name == SEARCH_TOOL_NAME else "coverage" + attempt_action = action + attempt_recorded = action_receipt_path is None + if action_receipt_path is not None: + try: + record_action(action_receipt_path, attempt_action) + except (OSError, ValueError): + pass + else: + attempt_recorded = True try: - result = call_named_tool(store, str(name), params.get("arguments", {})) + result = call_named_tool(store, str(name), arguments) except EvidenceMCPError as exc: return _success( request_id, {"content": [{"type": "text", "text": str(exc)}], "isError": True}, ) - if action_receipt_path is not None: - arguments = params.get("arguments", {}) - if name == TOOL_NAME: - action = arguments.get("action") if isinstance(arguments, dict) else None - else: - action = "search" if name == SEARCH_TOOL_NAME else "coverage" + if action_receipt_path is not None and attempt_recorded: try: - record_action(action_receipt_path, action) + record_action(action_receipt_path, action, completed=True) except (OSError, ValueError): pass return _success(request_id, result) diff --git a/src/ocr_toolkit/posting/formatting.py b/src/ocr_toolkit/posting/formatting.py index e487e42..2374651 100644 --- a/src/ocr_toolkit/posting/formatting.py +++ b/src/ocr_toolkit/posting/formatting.py @@ -22,7 +22,6 @@ MAX_TOOLKIT_MCP_USAGE_COUNT, MAX_TOOLKIT_MCP_USAGE_SERVERS, PUBLIC_REVIEW_TOOL_CALL_NAMES, - SUPPORTED_TOOLKIT_RESULT_SCHEMA_VERSIONS, TOOLKIT_MCP_SERVER_NAME_RE, OcrToolkitAdvisory, ) @@ -59,6 +58,7 @@ safe_repository_path, ) from ocr_toolkit.result_usage import normalize_token_usage +from ocr_toolkit.review_receipt import toolkit_receipt_is_valid OCR_FINDING_CATEGORIES = { "bug", @@ -493,10 +493,7 @@ def format_tool_calls_summary(tool_calls: Any) -> str: def format_mcp_usage_summary(toolkit_metadata: Any) -> str: """Report MCP servers from the safe receipt produced by `ocr-ci review`.""" - if ( - not isinstance(toolkit_metadata, dict) - or toolkit_metadata.get("schema_version") not in SUPPORTED_TOOLKIT_RESULT_SCHEMA_VERSIONS - ): + if not toolkit_receipt_is_valid(toolkit_metadata): return "" mcp = toolkit_metadata.get("mcp") mcp_usage = mcp.get("usage") if isinstance(mcp, dict) else None @@ -517,32 +514,43 @@ def format_mcp_usage_summary(toolkit_metadata: Any) -> str: if not used: return "" details = ", ".join(f"{inline_code(server)}: {count}" for server, count in used) - lines = [f"- verified MCP calls: {len(used)} server(s) ({details})"] + lines = [f"- reconciled MCP attempts: {len(used)} server(s) ({details})"] evidence = toolkit_metadata.get("evidence") actions = evidence.get("actions") if isinstance(evidence, dict) else None - if isinstance(actions, dict) and set(actions) == {"state", *EVIDENCE_ACTIONS}: - action_counts = [actions[action] for action in EVIDENCE_ACTIONS] + if isinstance(actions, dict) and set(actions) == {"state", "attempted", "completed"}: + attempted = actions.get("attempted") + completed = actions.get("completed") evidence_calls = evidence.get("calls") if isinstance(evidence, dict) else None + mandatory = evidence.get("mandatory") if isinstance(evidence, dict) else None + evidence_used = evidence.get("used") if isinstance(evidence, dict) else None if not ( actions.get("state") == "verified" + and isinstance(attempted, dict) + and set(attempted) == {*EVIDENCE_ACTIONS, "unattributed"} + and isinstance(completed, dict) + and set(completed) == set(EVIDENCE_ACTIONS) and all( isinstance(count, int) and not isinstance(count, bool) and 0 <= count <= MAX_TOOLKIT_MCP_USAGE_COUNT - for count in action_counts + for count in (*attempted.values(), *completed.values()) ) and isinstance(evidence_calls, int) and not isinstance(evidence_calls, bool) and 0 <= evidence_calls <= MAX_TOOLKIT_MCP_USAGE_COUNT - and sum(action_counts) == evidence_calls - and (evidence_calls == 0 or actions["summary"] >= 1) + and isinstance(mandatory, bool) + and isinstance(evidence_used, bool) + and all(completed[action] <= attempted[action] for action in EVIDENCE_ACTIONS) + and sum(attempted.values()) == evidence_calls + and evidence_used is (sum(completed.values()) > 0) + and (not mandatory or completed["summary"] >= 1) ): return "\n".join(lines) - positive = [action for action in EVIDENCE_ACTIONS if actions[action] > 0] + positive = [action for action in EVIDENCE_ACTIONS if completed[action] > 0] if positive: lines.append( - "- built-in evidence actions: " - + ", ".join(f"{action}: {actions[action]}" for action in positive) + "- completed built-in evidence actions: " + + ", ".join(f"{action}: {completed[action]}" for action in positive) ) return "\n".join(lines) diff --git a/src/ocr_toolkit/posting/workflow.py b/src/ocr_toolkit/posting/workflow.py index 8a3e97e..858e858 100644 --- a/src/ocr_toolkit/posting/workflow.py +++ b/src/ocr_toolkit/posting/workflow.py @@ -19,7 +19,6 @@ from ocr_toolkit.ocr_result import ( TOOLKIT_ADVISORY_KEY, TOOLKIT_RESULT_KEY, - TOOLKIT_RESULT_SCHEMA_VERSION, OcrResultMalformed, OcrResultMissing, OcrResultTooLarge, @@ -612,19 +611,16 @@ def post_results(config: GitLabConfig, result: dict[str, Any]) -> int: return 1 toolkit_metadata = result.get(TOOLKIT_RESULT_KEY) + if toolkit_metadata is not None and not toolkit_receipt_is_valid(toolkit_metadata): + return invalid_ocr_schema_exit( + config, + "receipt v7 is invalid", + intro="OCR result publication policy state could not be validated.", + title="**Open Code Review publication policy error**", + ) constrained_target = unprotected_target_limitation(toolkit_metadata) - publication = ( - toolkit_metadata.get("publication") if isinstance(toolkit_metadata, dict) else None - ) - if toolkit_metadata is None: - publication_state = "direct" - elif ( - isinstance(toolkit_metadata, dict) - and toolkit_metadata.get("schema_version") == TOOLKIT_RESULT_SCHEMA_VERSION - ): - publication_state = publication_dlp_state(publication) - else: - publication_state = None + publication = toolkit_metadata.get("publication") if toolkit_metadata is not None else None + publication_state = "direct" if toolkit_metadata is None else publication_dlp_state(publication) if publication_state is None: return invalid_ocr_schema_exit( config, diff --git a/src/ocr_toolkit/review_receipt.py b/src/ocr_toolkit/review_receipt.py index bb7d7fa..46593c5 100644 --- a/src/ocr_toolkit/review_receipt.py +++ b/src/ocr_toolkit/review_receipt.py @@ -5,7 +5,7 @@ from dataclasses import dataclass from typing import Any -from ocr_toolkit.evidence.actions import EVIDENCE_ACTIONS +from ocr_toolkit.evidence.actions import ACTION_RECEIPT_BUCKETS, EVIDENCE_ACTIONS from ocr_toolkit.evidence.mcp import COVERAGE_TOOL_NAME, SEARCH_TOOL_NAME, TOOL_NAME from ocr_toolkit.ocr_result import ( MAX_TOOLKIT_MCP_TOOL_NAME_CHARS, @@ -34,7 +34,7 @@ class ReceiptReviewIdentity: def verified_evidence_actions( evidence_by_tool: dict[str, int], - action_counts: dict[str, int] | None, + action_counts: dict[str, dict[str, int]] | None, *, mandatory: bool, ) -> dict[str, object]: @@ -44,26 +44,52 @@ def verified_evidence_actions( if action_counts is None: if evidence_calls: raise ValueError("evidence action attribution is unavailable") - action_counts = dict.fromkeys(EVIDENCE_ACTIONS, 0) - if set(action_counts) != set(EVIDENCE_ACTIONS) or any( + action_counts = { + "attempted": dict.fromkeys(ACTION_RECEIPT_BUCKETS, 0), + "completed": dict.fromkeys(EVIDENCE_ACTIONS, 0), + } + if set(action_counts) != {"attempted", "completed"}: + raise ValueError("evidence action attribution is unavailable") + attempted = action_counts.get("attempted") + completed = action_counts.get("completed") + if ( + not isinstance(attempted, dict) + or not isinstance(completed, dict) + or set(attempted) != set(ACTION_RECEIPT_BUCKETS) + or set(completed) != set(EVIDENCE_ACTIONS) + ): + raise ValueError("evidence action attribution is unavailable") + all_counts = (*attempted.values(), *completed.values()) + if any( not isinstance(count, int) or isinstance(count, bool) or not 0 <= count <= MAX_TOOLKIT_MCP_USAGE_COUNT - for count in action_counts.values() + for count in all_counts ): raise ValueError("evidence action attribution is unavailable") - if mandatory and action_counts["summary"] < 1: + if any(completed[action] > attempted[action] for action in EVIDENCE_ACTIONS): + raise ValueError("evidence action attribution is unavailable") + if mandatory and completed["summary"] < 1: raise ValueError("OCR review did not call the mandatory evidence summary action") - if ( - sum(action_counts[action] for action in ("summary", "list", "get")) - != evidence_by_tool[TOOL_NAME] - or action_counts["search"] != evidence_by_tool[SEARCH_TOOL_NAME] - or action_counts["coverage"] != evidence_by_tool[COVERAGE_TOOL_NAME] - ): + primary_received = sum(attempted[action] for action in ("summary", "list", "get")) + received_by_tool = { + TOOL_NAME: primary_received + attempted["unattributed"], + SEARCH_TOOL_NAME: attempted["search"], + COVERAGE_TOOL_NAME: attempted["coverage"], + } + if any(received_by_tool[tool] > evidence_by_tool[tool] for tool in received_by_tool): raise ValueError("evidence action attribution does not match OCR tool usage") + # OCR accounts for a dynamic tool attempt before its JSON arguments are parsed. + # Such a request never reaches the MCP owner, so retain only the count-only + # residual as unattributed and never let it establish successful evidence use. + undispatched = sum(evidence_by_tool[tool] - received_by_tool[tool] for tool in received_by_tool) return { "state": "verified", - **{action: action_counts[action] for action in EVIDENCE_ACTIONS}, + "attempted": { + **{action: attempted[action] for action in EVIDENCE_ACTIONS}, + "unattributed": attempted["unattributed"] + undispatched, + }, + "completed": {action: completed[action] for action in EVIDENCE_ACTIONS}, } @@ -364,17 +390,17 @@ def automatic_approval_metadata_reason(toolkit_metadata: Any) -> str: used = evidence.get("used") evidence_calls = evidence.get("calls") evidence_actions = evidence.get("actions") - evidence_called = isinstance(evidence_calls, int) and evidence_calls > 0 + evidence_completed = _completed_evidence_calls(evidence_actions) if ( not isinstance(mandatory, bool) or not isinstance(used, bool) or not isinstance(evidence_calls, int) or isinstance(evidence_calls, bool) or not 0 <= evidence_calls <= MAX_TOOLKIT_MCP_USAGE_COUNT - or used is not evidence_called + or used is not (evidence_completed > 0) or (mandatory and not used) or usage.get(builtin_server, 0) != evidence_calls + sum(context_usage.values()) - or not _valid_evidence_actions(evidence_actions, evidence_calls) + or not _valid_evidence_actions(evidence_actions, evidence_calls, mandatory=mandatory) ): return invalid publication = toolkit_metadata.get("publication") @@ -483,15 +509,43 @@ def publication_outcome_for_summary(outcome: ReviewOutcome, publication: Any) -> ) -def _valid_evidence_actions(value: Any, evidence_calls: Any) -> bool: +def _completed_evidence_calls(value: Any) -> int: + """Return the closed completed-action subtotal or a non-validating sentinel.""" + + if not isinstance(value, dict): + return -1 + completed = value.get("completed") + if not isinstance(completed, dict) or set(completed) != set(EVIDENCE_ACTIONS): + return 0 if value == {"state": "unavailable"} else -1 + values = completed.values() + if any( + not isinstance(count, int) + or isinstance(count, bool) + or not 0 <= count <= MAX_TOOLKIT_MCP_USAGE_COUNT + for count in values + ): + return -1 + return sum(completed.values()) + + +def _valid_evidence_actions(value: Any, evidence_calls: Any, *, mandatory: bool) -> bool: """Validate verified counts or an explicit unavailable attribution state.""" if value == {"state": "unavailable"}: return evidence_calls == 0 - expected = {"state", *EVIDENCE_ACTIONS} + expected = {"state", "attempted", "completed"} if not isinstance(value, dict) or set(value) != expected: return False - counts = [value.get(action) for action in EVIDENCE_ACTIONS] + attempted = value.get("attempted") + completed = value.get("completed") + if ( + not isinstance(attempted, dict) + or set(attempted) != set(ACTION_RECEIPT_BUCKETS) + or not isinstance(completed, dict) + or set(completed) != set(EVIDENCE_ACTIONS) + ): + return False + counts = [*attempted.values(), *completed.values()] return bool( value.get("state") == "verified" and all( @@ -500,6 +554,7 @@ def _valid_evidence_actions(value: Any, evidence_calls: Any) -> bool: and 0 <= count <= MAX_TOOLKIT_MCP_USAGE_COUNT for count in counts ) - and sum(counts) == evidence_calls - and (evidence_calls == 0 or value.get("summary", 0) >= 1) + and all(completed[action] <= attempted[action] for action in EVIDENCE_ACTIONS) + and sum(attempted.values()) == evidence_calls + and (not mandatory or completed["summary"] >= 1) ) diff --git a/src/ocr_toolkit/review_runner.py b/src/ocr_toolkit/review_runner.py index 72c911c..fff861f 100644 --- a/src/ocr_toolkit/review_runner.py +++ b/src/ocr_toolkit/review_runner.py @@ -340,7 +340,7 @@ def _review_receipt( composition: mcp_config.MCPComposition, identity: ReviewIdentity, enrichment: EnrichmentReceipt | None = None, - evidence_action_counts: dict[str, int] | None = None, + evidence_action_counts: dict[str, dict[str, int]] | None = None, ) -> dict[str, object]: """Return a closed privacy-safe receipt tied to review-time facts.""" @@ -352,6 +352,11 @@ def _review_receipt( tool_calls = payload.get("tool_calls") by_tool = tool_calls.get("by_tool") if isinstance(tool_calls, dict) else None total_calls = tool_calls.get("total") if isinstance(tool_calls, dict) else None + if tool_calls is None and outcome.kind == "failed": + by_tool = {} + total_calls = 0 + elif not isinstance(tool_calls, dict) or not isinstance(by_tool, dict): + raise ReviewRunnerError("OCR result has inconsistent aggregate MCP usage") if outcome.kind == "skipped": legacy_message_invalid = ( not outcome.manifest_present and payload.get("message") != "No supported files changed." @@ -389,11 +394,10 @@ def _review_receipt( raise ReviewRunnerError("OCR result exceeds the per-server MCP usage bound") usage[owner] = aggregate known_usage_total = sum(usage.values()) - if tool_calls is None and outcome.kind == "failed": - total_calls = 0 if ( not isinstance(total_calls, int) or isinstance(total_calls, bool) + or not 0 <= total_calls <= MAX_TOOLKIT_MCP_USAGE_COUNT or total_calls < known_usage_total ): raise ReviewRunnerError("OCR result has inconsistent aggregate MCP usage") @@ -402,7 +406,6 @@ def _review_receipt( for name in (TOOL_NAME, SEARCH_TOOL_NAME, COVERAGE_TOOL_NAME) } evidence_calls = sum(evidence_by_tool.values()) - evidence_used = isinstance(evidence_calls, int) and evidence_calls > 0 if outcome.requires_evidence_mcp and not evidence_by_tool[TOOL_NAME]: raise ReviewRunnerError(f"OCR review did not call the mandatory {TOOL_NAME} tool") try: @@ -413,6 +416,12 @@ def _review_receipt( ) except ValueError as exc: raise ReviewRunnerError(str(exc)) from exc + completed_actions = action_attribution.get("completed") + evidence_used = ( + isinstance(completed_actions, dict) + and all(isinstance(count, int) for count in completed_actions.values()) + and sum(completed_actions.values()) > 0 + ) capabilities = [ { "server": capability.server, @@ -1008,7 +1017,7 @@ def _finalize_ocr_result( composition: mcp_config.MCPComposition, identity: ReviewIdentity, enrichment: EnrichmentReceipt | None, - evidence_action_counts: dict[str, int] | None = None, + evidence_action_counts: dict[str, dict[str, int]] | None = None, *, forbidden: tuple[str, ...], toolkit_advisory: OcrToolkitAdvisory | None = None, @@ -1068,7 +1077,7 @@ def _record_ocr_result_mcp_usage( composition: mcp_config.MCPComposition, identity: ReviewIdentity, enrichment: EnrichmentReceipt | None = None, - evidence_action_counts: dict[str, int] | None = None, + evidence_action_counts: dict[str, dict[str, int]] | None = None, ) -> dict[str, int]: """Verify MCP use and bind the closed review-time receipt to the result.""" @@ -2004,7 +2013,7 @@ def run_evidence_review( usage: dict[str, int] = {} publication_filtered = False publication: dict[str, object] = {"state": "passed"} - evidence_action_counts: dict[str, int] | None = None + evidence_action_counts: dict[str, dict[str, int]] | None = None background_qualification = BackgroundQualification() preserve_authorized = False previous_handlers = _install_termination_handlers() diff --git a/tests/support.py b/tests/support.py index 3d6ddc3..9ac2310 100644 --- a/tests/support.py +++ b/tests/support.py @@ -73,3 +73,108 @@ def gitlab_config( current_user_id=current_user_id, current_username=current_username, ) + + +def review_receipt_v7( + *, + usage: dict[str, int] | None = None, + attempted: dict[str, int] | None = None, + completed: dict[str, int] | None = None, + context_tool_usage: dict[str, int] | None = None, + mandatory: bool = True, +) -> dict[str, Any]: + """Return one exact synthetic receipt v7 for posting-boundary tests.""" + + attempted = ( + attempted + if attempted is not None + else { + "summary": 1, + "list": 0, + "get": 0, + "search": 0, + "coverage": 0, + "unattributed": 0, + } + ) + completed = ( + completed + if completed is not None + else { + "summary": 1, + "list": 0, + "get": 0, + "search": 0, + "coverage": 0, + } + ) + context_tool_usage = ( + context_tool_usage + if context_tool_usage is not None + else {"context_get": 0, "context_list": 0} + ) + evidence_calls = sum(attempted.values()) + builtin_calls = evidence_calls + sum(context_tool_usage.values()) + usage = usage if usage is not None else {"ocr_toolkit_evidence": builtin_calls} + enriched = any(context_tool_usage.values()) + builtin_tools = [ + "ocr_toolkit_evidence", + "ocr_toolkit_evidence_search", + "ocr_toolkit_evidence_coverage", + ] + if enriched: + builtin_tools.extend(("context_list", "context_get")) + capabilities = [ + { + "server": "ocr_toolkit_evidence", + "transport": "builtin", + "tools": builtin_tools, + } + ] + capabilities.extend( + { + "server": server, + "transport": "remote", + "tools": [f"{server}_read"], + } + for server in usage + if server != "ocr_toolkit_evidence" + ) + return { + "schema_version": 7, + "review": { + "source_sha": "a" * 40, + "policy_sha": "b" * 40, + "target_sha": "b" * 40, + "target_protection": "protected", + "mr_author_id": 41, + }, + "context": { + "mode": "enriched" if enriched else "off", + "state": "complete" if enriched else "disabled", + "classes": ( + ["merge_request_metadata", "forge_discussions", "external_records"] + if enriched + else [] + ), + "policy_digest": "c" * 64 if enriched else None, + "per_source": {}, + "degradation_counts": {"invalid": 0, "limit": 0, "unavailable": 0}, + "required_degraded": False, + "mutable_admitted": False, + "tool_usage": context_tool_usage, + }, + "mcp": {"capabilities": capabilities, "usage": usage}, + "evidence": { + "mandatory": mandatory, + "used": sum(completed.values()) > 0, + "calls": evidence_calls, + "actions": { + "state": "verified", + "attempted": attempted, + "completed": completed, + }, + }, + "publication": {"state": "passed"}, + "cleanup": {"result": "passed"}, + } diff --git a/tests/test_evidence_actions.py b/tests/test_evidence_actions.py index adf7faa..6240563 100644 --- a/tests/test_evidence_actions.py +++ b/tests/test_evidence_actions.py @@ -10,7 +10,30 @@ import pytest -from ocr_toolkit.evidence.actions import read_action_receipt, record_action +from ocr_toolkit.evidence.actions import MAX_ACTION_CALLS, read_action_receipt, record_action + + +def _valid_receipt_payload() -> dict[str, object]: + return { + "schema_version": "ocr.evidence-action-receipt/v3", + "actions": { + "attempted": { + "summary": 1, + "list": 0, + "get": 0, + "search": 0, + "coverage": 0, + "unattributed": 0, + }, + "completed": { + "summary": 1, + "list": 0, + "get": 0, + "search": 0, + "coverage": 0, + }, + }, + } def _record_repeated_actions(path: str, action: str, count: int) -> None: @@ -18,19 +41,25 @@ def _record_repeated_actions(path: str, action: str, count: int) -> None: for _ in range(count): record_action(Path(path), action) + record_action(Path(path), action, completed=True) def test_action_receipt_is_closed_atomic_private_and_count_only(tmp_path: Path) -> None: path = tmp_path / "actions.json" for action in ("summary", "list", "get", "search", "coverage", "list"): record_action(path, action) + record_action(path, action, completed=True) assert read_action_receipt(path) == { - "summary": 1, - "list": 2, - "get": 1, - "search": 1, - "coverage": 1, + "attempted": { + "summary": 1, + "list": 2, + "get": 1, + "search": 1, + "coverage": 1, + "unattributed": 0, + }, + "completed": {"summary": 1, "list": 2, "get": 1, "search": 1, "coverage": 1}, } assert stat.S_IMODE(path.stat().st_mode) == 0o600 raw = path.read_text(encoding="utf-8") @@ -51,13 +80,23 @@ def test_action_receipt_is_closed_atomic_private_and_count_only(tmp_path: Path) json.dumps({"schema_version": "ocr.evidence-action-receipt/v2", "actions": {}}), json.dumps( { - "schema_version": "ocr.evidence-action-receipt/v2", + "schema_version": "ocr.evidence-action-receipt/v3", "actions": { - "summary": 1, - "list": True, - "get": 0, - "search": 0, - "coverage": 0, + "attempted": { + "summary": 1, + "list": True, + "get": 0, + "search": 0, + "coverage": 0, + "unattributed": 0, + }, + "completed": { + "summary": 1, + "list": 0, + "get": 0, + "search": 0, + "coverage": 0, + }, }, } ), @@ -74,6 +113,57 @@ def test_hostile_action_receipt_is_unavailable_and_never_overwritten( assert path.read_text(encoding="utf-8") == content +@pytest.mark.parametrize( + ("section", "key", "value"), + [ + ("attempted", "unattributed", True), + ("attempted", "search", MAX_ACTION_CALLS + 1), + ("completed", "summary", 2), + ("completed", "coverage", -1), + ], +) +def test_action_receipt_rejects_type_bounds_and_completion_without_attempt( + tmp_path: Path, section: str, key: str, value: object +) -> None: + payload = _valid_receipt_payload() + actions = payload["actions"] + assert isinstance(actions, dict) + counts = actions[section] + assert isinstance(counts, dict) + counts[key] = value + path = tmp_path / "actions.json" + raw = json.dumps(payload) + path.write_text(raw, encoding="utf-8") + + assert read_action_receipt(path) is None + with pytest.raises(ValueError, match="malformed"): + record_action(path, "summary") + assert path.read_text(encoding="utf-8") == raw + + +@pytest.mark.parametrize( + ("section", "key"), + [ + ("attempted", "unattributed"), + ("completed", "coverage"), + ], +) +def test_action_receipt_rejects_missing_or_extra_closed_keys( + tmp_path: Path, section: str, key: str +) -> None: + payload = _valid_receipt_payload() + actions = payload["actions"] + assert isinstance(actions, dict) + counts = actions[section] + assert isinstance(counts, dict) + counts.pop(key) + counts["unexpected"] = 0 + path = tmp_path / "actions.json" + path.write_text(json.dumps(payload), encoding="utf-8") + + assert read_action_receipt(path) is None + + def test_unknown_action_is_rejected_without_creating_receipt(tmp_path: Path) -> None: path = tmp_path / "actions.json" with pytest.raises(ValueError, match="closed"): @@ -95,11 +185,21 @@ def test_action_receipt_serializes_concurrent_completed_calls(tmp_path: Path) -> future.result() assert read_action_receipt(path) == { - "summary": 80, - "list": 80, - "get": 80, - "search": 80, - "coverage": 80, + "attempted": { + "summary": 80, + "list": 80, + "get": 80, + "search": 80, + "coverage": 80, + "unattributed": 0, + }, + "completed": { + "summary": 80, + "list": 80, + "get": 80, + "search": 80, + "coverage": 80, + }, } lock_path = path.with_name(f".{path.name}.lock") assert stat.S_IMODE(lock_path.stat().st_mode) == 0o600 @@ -129,8 +229,24 @@ def test_action_receipt_rejects_unsafe_existing_file(tmp_path: Path, unsafe: str target = tmp_path / "outside" payload = json.dumps( { - "schema_version": "ocr.evidence-action-receipt/v2", - "actions": {"summary": 1, "list": 0, "get": 0, "search": 0, "coverage": 0}, + "schema_version": "ocr.evidence-action-receipt/v3", + "actions": { + "attempted": { + "summary": 1, + "list": 0, + "get": 0, + "search": 0, + "coverage": 0, + "unattributed": 0, + }, + "completed": { + "summary": 1, + "list": 0, + "get": 0, + "search": 0, + "coverage": 0, + }, + }, } ) target.write_text(payload, encoding="utf-8") @@ -150,3 +266,24 @@ def test_action_receipt_rejects_unsafe_existing_file(tmp_path: Path, unsafe: str record_action(path, "summary") assert target.read_text(encoding="utf-8") == payload + + +def test_completed_action_requires_a_prior_authenticated_attempt(tmp_path: Path) -> None: + path = tmp_path / "actions.json" + + with pytest.raises(ValueError, match="authenticated attempt"): + record_action(path, "summary", completed=True) + + assert not path.exists() + + +def test_failed_attempt_is_preserved_without_becoming_completed(tmp_path: Path) -> None: + path = tmp_path / "actions.json" + record_action(path, "unattributed") + record_action(path, "get") + + receipt = read_action_receipt(path) + assert receipt is not None + assert receipt["attempted"]["unattributed"] == 1 + assert receipt["attempted"]["get"] == 1 + assert sum(receipt["completed"].values()) == 0 diff --git a/tests/test_evidence_mcp.py b/tests/test_evidence_mcp.py index 366f501..a1770ea 100644 --- a/tests/test_evidence_mcp.py +++ b/tests/test_evidence_mcp.py @@ -22,7 +22,7 @@ TrustClass, ) from ocr_toolkit.evidence import mcp as evidence_mcp -from ocr_toolkit.evidence.actions import read_action_receipt +from ocr_toolkit.evidence.actions import read_action_receipt, record_action from ocr_toolkit.evidence.mcp import ( COVERAGE_TOOL_NAME, MAX_REQUEST_BYTES, @@ -790,7 +790,7 @@ def test_initialized_notification_and_post_handshake_operations() -> None: assert called["result"]["content"][0]["type"] == "text" -def test_server_records_only_completed_model_time_evidence_actions(tmp_path: Path) -> None: +def test_server_records_attempted_and_completed_model_time_evidence_actions(tmp_path: Path) -> None: receipt = tmp_path / "actions.json" successful = handle_request( _store(), @@ -816,14 +816,81 @@ def test_server_records_only_completed_model_time_evidence_actions(tmp_path: Pat assert successful and successful["result"].get("isError", False) is False assert failed and failed["result"].get("isError", False) is True assert read_action_receipt(receipt) == { - "summary": 1, - "list": 0, - "get": 0, - "search": 0, - "coverage": 0, + "attempted": { + "summary": 1, + "list": 0, + "get": 1, + "search": 0, + "coverage": 0, + "unattributed": 0, + }, + "completed": {"summary": 1, "list": 0, "get": 0, "search": 0, "coverage": 0}, } +def test_server_binds_malformed_primary_action_to_unattributed_attempt(tmp_path: Path) -> None: + receipt = tmp_path / "actions.json" + + for arguments in ( + {}, + {"action": "delete"}, + {"action": []}, + {"action": {}}, + {"action": {"nested": []}}, + {"action": [[]]}, + {"action": True}, + {"action": 1}, + {"action": None}, + "malformed", + ): + result = handle_request( + _store(), + { + "jsonrpc": "2.0", + "id": 1, + "method": "tools/call", + "params": {"name": TOOL_NAME, "arguments": arguments}, + }, + action_receipt_path=receipt, + ) + assert result and result["result"].get("isError", False) is True + + counts = read_action_receipt(receipt) + assert counts is not None + assert counts["attempted"]["unattributed"] == 10 + assert sum(counts["completed"].values()) == 0 + + +@pytest.mark.parametrize( + ("name", "arguments", "action"), + [ + (SEARCH_TOOL_NAME, {"query": ""}, "search"), + (COVERAGE_TOOL_NAME, {}, "coverage"), + ], +) +def test_dedicated_tool_errors_remain_authenticated_attempts( + tmp_path: Path, name: str, arguments: object, action: str +) -> None: + receipt = tmp_path / "actions.json" + + result = handle_request( + _store(), + { + "jsonrpc": "2.0", + "id": 1, + "method": "tools/call", + "params": {"name": name, "arguments": arguments}, + }, + action_receipt_path=receipt, + ) + + assert result and result["result"].get("isError", False) is True + counts = read_action_receipt(receipt) + assert counts is not None + assert counts["attempted"][action] == 1 + assert counts["completed"][action] == 0 + + @pytest.mark.parametrize("failure", [OSError("unwritable"), ValueError("malformed")]) def test_server_action_receipt_failure_cannot_change_tool_result( tmp_path: Path, monkeypatch: pytest.MonkeyPatch, failure: Exception @@ -831,7 +898,7 @@ def test_server_action_receipt_failure_cannot_change_tool_result( """Keep model behavior stable while finalization later fails closed on attribution.""" monkeypatch.setattr( - evidence_mcp, "record_action", lambda *_args: (_ for _ in ()).throw(failure) + evidence_mcp, "record_action", lambda *_args, **_kwargs: (_ for _ in ()).throw(failure) ) result = handle_request( _store(), @@ -845,7 +912,72 @@ def test_server_action_receipt_failure_cannot_change_tool_result( ) assert result and result["result"].get("isError", False) is False - assert not (tmp_path / "actions.json").exists() + + +def test_failed_attempt_write_cannot_be_recovered_as_a_completed_call( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + """A transient attempt-write failure cannot consume an older unmatched attempt.""" + + receipt = tmp_path / "actions.json" + record_action(receipt, "summary") + original = evidence_mcp.record_action + calls = 0 + + def fail_first_record(path: Path, action: object, *, completed: bool = False) -> None: + nonlocal calls + calls += 1 + if calls == 1: + raise OSError("attempt write failed") + original(path, action, completed=completed) + + monkeypatch.setattr(evidence_mcp, "record_action", fail_first_record) + result = handle_request( + _store(), + { + "jsonrpc": "2.0", + "id": 1, + "method": "tools/call", + "params": {"name": TOOL_NAME, "arguments": {"action": "summary"}}, + }, + action_receipt_path=receipt, + ) + + assert result and result["result"].get("isError", False) is False + counts = read_action_receipt(receipt) + assert counts is not None + assert counts["attempted"]["summary"] == 1 + assert counts["completed"]["summary"] == 0 + + +def test_completion_write_failure_retains_attempt_without_changing_tool_result( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + receipt = tmp_path / "actions.json" + original = evidence_mcp.record_action + + def fail_completion(path: Path, action: object, *, completed: bool = False) -> None: + if completed: + raise OSError("completion write failed") + original(path, action, completed=False) + + monkeypatch.setattr(evidence_mcp, "record_action", fail_completion) + result = handle_request( + _store(), + { + "jsonrpc": "2.0", + "id": 1, + "method": "tools/call", + "params": {"name": TOOL_NAME, "arguments": {"action": "summary"}}, + }, + action_receipt_path=receipt, + ) + + assert result and result["result"].get("isError", False) is False + counts = read_action_receipt(receipt) + assert counts is not None + assert counts["attempted"]["summary"] == 1 + assert counts["completed"]["summary"] == 0 def test_notifications_never_receive_json_rpc_responses() -> None: diff --git a/tests/test_gitlab_provider.py b/tests/test_gitlab_provider.py index c88cbd0..0928ce4 100644 --- a/tests/test_gitlab_provider.py +++ b/tests/test_gitlab_provider.py @@ -1020,11 +1020,21 @@ def test_evidence_review_crosses_provider_git_store_mcp_and_subprocess_boundarie "calls": 3, "actions": { "state": "verified", - "summary": 1, - "list": 2, - "get": 0, - "search": 0, - "coverage": 0, + "attempted": { + "summary": 1, + "list": 2, + "get": 0, + "search": 0, + "coverage": 0, + "unattributed": 0, + }, + "completed": { + "summary": 1, + "list": 2, + "get": 0, + "search": 0, + "coverage": 0, + }, }, }, "publication": {"state": "passed"}, diff --git a/tests/test_operations_docs.py b/tests/test_operations_docs.py index ee34e23..200ca26 100644 --- a/tests/test_operations_docs.py +++ b/tests/test_operations_docs.py @@ -134,7 +134,7 @@ def test_review_signal_audit_keeps_group_data_outside_toolkit_authority() -> Non for phrase in ( "Source-to-signal matrix", "receipt v7", - "action-receipt-v2 attribution", + "action-receipt-v3 attempted/completed accounting", "Group labels are model-produced", "sorted changed paths", "no exporter of its own", @@ -394,12 +394,14 @@ def test_builtin_search_coverage_and_receipt_v7_boundaries_are_public() -> None: "at most eight literal tokens", "absence_authoritative=true", "Stop once the required evidence is sufficient", - "action receipt v2", + "action receipt v3", "Receipt v7", ): assert phrase in configuration assert "DLP-admitted store" in security - assert "Zero action counters, queries, scopes, IDs" in operations + assert ( + "Zero completed action counters, unattributed attempts, queries, scopes, IDs" in operations + ) assert "arguments, queries, scopes, IDs, and results stay private" in gitlab diff --git a/tests/test_posting_approval.py b/tests/test_posting_approval.py index c967eb8..efda340 100644 --- a/tests/test_posting_approval.py +++ b/tests/test_posting_approval.py @@ -99,11 +99,21 @@ def receipt_v7( "calls": 1, "actions": { "state": "verified", - "summary": 1, - "list": 0, - "get": 0, - "search": 0, - "coverage": 0, + "attempted": { + "summary": 1, + "list": 0, + "get": 0, + "search": 0, + "coverage": 0, + "unattributed": 0, + }, + "completed": { + "summary": 1, + "list": 0, + "get": 0, + "search": 0, + "coverage": 0, + }, }, }, "publication": {"state": "passed"}, @@ -425,20 +435,32 @@ def test_private_only_sanitization_keeps_existing_approval_gates(self) -> None: def test_evidence_action_attribution_does_not_change_approval_eligibility(self) -> None: receipt = receipt_v7() - receipt["evidence"]["actions"] = { - "state": "verified", - "summary": 1, - "list": 0, - "get": 0, - "search": 0, - "coverage": 0, - } decision = approval.evaluate_approval_policy( settings.BooleanSetting(True), complete_outcome(), [], [], 0, receipt ) self.assertTrue(decision.eligible) - receipt["evidence"]["actions"]["summary"] = 0 + receipt["evidence"]["actions"]["completed"]["summary"] = 0 + decision = approval.evaluate_approval_policy( + settings.BooleanSetting(True), complete_outcome(), [], [], 0, receipt + ) + self.assertFalse(decision.eligible) + self.assertIn("receipt is missing or invalid", decision.result.reason) + + def test_failed_attempts_do_not_authorize_successful_evidence_or_approval(self) -> None: + receipt = receipt_v7() + actions = receipt["evidence"]["actions"] + actions["attempted"]["get"] = 1 + receipt["evidence"]["calls"] = 2 + receipt["mcp"]["usage"]["ocr_toolkit_evidence"] = 2 + + decision = approval.evaluate_approval_policy( + settings.BooleanSetting(True), complete_outcome(), [], [], 0, receipt + ) + self.assertTrue(decision.eligible) + + actions["completed"]["summary"] = 0 + receipt["evidence"]["used"] = False decision = approval.evaluate_approval_policy( settings.BooleanSetting(True), complete_outcome(), [], [], 0, receipt ) diff --git a/tests/test_posting_helpers.py b/tests/test_posting_helpers.py index 42410b1..672d27b 100644 --- a/tests/test_posting_helpers.py +++ b/tests/test_posting_helpers.py @@ -49,6 +49,7 @@ gitlab_config, patched_attr, patched_env, + review_receipt_v7, ) @@ -1340,7 +1341,10 @@ def capture_note(_config: Any, *args: Any) -> dict[str, int]: "status": "completed_with_errors", "comments": [old, new], "warnings": [], - "_ocr_toolkit": {"schema_version": 7, "publication": publication}, + "_ocr_toolkit": { + **review_receipt_v7(), + "publication": publication, + }, }, ) @@ -1352,6 +1356,40 @@ def capture_note(_config: Any, *args: Any) -> dict[str, int]: self.assertIn("Publication filtering signal", rendered) self.assertIn('"carried_forward_comments":1', rendered) + def test_present_invalid_receipt_never_reaches_normal_publication(self) -> None: + notes: list[str] = [] + collect_calls: list[str] = [] + receipt = review_receipt_v7() + receipt.pop("review") + + with ( + patched_attr( + workflow, + "collect_previous_bot_comment_refs", + lambda _config: collect_calls.append("collect") or snapshot.BotCommentRefs(), + ), + patched_attr( + workflow, + "post_review_note_bounded", + lambda _config, title, *_args: notes.append(title) or {"id": 1}, + ), + patched_attr(workflow, "finalize_posting", lambda *_args: True), + redirect_stderr(io.StringIO()), + ): + exit_code = workflow.post_results( + gitlab_config(), + { + "status": "complete", + "comments": [{"path": "safe.py", "line": 1, "content": "finding"}], + "warnings": [], + "_ocr_toolkit": receipt, + }, + ) + + self.assertEqual(exit_code, 0) + self.assertEqual(collect_calls, []) + self.assertEqual(notes, ["**Open Code Review publication policy error**"]) + def test_manifest_failed_posts_failure_without_collecting_or_replacing_previous(self) -> None: notes: list[str] = [] collect_calls: list[str] = [] @@ -3288,101 +3326,144 @@ def test_budget_summary_and_guide_mark_findings_as_partial(self) -> None: def test_mcp_usage_summary_reports_only_servers_actually_called(self) -> None: summary = posting_formatting.format_mcp_usage_summary( - { - "schema_version": 7, - "mcp": { - "usage": { - "ocr_toolkit_evidence": 2, - "documentation": 1, - } + review_receipt_v7( + usage={"ocr_toolkit_evidence": 2, "documentation": 1}, + attempted={ + "summary": 1, + "list": 1, + "get": 0, + "search": 0, + "coverage": 0, + "unattributed": 0, }, - "evidence": {"actions": {"state": "unavailable"}}, - } + completed={ + "summary": 0, + "list": 0, + "get": 0, + "search": 0, + "coverage": 0, + }, + mandatory=False, + ) ) self.assertEqual( summary, - "- verified MCP calls: 2 server(s) (`documentation`: 1, `ocr_toolkit_evidence`: 2)", + "- reconciled MCP attempts: 2 server(s) (`documentation`: 1, `ocr_toolkit_evidence`: 2)", ) self.assertNotIn("file_read", summary) def test_mcp_usage_summary_reads_receipt_v7_inventory(self) -> None: summary = posting_formatting.format_mcp_usage_summary( - { - "schema_version": 7, - "mcp": { - "capabilities": [], - "usage": {"ocr_toolkit_evidence": 3}, + review_receipt_v7( + attempted={ + "summary": 1, + "list": 2, + "get": 0, + "search": 0, + "coverage": 0, + "unattributed": 0, }, - "evidence": {"actions": {"state": "unavailable"}}, - } + completed={ + "summary": 0, + "list": 0, + "get": 0, + "search": 0, + "coverage": 0, + }, + mandatory=False, + ) ) self.assertEqual( summary, - "- verified MCP calls: 1 server(s) (`ocr_toolkit_evidence`: 3)", + "- reconciled MCP attempts: 1 server(s) (`ocr_toolkit_evidence`: 3)", ) def test_mcp_usage_summary_renders_verified_action_breakdown(self) -> None: summary = posting_formatting.format_mcp_usage_summary( - { - "schema_version": 7, - "mcp": {"usage": {"ocr_toolkit_evidence": 4}}, - "evidence": { - "calls": 4, - "actions": { - "state": "verified", - "summary": 1, - "list": 2, - "get": 1, - "search": 0, - "coverage": 0, - }, + review_receipt_v7( + attempted={ + "summary": 1, + "list": 2, + "get": 1, + "search": 0, + "coverage": 0, + "unattributed": 0, }, - } + completed={ + "summary": 1, + "list": 2, + "get": 1, + "search": 0, + "coverage": 0, + }, + ) ) self.assertEqual( summary, - "- verified MCP calls: 1 server(s) (`ocr_toolkit_evidence`: 4)\n" - "- built-in evidence actions: summary: 1, list: 2, get: 1", + "- reconciled MCP attempts: 1 server(s) (`ocr_toolkit_evidence`: 4)\n" + "- completed built-in evidence actions: summary: 1, list: 2, get: 1", ) def test_mcp_usage_summary_omits_zero_or_unavailable_action_breakdown(self) -> None: - """Publish only reconciled non-zero action counts.""" - - for actions in ( - {"state": "unavailable"}, - { - "state": "verified", - "summary": 0, - "list": 0, - "get": 0, - "search": 0, - "coverage": 0, - }, - { - "state": "verified", - "summary": 0, - "list": 2, - "get": 1, - "search": 0, - "coverage": 0, - }, - ): - with self.subTest(actions=actions): - calls = sum(value for key, value in actions.items() if key != "state") + """Publish only reconciled non-zero completed action counts.""" + + zero_completed = { + "summary": 0, + "list": 0, + "get": 0, + "search": 0, + "coverage": 0, + } + cases = ( + ( + { + "summary": 0, + "list": 2, + "get": 1, + "search": 0, + "coverage": 0, + "unattributed": 0, + }, + zero_completed, + ), + ) + for attempted, completed in cases: + with self.subTest(attempted=attempted): self.assertEqual( posting_formatting.format_mcp_usage_summary( - { - "schema_version": 7, - "mcp": {"usage": {"ocr_toolkit_evidence": 3}}, - "evidence": {"calls": calls, "actions": actions}, - } + review_receipt_v7( + attempted=attempted, + completed=completed, + mandatory=False, + ) ), - "- verified MCP calls: 1 server(s) (`ocr_toolkit_evidence`: 3)", + "- reconciled MCP attempts: 1 server(s) (`ocr_toolkit_evidence`: 3)", ) + def test_mcp_usage_summary_never_renders_unattributed_attempts(self) -> None: + summary = posting_formatting.format_mcp_usage_summary( + review_receipt_v7( + attempted={ + "summary": 1, + "list": 0, + "get": 0, + "search": 0, + "coverage": 0, + "unattributed": 4, + }, + ) + ) + + self.assertEqual( + summary, + "- reconciled MCP attempts: 1 server(s) (`ocr_toolkit_evidence`: 5)\n" + "- completed built-in evidence actions: summary: 1", + ) + self.assertNotIn("unattributed", summary) + def test_mcp_usage_summary_omits_hostile_or_unreconciled_action_breakdown(self) -> None: for actions in ( {"state": "verified", "summary": "1\n/approve", "list": 2, "get": 1}, @@ -3391,44 +3472,39 @@ def test_mcp_usage_summary_omits_hostile_or_unreconciled_action_breakdown(self) {"state": "verified", "summary": 1, "list": 2, "get": 1, "extra": 0}, ): with self.subTest(actions=actions): - summary = posting_formatting.format_mcp_usage_summary( - { - "schema_version": 7, - "mcp": {"usage": {"ocr_toolkit_evidence": 4}}, - "evidence": {"calls": 4, "actions": actions}, - } - ) + receipt = review_receipt_v7() + receipt["evidence"]["actions"] = actions + summary = posting_formatting.format_mcp_usage_summary(receipt) - self.assertEqual( - summary, - "- verified MCP calls: 1 server(s) (`ocr_toolkit_evidence`: 4)", - ) + self.assertEqual(summary, "") self.assertNotIn("/approve", summary) def test_mcp_usage_summary_reconciles_actions_after_context_calls(self) -> None: summary = posting_formatting.format_mcp_usage_summary( - { - "schema_version": 7, - "context": {"tool_usage": {"context_get": 1, "context_list": 2}}, - "mcp": {"usage": {"ocr_toolkit_evidence": 7}}, - "evidence": { - "calls": 4, - "actions": { - "state": "verified", - "summary": 1, - "list": 2, - "get": 1, - "search": 0, - "coverage": 0, - }, + review_receipt_v7( + attempted={ + "summary": 1, + "list": 2, + "get": 1, + "search": 0, + "coverage": 0, + "unattributed": 0, }, - } + completed={ + "summary": 1, + "list": 2, + "get": 1, + "search": 0, + "coverage": 0, + }, + context_tool_usage={"context_get": 1, "context_list": 2}, + ) ) self.assertEqual( summary, - "- verified MCP calls: 1 server(s) (`ocr_toolkit_evidence`: 7)\n" - "- built-in evidence actions: summary: 1, list: 2, get: 1", + "- reconciled MCP attempts: 1 server(s) (`ocr_toolkit_evidence`: 7)\n" + "- completed built-in evidence actions: summary: 1, list: 2, get: 1", ) def test_mcp_usage_summary_rejects_malformed_usage_before_rendering(self) -> None: @@ -3439,17 +3515,25 @@ def test_mcp_usage_summary_rejects_malformed_usage_before_rendering(self) -> Non {"ocr_toolkit_evidence": 0}, ): with self.subTest(usage=usage): + receipt = review_receipt_v7() + receipt["mcp"]["usage"] = usage self.assertEqual( - posting_formatting.format_mcp_usage_summary( - { - "schema_version": 7, - "mcp": {"usage": usage}, - "evidence": {"actions": {"state": "unavailable"}}, - } - ), + posting_formatting.format_mcp_usage_summary(receipt), "", ) + def test_mcp_usage_summary_requires_complete_valid_receipt(self) -> None: + receipt = review_receipt_v7() + for mutate in ( + lambda value: value.pop("review"), + lambda value: value["review"].update({"source_sha": "invalid"}), + lambda value: value["cleanup"].update({"result": "failed"}), + ): + candidate = json.loads(json.dumps(receipt)) + mutate(candidate) + with self.subTest(candidate=candidate): + self.assertEqual(posting_formatting.format_mcp_usage_summary(candidate), "") + def test_mcp_usage_summary_rejects_pre_v6_receipt(self) -> None: self.assertEqual( posting_formatting.format_mcp_usage_summary( diff --git a/tests/test_review_runner.py b/tests/test_review_runner.py index fce6d83..1b88c74 100644 --- a/tests/test_review_runner.py +++ b/tests/test_review_runner.py @@ -11,6 +11,7 @@ import sys from contextlib import redirect_stderr from dataclasses import replace +from itertools import product from pathlib import Path from tempfile import TemporaryDirectory from types import SimpleNamespace @@ -47,13 +48,37 @@ "ocr_toolkit_evidence_search", "ocr_toolkit_evidence_coverage", ) -SUMMARY_ACTION_COUNTS = { - "summary": 1, - "list": 0, - "get": 0, - "search": 0, - "coverage": 0, -} + + +def action_counts( + *, + summary: int = 1, + list: int = 0, + get: int = 0, + search: int = 0, + coverage: int = 0, + unattributed: int = 0, + completed: dict[str, int] | None = None, +) -> dict[str, dict[str, int]]: + """Return one exact synthetic action-receipt-v3 projection.""" + + attempted = { + "summary": summary, + "list": list, + "get": get, + "search": search, + "coverage": coverage, + "unattributed": unattributed, + } + return { + "attempted": attempted, + "completed": completed + if completed is not None + else {name: attempted[name] for name in ("summary", "list", "get", "search", "coverage")}, + } + + +SUMMARY_ACTION_COUNTS = action_counts() def enriched_identity() -> review_runner.ReviewIdentity: @@ -548,7 +573,7 @@ def test_safe_mr_and_enrichment_data_preserve_auto_approval_but_remediation_does composition, identity, safe_enrichment, - {"summary": 1, "list": 0, "get": 0, "search": 0, "coverage": 0}, + SUMMARY_ACTION_COUNTS, ) metadata["schema_version"] = ocr_result.TOOLKIT_RESULT_SCHEMA_VERSION eligible = approval.evaluate_approval_policy( @@ -581,7 +606,7 @@ def test_safe_mr_and_enrichment_data_preserve_auto_approval_but_remediation_does composition, identity, changed, - {"summary": 1, "list": 0, "get": 0, "search": 0, "coverage": 0}, + SUMMARY_ACTION_COUNTS, ) blocked["schema_version"] = ocr_result.TOOLKIT_RESULT_SCHEMA_VERSION decision = approval.evaluate_approval_policy( @@ -703,7 +728,7 @@ def test_ocr_result_requires_builtin_mcp_usage_for_completed_review(tmp_path: Pa external_servers=(), secret_values=(), ) - counts = {"summary": 1, "list": 1, "get": 0, "search": 0, "coverage": 0} + counts = action_counts(list=1) assert review_runner._record_ocr_result_mcp_usage( result, composition, DEFAULT_IDENTITY, evidence_action_counts=counts ) == {"ocr_toolkit_evidence": 2} @@ -893,7 +918,7 @@ def test_evidence_action_receipt_reconciles_each_fixed_tool_independently( external_servers=(), secret_values=(), ) - counts = {"summary": 1, "list": 1, "get": 1, "search": 1, "coverage": 1} + counts = action_counts(list=1, get=1, search=1, coverage=1) usage = review_runner._record_ocr_result_mcp_usage( result, @@ -912,6 +937,231 @@ def test_evidence_action_receipt_reconciles_each_fixed_tool_independently( } +def test_failed_attempts_reconcile_without_impersonating_completed_evidence( + tmp_path: Path, +) -> None: + """Accept OCR attempt telemetry while retaining successful-use authority separately.""" + + result = tmp_path / "result.json" + result.write_text( + json.dumps( + { + "status": "success", + "tool_calls": { + "total": 5, + "by_tool": { + "ocr_toolkit_evidence": 3, + "ocr_toolkit_evidence_search": 1, + "ocr_toolkit_evidence_coverage": 1, + }, + }, + } + ), + encoding="utf-8", + ) + composition = MCPComposition( + payload={}, + capabilities=(MCPCapability("ocr_toolkit_evidence", BUILTIN_EVIDENCE_TOOLS, True),), + external_servers=(), + secret_values=(), + ) + counts = action_counts( + get=1, + search=1, + coverage=1, + unattributed=1, + completed={"summary": 1, "list": 0, "get": 0, "search": 0, "coverage": 0}, + ) + + review_runner._record_ocr_result_mcp_usage( + result, + composition, + DEFAULT_IDENTITY, + evidence_action_counts=counts, + ) + evidence = json.loads(result.read_text(encoding="utf-8"))["_ocr_toolkit"]["evidence"] + + assert evidence == { + "mandatory": True, + "used": True, + "calls": 5, + "actions": {"state": "verified", **counts}, + } + + +def test_ocr_preparse_attempts_become_unattributed_without_success_authority( + tmp_path: Path, +) -> None: + """Model OCR's counter-before-argument-parse boundary exactly.""" + + result = tmp_path / "result.json" + result.write_text( + json.dumps( + { + "status": "success", + "tool_calls": { + "total": 5, + "by_tool": { + "ocr_toolkit_evidence": 3, + "ocr_toolkit_evidence_search": 1, + "ocr_toolkit_evidence_coverage": 1, + }, + }, + } + ), + encoding="utf-8", + ) + composition = MCPComposition( + payload={}, + capabilities=(MCPCapability("ocr_toolkit_evidence", BUILTIN_EVIDENCE_TOOLS, True),), + external_servers=(), + secret_values=(), + ) + received = action_counts( + completed={"summary": 1, "list": 0, "get": 0, "search": 0, "coverage": 0} + ) + + review_runner._record_ocr_result_mcp_usage( + result, composition, DEFAULT_IDENTITY, evidence_action_counts=received + ) + evidence = json.loads(result.read_text(encoding="utf-8"))["_ocr_toolkit"]["evidence"] + + assert evidence["calls"] == 5 + assert evidence["used"] is True + assert evidence["actions"]["attempted"] == { + "summary": 1, + "list": 0, + "get": 0, + "search": 0, + "coverage": 0, + "unattributed": 4, + } + assert evidence["actions"]["completed"] == received["completed"] + + +def test_mcp_received_attempts_cannot_exceed_ocr_preparse_totals(tmp_path: Path) -> None: + result = tmp_path / "result.json" + result.write_text( + json.dumps( + { + "status": "success", + "tool_calls": {"total": 1, "by_tool": {"ocr_toolkit_evidence": 1}}, + } + ), + encoding="utf-8", + ) + composition = MCPComposition( + payload={}, + capabilities=(MCPCapability("ocr_toolkit_evidence", BUILTIN_EVIDENCE_TOOLS, True),), + external_servers=(), + secret_values=(), + ) + + with pytest.raises(review_runner.ReviewRunnerError, match="does not match OCR tool usage"): + review_runner._record_ocr_result_mcp_usage( + result, + composition, + DEFAULT_IDENTITY, + evidence_action_counts=action_counts(list=1), + ) + + +def test_evidence_action_reconciliation_exhaustively_covers_small_state_space() -> None: + """Prove every bounded attempted/completed transition and per-tool residual.""" + + named_actions = ("summary", "list", "get", "search", "coverage") + attempted_actions = (*named_actions, "unattributed") + accepted = 0 + rejected = 0 + + for attempted_values in product(range(2), repeat=len(attempted_actions)): + attempted = dict(zip(attempted_actions, attempted_values, strict=True)) + ocr_by_tool = { + "ocr_toolkit_evidence": sum( + attempted[action] for action in ("summary", "list", "get", "unattributed") + ), + "ocr_toolkit_evidence_search": attempted["search"], + "ocr_toolkit_evidence_coverage": attempted["coverage"], + } + for completed_values in product(range(2), repeat=len(named_actions)): + completed = dict(zip(named_actions, completed_values, strict=True)) + counts = {"attempted": attempted, "completed": completed} + valid = all(completed[action] <= attempted[action] for action in named_actions) + + if not valid: + with pytest.raises(ValueError, match="attribution is unavailable"): + review_runner.verified_evidence_actions(ocr_by_tool, counts, mandatory=False) + rejected += 1 + continue + + projected = review_runner.verified_evidence_actions( + ocr_by_tool, counts, mandatory=False + ) + assert projected == {"state": "verified", **counts} + accepted += 1 + + if completed["summary"] == 0: + with pytest.raises(ValueError, match="mandatory evidence summary"): + review_runner.verified_evidence_actions(ocr_by_tool, counts, mandatory=True) + + assert accepted == 486 + assert rejected == 1_562 + + baseline = action_counts(list=1, get=1, search=1, coverage=1) + baseline_by_tool = { + "ocr_toolkit_evidence": 3, + "ocr_toolkit_evidence_search": 1, + "ocr_toolkit_evidence_coverage": 1, + } + for tool in baseline_by_tool: + with_residual = {**baseline_by_tool, tool: baseline_by_tool[tool] + 1} + projected = review_runner.verified_evidence_actions(with_residual, baseline, mandatory=True) + assert projected["attempted"] == { + **baseline["attempted"], + "unattributed": 1, + } + + for tool in baseline_by_tool: + too_small = {**baseline_by_tool, tool: baseline_by_tool[tool] - 1} + with pytest.raises(ValueError, match="does not match OCR tool usage"): + review_runner.verified_evidence_actions(too_small, baseline, mandatory=True) + + +def test_failed_summary_attempt_cannot_satisfy_mandatory_evidence(tmp_path: Path) -> None: + result = tmp_path / "result.json" + result.write_text( + json.dumps( + { + "status": "success", + "tool_calls": {"total": 1, "by_tool": {"ocr_toolkit_evidence": 1}}, + } + ), + encoding="utf-8", + ) + composition = MCPComposition( + payload={}, + capabilities=(MCPCapability("ocr_toolkit_evidence", BUILTIN_EVIDENCE_TOOLS, True),), + external_servers=(), + secret_values=(), + ) + + with pytest.raises(review_runner.ReviewRunnerError, match="mandatory evidence summary"): + review_runner._record_ocr_result_mcp_usage( + result, + composition, + DEFAULT_IDENTITY, + evidence_action_counts=action_counts( + completed={ + "summary": 0, + "list": 0, + "get": 0, + "search": 0, + "coverage": 0, + } + ), + ) + + def test_mandatory_evidence_rejects_available_receipt_without_summary( tmp_path: Path, ) -> None: @@ -942,13 +1192,7 @@ def test_mandatory_evidence_rejects_available_receipt_without_summary( result, composition, DEFAULT_IDENTITY, - evidence_action_counts={ - "summary": 0, - "list": 1, - "get": 1, - "search": 0, - "coverage": 0, - }, + evidence_action_counts=action_counts(summary=0, list=1, get=1), ) @@ -978,13 +1222,7 @@ def test_ocr_result_receipt_blocks_approval_when_mr_context_was_admitted( review_runner.ReviewIdentity( "a" * 40, "b" * 40, 41, "metadata", None, "b" * 40, "protected" ), - evidence_action_counts={ - "summary": 1, - "list": 0, - "get": 0, - "search": 0, - "coverage": 0, - }, + evidence_action_counts=SUMMARY_ACTION_COUNTS, ) assert json.loads(result.read_text(encoding="utf-8"))["_ocr_toolkit"] == { @@ -1021,14 +1259,7 @@ def test_ocr_result_receipt_blocks_approval_when_mr_context_was_admitted( "mandatory": True, "used": True, "calls": 1, - "actions": { - "state": "verified", - "summary": 1, - "list": 0, - "get": 0, - "search": 0, - "coverage": 0, - }, + "actions": {"state": "verified", **SUMMARY_ACTION_COUNTS}, }, "publication": {"state": "passed"}, "cleanup": {"result": "passed"}, @@ -2343,16 +2574,65 @@ def test_ocr_result_allows_manifest_failure_without_tool_calls(tmp_path: Path) - "calls": 0, "actions": { "state": "verified", - "summary": 0, - "list": 0, - "get": 0, - "search": 0, - "coverage": 0, + **action_counts(summary=0), }, } assert persisted["_ocr_toolkit"]["mcp"]["usage"] == {} +def test_failed_result_preserves_attempts_without_claiming_successful_evidence( + tmp_path: Path, +) -> None: + """A non-mandatory failed result may authenticate failed attempts without use.""" + + result = tmp_path / "result.json" + result.write_text( + json.dumps( + { + "status": "failed", + "comments": [], + "tool_calls": {"total": 1, "by_tool": {"ocr_toolkit_evidence": 1}}, + "manifest": { + "schema_version": "ocr.run-manifest/v1", + "operation": "review", + "terminal_state": "failed", + "coverage": { + "selected": [], + "completed": [], + "reused": [], + "failed": [], + "waived": [], + }, + "run_failure": {"classification": "configuration"}, + }, + } + ), + encoding="utf-8", + ) + composition = MCPComposition( + payload={}, + capabilities=(MCPCapability("ocr_toolkit_evidence", BUILTIN_EVIDENCE_TOOLS, True),), + external_servers=(), + secret_values=(), + ) + failed_attempt = action_counts( + summary=0, + unattributed=1, + completed={"summary": 0, "list": 0, "get": 0, "search": 0, "coverage": 0}, + ) + + assert review_runner._record_ocr_result_mcp_usage( + result, composition, DEFAULT_IDENTITY, evidence_action_counts=failed_attempt + ) == {"ocr_toolkit_evidence": 1} + evidence = json.loads(result.read_text(encoding="utf-8"))["_ocr_toolkit"]["evidence"] + assert evidence == { + "mandatory": False, + "used": False, + "calls": 1, + "actions": {"state": "verified", **failed_attempt}, + } + + def test_ocr_result_allows_skipped_review_without_tool_calls(tmp_path: Path) -> None: """Do not invent an MCP-use requirement when OCR found no supported files.""" @@ -2549,7 +2829,7 @@ def test_ocr_result_receipt_attributes_independent_mcp_servers(tmp_path: Path) - result, composition, DEFAULT_IDENTITY, - evidence_action_counts={"summary": 1, "list": 1, "get": 0, "search": 0, "coverage": 0}, + evidence_action_counts=action_counts(list=1), ) == { "documentation": 5, "ocr_toolkit_evidence": 2, @@ -2592,6 +2872,58 @@ def test_ocr_result_rejects_unbounded_known_mcp_usage( review_runner._record_ocr_result_mcp_usage(result, composition, DEFAULT_IDENTITY) +@pytest.mark.parametrize( + "tool_calls", + [ + [], + {}, + {"total": 0}, + {"total": 0, "by_tool": []}, + {"total": True, "by_tool": {}}, + {"total": -1, "by_tool": {}}, + {"total": 1_000_000_001, "by_tool": {}}, + ], +) +def test_failed_result_rejects_explicit_malformed_aggregate_tool_usage( + tmp_path: Path, tool_calls: object +) -> None: + """Only an absent failed-result telemetry envelope means no tool calls.""" + + result = tmp_path / "result.json" + result.write_text( + json.dumps( + { + "status": "failed", + "comments": [], + "tool_calls": tool_calls, + "manifest": { + "schema_version": "ocr.run-manifest/v1", + "operation": "review", + "terminal_state": "failed", + "coverage": { + "selected": [], + "completed": [], + "reused": [], + "failed": [], + "waived": [], + }, + "run_failure": {"classification": "configuration"}, + }, + } + ), + encoding="utf-8", + ) + composition = MCPComposition( + payload={}, + capabilities=(MCPCapability("ocr_toolkit_evidence", BUILTIN_EVIDENCE_TOOLS, True),), + external_servers=(), + secret_values=(), + ) + + with pytest.raises(review_runner.ReviewRunnerError, match="aggregate MCP usage"): + review_runner._record_ocr_result_mcp_usage(result, composition, DEFAULT_IDENTITY) + + def test_budget_limited_result_preserves_verified_mcp_usage(tmp_path: Path) -> None: """Treat a budget stop as a partial completed review, not unsupported output.""" @@ -2621,7 +2953,7 @@ def test_budget_limited_result_preserves_verified_mcp_usage(tmp_path: Path) -> N result, composition, DEFAULT_IDENTITY, - evidence_action_counts={"summary": 1, "list": 1, "get": 0, "search": 0, "coverage": 0}, + evidence_action_counts=action_counts(list=1), ) == {"ocr_toolkit_evidence": 2} persisted = json.loads(result.read_text(encoding="utf-8")) assert persisted["summary"] == {"budget_exceeded": True, "total_tokens": 321} From e6a89eeb2f1a7ad95891e495c8b4288b998cb241 Mon Sep 17 00:00:00 2001 From: xeonvs <11463419+xeonvs@users.noreply.github.com> Date: Thu, 3 Sep 2026 08:53:12 +0200 Subject: [PATCH 12/15] fix: close retained OCR findings --- PLANS.md | 16 ++- changelog.d/169.bugfix.md | 2 +- compatibility/evidence/ocr-1.11.1.json | 32 +++--- compatibility/ocr-support.json | 2 +- docs/configuration.md | 4 +- docs/operations.md | 2 + docs/security.md | 1 + scripts/ocr_compat.py | 37 +++---- src/ocr_toolkit/review_runner.py | 12 ++- tests/test_ocr_compat.py | 14 +++ tests/test_review_runner.py | 136 +++++++++++++++++++++++-- 11 files changed, 202 insertions(+), 56 deletions(-) diff --git a/PLANS.md b/PLANS.md index d8e837f..5b06491 100644 --- a/PLANS.md +++ b/PLANS.md @@ -126,9 +126,19 @@ attribution, detached-pipeline diagnostics, and OCR 1.11.2 compatibility. compatible direct path. The complete deterministic suite passed with 1,468 tests and 407 subtests at 86.31% coverage; compatibility, Towncrier, Gitleaks, dependency audit, signatures, diff checks, deterministic double build, Twine, archive privacy, and clean wheel/sdist CLI smokes are green. The - owner-authorized retained-artifact OCR repeat remains pending. Close this gate only after its raw - result, terminal manifest coverage, action counters, and offline finalization all validate; do not - claim OCR completion or findings earlier. + owner-authorized retained-artifact OCR repeat then completed the exact + `b9a0e54af7f39a1db21e2a8f4780761e74782bf8..782b205b8f8a2a5a2491c4f25b2b444a5e40c16b` + range with all 20 selected items completed, no failed/reused/waived items, 144 total tool calls, + and six exactly reconciled attempted/completed evidence summaries. No posting occurred and every + raw private artifact remains retained. Its two supported findings are corrected in the current + reviewed slice: generated OCR 1.11.1 language evidence and validation now share one canonical + sorted extension projection, and local reviews no longer serialize receipt v7 with a non-GitLab + `local` protection value. Local finalization still enforces result, action, and DLP contracts but + remains receipt-less and approval-ineligible; any present invalid receipt still fails closed. The + focused remediation and documentation matrix passes 253 tests, compatibility validation, Towncrier, + Ruff format/check, and diff checks. Close this gate only after a copied retained result passes + offline finalization/readback under the corrected code; do not modify either retained OCR run or + run another semantic OCR review. 11. [ ] Push the locally closed implementation, finish hosted checks and review with zero unresolved threads, mark the Draft PR ready, and exact-head squash merge. Delete the feature branch. 12. [ ] Verify the protected-main TestPyPI development publication, create `release/v0.9.0`, set diff --git a/changelog.d/169.bugfix.md b/changelog.d/169.bugfix.md index 7132dbc..77b245c 100644 --- a/changelog.d/169.bugfix.md +++ b/changelog.d/169.bugfix.md @@ -1 +1 @@ -Reconcile OCR 1.11.2's attempted built-in evidence tool counts with private action receipt v3 while retaining completed actions as the only source of successful evidence use and approval authority; failed or malformed calls cannot satisfy the mandatory summary. +Reconcile OCR 1.11.2's attempted built-in evidence tool counts with private action receipt v3 while retaining completed actions as the only source of successful evidence use and approval authority; failed or malformed calls cannot satisfy the mandatory summary. Local reviews remain receipt-less instead of emitting receipt v7 with a non-GitLab protection state, while every present invalid receipt still fails closed. diff --git a/compatibility/evidence/ocr-1.11.1.json b/compatibility/evidence/ocr-1.11.1.json index d325405..0cb1658 100644 --- a/compatibility/evidence/ocr-1.11.1.json +++ b/compatibility/evidence/ocr-1.11.1.json @@ -53,6 +53,22 @@ "result": "passed", "wire_field": "max_completion_tokens" }, + "language_rule_probe": { + "excluded_extensions": [ + ".svh" + ], + "extensions": [ + ".pug", + ".sv", + ".v", + ".vh", + ".vhd", + ".vhdl" + ], + "result": "passed", + "rule_source": "system_builtin", + "selected": 6 + }, "numeric_cli_probe": { "max_tokens_budget": { "cases": { @@ -188,22 +204,6 @@ "result": "passed", "review_rounds": 2 }, - "language_rule_probe": { - "excluded_extensions": [ - ".svh" - ], - "extensions": [ - ".pug", - ".sv", - ".v", - ".vhd", - ".vhdl", - ".vh" - ], - "result": "passed", - "rule_source": "system_builtin", - "selected": 6 - }, "small_change_grouping_probe": { "grouping_requests": 0, "high_churn": "per_file", diff --git a/compatibility/ocr-support.json b/compatibility/ocr-support.json index a449981..f194294 100644 --- a/compatibility/ocr-support.json +++ b/compatibility/ocr-support.json @@ -1393,7 +1393,7 @@ "semantic_grouping" ], "evidence": "compatibility/evidence/ocr-1.11.1.json", - "evidence_sha256": "24fc4c6850503c1dc1b613fd9792e36f4e405ca97e919ef9bfc1d1918efe5ef2", + "evidence_sha256": "08a125d2f10c4f99ef47a99e913408868dcd85e105c5c85c88c0abb31d0c77d2", "human_conclusion": "Compatible after source and behavioral review. Toolkit consumes bounded small-change grouping, project-rule confinement, selected-file session correction, and Pug/Verilog/SystemVerilog/VHDL review coverage; .svh remains excluded. Result and manifest schemas, completion cap 16384, explicit positive completion-cap transport, medium effort, effective max-tools, private reasoning/session boundaries, receipts, DLP, and approval authority remain unchanged. The toolkit default remains unset and qualification does not recommend a provider-specific cap. Filter survivors are still labeled confirmed upstream, so toolkit treats them as unverified and requires current code, test, and trusted evidence re-checking.", "published_at": "2026-08-31T10:06:30Z", "release_url": "https://github.com/alibaba/open-code-review/releases/tag/v1.11.1", diff --git a/docs/configuration.md b/docs/configuration.md index d154f91..d5f0b26 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -221,7 +221,7 @@ partial/budget outcomes, any receipt other than v7, an unprotected target, degra and findings omitted by `OCR_MAX_POST_COMMENTS`. For receipt v7 on a protected target, complete metadata, complete non-remediation enrichment, private-only sanitization, and the built-in evidence/context MCP are not blockers. GitLab posting also revalidates the receipt-bound source SHA and author ID, and skips without writing when the author changed or the toolkit user authored the merge request. There are intentionally no environment variables for policy thresholds or category lists in this release. -`ocr-ci review --result PATH --stderr PATH -- ...` executes OCR without posting, creates private artifacts, and prints a bounded redacted stderr excerpt to the CI log when OCR fails. It accepts only a regular, single-link result artifact and, after a successful ordinary OCR process, atomically replaces that artifact with an owner-only copy containing the toolkit's bounded MCP-use receipt. For a local diagnosis only, `--preserve-private-artifacts` retains the owner-only isolated OCR home and repository-local review artifacts and leaves the OCR result without a posting receipt. It also writes `.review-context/private-dlp-decisions.json` using schema `ocr.private-dlp-decisions/v1`: up to 1,000 rejected keys or values have only their bounded JSON path, scope/action, public reason, detector subtype, value type, character/byte/line counts, and SHA-256; `truncated` plus `omitted_decisions` report any remaining decisions. The sidecar never contains the rejected value, replaces unsafe key names with ``, collapses excessive path depth to ``, and can correlate repeated technical values by digest so a local operator can distinguish likely false positives from genuine PII, secret, limit, or laundering detections. The retained result and other paths can still contain repository, provider, model, tool-argument, tool-result, and credential-adjacent data: inspect them locally, never upload or post them, and delete them after diagnosis. Ordinary execution removes a stale sidecar and never creates a new one. A validated GitLab merge-request profile rejects this flag before OCR execution; CI detection variables do not authorize it. `OCR_POST_ERROR_DETAILS=1` separately opts into including the same safe stderr excerpt in the GitLab failure note; leave it unset when diagnostics should remain runner-only. +`ocr-ci review --result PATH --stderr PATH -- ...` executes OCR without posting, creates private artifacts, and prints a bounded redacted stderr excerpt to the CI log when OCR fails. It accepts only a regular, single-link result artifact and, after a successful ordinary OCR process, atomically replaces that artifact with an owner-only DLP-checked copy. A validated GitLab merge-request review receives receipt v7; a local review has no actual GitLab target-protection fact, so it remains receipt-less rather than claiming `protected` or emitting an invalid receipt. The compatible direct-posting path can consume a genuinely receipt-less result but cannot use receipt-derived MCP status, the unprotected-target limitation, or automatic approval. For a local diagnosis only, `--preserve-private-artifacts` retains the owner-only isolated OCR home and repository-local review artifacts and leaves the OCR result without a posting receipt. It also writes `.review-context/private-dlp-decisions.json` using schema `ocr.private-dlp-decisions/v1`: up to 1,000 rejected keys or values have only their bounded JSON path, scope/action, public reason, detector subtype, value type, character/byte/line counts, and SHA-256; `truncated` plus `omitted_decisions` report any remaining decisions. The sidecar never contains the rejected value, replaces unsafe key names with ``, collapses excessive path depth to ``, and can correlate repeated technical values by digest so a local operator can distinguish likely false positives from genuine PII, secret, limit, or laundering detections. The retained result and other paths can still contain repository, provider, model, tool-argument, tool-result, and credential-adjacent data: inspect them locally, never upload or post them, and delete them after diagnosis. Ordinary execution removes a stale sidecar and never creates a new one. A validated GitLab merge-request profile rejects this flag before OCR execution; CI detection variables do not authorize it. `OCR_POST_ERROR_DETAILS=1` separately opts into including the same safe stderr excerpt in the GitLab failure note; leave it unset when diagnostics should remain runner-only. ## Repository evidence @@ -241,7 +241,7 @@ Evidence-store schema v4 retains v1-v3 readback and adds a distinct immutable po In `metadata` mode, GitLab MR acquisition normalizes only title, description, labels, optional source branch, and the reviewed source SHA into `review.merge_request_context/v1`. Values are complete-field bounded, NFC-normalized, control-stripped, redacted, source-head-bound invocation data. Raw values never enter bootstrap, argv, environment, diagnostics, or receipts; bootstrap lists only field statuses and toolkit-authored comparison guidance. In `off` mode none of those mutable text fields reaches normalization or persistence. OCR may treat matching intent as evidence against an assumption-dependent concern, contradictory intent as mismatch evidence, and missing intent as unknown. The source-branch hint is weaker than an explicit description and cannot establish rollout intent by itself. Metadata cannot authorize tools, policy, suppression, posting, or approval. In `enriched`, references are extracted only from admitted metadata and admitted discussion bodies; adapters authorize them before local handle minting. There is no generic URL, identifier, search, or provider-tool path in the model loop. -The review step writes exact closed receipt v7 inside the private result only after cleanup and the inode-checked atomic publication transformation. It binds source, policy, and target SHA, the actual closed `protected|unprotected` target state, merge-request author ID where applicable, context mode/state/classes, per-source completeness/degradation, admitted-mutable state, the complete bounded MCP capability inventory, positive known-server and fixed tool usage, verified action-receipt v3 attempted/completed counts, mandatory-evidence state, publication-DLP result, and cleanup result. The current closed states are `passed`, `private-sanitized`, and `publication-filtered`. A pure canonical projection covers the normalized outcome/message, ordered allowlisted finding fields and warnings, manifest coverage/failure details, displayed tool counters, normalized token telemetry, omission/completeness, and approval inputs. Token telemetry has a closed provider-neutral vocabulary: input, output, cached as a subset of input, reasoning as a subset of output, optional validated total, and mathematically derived other; malformed or contradictory telemetry is unavailable and unknown provider keys are never published. Private sanitization may retain the original complete result and continue through existing approval gates only when that projection is byte-equivalent before and after sanitization. Any changed, malformed, or incomparable projection becomes a safe `publication-filtered` result with closed retained/omitted/original counts; it preserves the previous review and cannot authorize approval. The public projection may be incomplete while its validated original coverage counts still prove a complete OCR run, so the GitLab summary reports those dimensions separately and never feeds filtered warnings into legacy failed-item inference. Horizontal tab is admitted only in the closed `existing_code` and `suggestion_code` finding fields and remains subject to every non-control DLP check; tabs in all other fields and every other control/format character remain invalid. Receipt v1-v6 has no posting or approval compatibility. A present incomplete or invalid v7 receipt is rejected before previous review state is read or normal findings are published; only a genuinely absent receipt retains the compatible direct-posting path. The later GitLab posting step reads a complete valid v7 receipt instead of rebuilding context or MCP facts from a possibly changed environment. Its summary exposes only reconciled non-zero numeric server/action counts; the receipt/event never stores search queries, coverage scopes, rejected text/locations, provider/context text, upstream IDs, server URLs, commands, setup, arguments, headers, tool inputs/results, credentials, or repository contents. +For a validated GitLab merge-request profile, the review step writes exact closed receipt v7 inside the private result only after cleanup and the inode-checked atomic publication transformation. It binds source, policy, and target SHA, the actual closed `protected|unprotected` target state, merge-request author ID, context mode/state/classes, per-source completeness/degradation, admitted-mutable state, the complete bounded MCP capability inventory, positive known-server and fixed tool usage, verified action-receipt v3 attempted/completed counts, mandatory-evidence state, publication-DLP result, and cleanup result. The current closed states are `passed`, `private-sanitized`, and `publication-filtered`. A pure canonical projection covers the normalized outcome/message, ordered allowlisted finding fields and warnings, manifest coverage/failure details, displayed tool counters, normalized token telemetry, omission/completeness, and approval inputs. Token telemetry has a closed provider-neutral vocabulary: input, output, cached as a subset of input, reasoning as a subset of output, optional validated total, and mathematically derived other; malformed or contradictory telemetry is unavailable and unknown provider keys are never published. Private sanitization may retain the original complete result and continue through existing approval gates only when that projection is byte-equivalent before and after sanitization. Any changed, malformed, or incomparable projection becomes a safe `publication-filtered` result with closed retained/omitted/original counts; it preserves the previous review and cannot authorize approval. The public projection may be incomplete while its validated original coverage counts still prove a complete OCR run, so the GitLab summary reports those dimensions separately and never feeds filtered warnings into legacy failed-item inference. Horizontal tab is admitted only in the closed `existing_code` and `suggestion_code` finding fields and remains subject to every non-control DLP check; tabs in all other fields and every other control/format character remain invalid. Receipt v1-v6 has no posting or approval compatibility. A present incomplete or invalid v7 receipt is rejected before previous review state is read or normal findings are published; only a genuinely absent receipt retains the compatible direct-posting path. The later GitLab posting step reads a complete valid v7 receipt instead of rebuilding context or MCP facts from a possibly changed environment. Its summary exposes only reconciled non-zero numeric server/action counts; the receipt/event never stores search queries, coverage scopes, rejected text/locations, provider/context text, upstream IDs, server URLs, commands, setup, arguments, headers, tool inputs/results, credentials, or repository contents. ### Accepted project decisions diff --git a/docs/operations.md b/docs/operations.md index be1f357..e40163d 100644 --- a/docs/operations.md +++ b/docs/operations.md @@ -130,6 +130,8 @@ Suppression checks both the recorded inline position and compatible fingerprints ## OCR diagnostics +Receipt v7 is emitted only for a validated GitLab merge-request profile with a real `protected|unprotected` target observation. An ordinary local review still receives the same result parsing, evidence-action reconciliation, and publication DLP, but remains receipt-less because local Git state cannot establish GitLab protection. The compatible direct-posting path accepts an absent receipt, remains approval-ineligible, and cannot render receipt-derived MCP or target-protection claims; any present malformed or incomplete receipt still fails closed before prior review state is read. + Run OCR through `ocr-ci review --result PATH --stderr PATH -- ...`. This wrapper does not post to GitLab: it creates private artifacts, acquires enriched context when selected, asks the exact resolved and preflight-qualified OCR executable to preview the production refs/rules/selection/background without an LLM, then runs the model review only if OCR accepts that background. OCR owns the current recommendation and rejection thresholds; the toolkit has no threshold setting. A recognized soft diagnostic is reduced to a toolkit-authored `ocr.toolkit-advisory/v1` enum and two positive character counts. It is attached after publication DLP, rendered only with an exact receipt v7 in Technical details, and does not change warnings, coverage, DLP counts, telemetry, or automatic approval. A recognized hard character/file-size rejection stops before the model and lets `ocr-ci post` publish only a static numeric failure summary; the OCR path and raw diagnostic remain private. Unknown preview failures use the generic fail-closed diagnostic path. The ordinary review still validates the same background, the wrapper validates the complete output, and context/session/configuration data is removed. On an unclassified ordinary failure it prints only a bounded redacted stderr excerpt to the runner log; a classified provider failure keeps that excerpt private. Pass the paths and captured exit code to `ocr-ci post` afterward. Set `OCR_POST_ERROR_DETAILS=1` only when the generic path's safe excerpt should also appear in the merge-request failure note. Cleanup uncertainty blocks result publication. DLP atomically converts unsafe publication output into a safe `completed_with_errors` subset, but sanitizes unsafe private-only result fields without discarding an otherwise valid manifest or finding set. Safe findings are posted, unsafe finding content/warnings and unsafe optional fields are omitted, previous OCR comments remain, and matching prior findings are consumed one-for-one rather than duplicated. Receipt v7 and the `ocr.publication-dlp-signal/v2` marker distinguish `private-sanitized`, where the canonical published and approval-relevant projection is unchanged, from approval-ineligible `publication-filtered`, where the public projection is incomplete even when the receipt proves OCR coverage was complete. Posting uses only the validated original coverage kind and counts for status and Technical details; it never reconstructs or republishes rejected values. Horizontal tabs survive only in `existing_code` and `suggestion_code`; the unchanged values pass the same secret, PII, forbidden-value, laundering, and budget checks, while tabs elsewhere and all other control/format characters remain blocking. The same count-only JSON is logged as `OCR toolkit telemetry event` for optional CI collection/alerting. It is not an OTLP/network exporter and contains no rejected value or location. Never interpret a filtered subset as a full publishable review or as proof that OCR coverage was partial. OCR 1.11.2 may add group labels, file membership, grouping-task records, round diagnostics, readable reasoning, and provider-native signed or encrypted replay payloads to its private session data. Safe values remain private; the isolated OCR home is removed before publication. Unknown private-only result fields are sanitized through the existing DLP owner, while any value reaching a canonical finding or warning makes publication incomplete and approval-ineligible. Reasoning, native payloads, and `tool_choice` are deliberately absent from the canonical finding/posting projection, receipt v7, DLP counts, toolkit telemetry, tool/token summary, and approval inputs. If they appear inside receipt v7, the receipt is invalid and approval fails closed. Caller `--output`/`-o` is rejected before preview: only `ocr-ci review --result` owns the result descriptor and posting handoff. diff --git a/docs/security.md b/docs/security.md index e9a3ba9..2dd20bf 100644 --- a/docs/security.md +++ b/docs/security.md @@ -124,6 +124,7 @@ Policy v2 adds optional toolkit-owned remediation threads without changing these - **Marker and retry spoofing:** recognize ownership, run-summary, and setup identities only in the exact toolkit preamble. Repository/provider text containing marker lookalikes has no authority. Duplicate findings consume visible prior occurrences one-for-one; successful setup retries replace only previous toolkit setup-pending notes. - **Approval/suppression manipulation:** every admitted remediation thread blocks automatic approval; partial, required-degraded, or DLP-rejected context cannot prove absence or restore eligibility. Safe MR metadata, generic discussions, and adapter records do not themselves block an otherwise eligible receipt. Remediation text cannot change severity, prove a fix, suppress or resolve a finding, issue a lifecycle command, or authorize approval. Existing suppression, exact slash/mention commands, fingerprints, and discussion ownership remain separate deterministic consumers. - **Persistent-session leakage:** run OCR under an isolated owner-only home and clean session artifacts deterministically. Containment/cleanup failure blocks publication. The explicit local diagnostic exception preserves owner-only paths and produces no posting-eligible receipt; the authoritative GitLab MR profile rejects it before OCR execution. +- **Local receipt confusion:** local Git state cannot establish an actual GitLab target-protection fact. Local review finalization therefore enforces result, action, and DLP contracts without emitting receipt v7; the compatible receipt-less posting path cannot authorize approval or receipt-derived status. Any present invalid receipt still fails closed. - **Second-review-engine drift:** OCR remains the sole review engine. If contextual adjudication needs a separate model phase, depend on a native structured OCR API rather than merge two toolkit-driven reviews. ### Deployment assumptions and residual risks diff --git a/scripts/ocr_compat.py b/scripts/ocr_compat.py index 38b24af..ea3033f 100644 --- a/scripts/ocr_compat.py +++ b/scripts/ocr_compat.py @@ -431,29 +431,11 @@ def validate_manifest(manifest: dict[str, Any], root: Path = ROOT) -> None: _fail(f"evidence does not qualify small-change grouping behavior for {version}") expected_language_probe = { "excluded_extensions": [".svh"], - "extensions": [".pug", ".sv", ".v", ".vhd", ".vhdl", ".vh"], + "extensions": _expected_language_rule_extensions(version), "result": "passed", "rule_source": "system_builtin", - "selected": 6, + "selected": len(_expected_language_rule_extensions(version)), } - if _version(version) >= (1, 11, 2): - expected_language_probe.update( - { - "extensions": [ - ".cjs", - ".cxx", - ".hxx", - ".mjs", - ".pug", - ".sv", - ".v", - ".vh", - ".vhd", - ".vhdl", - ], - "selected": 10, - } - ) if ( _version(version) >= (1, 11, 1) and contracts.get("language_rule_probe") != expected_language_probe @@ -2026,6 +2008,15 @@ def preview(home_name: str, *extra: str) -> dict[str, Any] | str: } +def _expected_language_rule_extensions(version: str) -> list[str]: + """Return the canonical sorted extension projection for one OCR release.""" + + extensions = {".pug", ".sv", ".v", ".vh", ".vhd", ".vhdl"} + if _version(version) >= (1, 11, 2): + extensions.update({".cjs", ".cxx", ".hxx", ".mjs"}) + return sorted(extensions) + + def _language_rule_probe(binary: Path, version: str, directory: Path) -> dict[str, object]: """Prove consumed built-in language selection and rule ownership without an LLM.""" @@ -2124,8 +2115,12 @@ def _language_rule_probe(binary: Path, version: str, directory: Path) -> dict[st expected_pattern = exact_patterns.get(path) if expected_pattern is not None and f"Pattern: {expected_pattern}\n" not in output: _fail(f"candidate resolved the wrong built-in language rule for {path}") + extensions = sorted(Path(path).suffix for path in supported_paths) + expected_extensions = _expected_language_rule_extensions(version) + if extensions != expected_extensions: + _fail("language probe paths disagree with the canonical extension projection") return { - "extensions": sorted(Path(path).suffix for path in supported_paths), + "extensions": expected_extensions, "excluded_extensions": [".svh"], "result": "passed", "rule_source": "system_builtin", diff --git a/src/ocr_toolkit/review_runner.py b/src/ocr_toolkit/review_runner.py index fff861f..d2f7930 100644 --- a/src/ocr_toolkit/review_runner.py +++ b/src/ocr_toolkit/review_runner.py @@ -1049,13 +1049,17 @@ def finalize(payload: dict[str, object]) -> dict[str, object]: ) metadata["publication"] = publication metadata["schema_version"] = TOOLKIT_RESULT_SCHEMA_VERSION - if identity.mr_author_id is not None and not toolkit_receipt_is_valid(metadata): - raise ReviewRunnerError("toolkit generated an invalid review receipt") + provider_receipt = identity.target_protection in {"protected", "unprotected"} + if provider_receipt: + if not toolkit_receipt_is_valid(metadata): + raise ReviewRunnerError("toolkit generated an invalid review receipt") + elif identity.target_protection != "local" or identity.mr_author_id is not None: + raise ReviewRunnerError("toolkit generated an invalid review identity") mcp = metadata.get("mcp") raw_usage = mcp.get("usage") if isinstance(mcp, dict) else None usage = dict(raw_usage) if isinstance(raw_usage, dict) else {} - finalized = {**projected, TOOLKIT_RESULT_KEY: metadata} - if toolkit_advisory is not None: + finalized = {**projected, TOOLKIT_RESULT_KEY: metadata} if provider_receipt else projected + if toolkit_advisory is not None and provider_receipt: finalized[TOOLKIT_ADVISORY_KEY] = toolkit_advisory_payload(toolkit_advisory) return finalized diff --git a/tests/test_ocr_compat.py b/tests/test_ocr_compat.py index 9fec248..284d6fa 100644 --- a/tests/test_ocr_compat.py +++ b/tests/test_ocr_compat.py @@ -78,6 +78,20 @@ def test_committed_manifest_is_valid_and_has_recommended_tested_baseline() -> No ] +def test_language_probe_generation_and_validation_share_canonical_order() -> None: + """Keep regenerated evidence byte-compatible with the manifest validator.""" + + module = load_script() + for version in ("1.11.1", "1.11.2"): + evidence = module.load_json( + PROJECT_ROOT / "compatibility" / "evidence" / f"ocr-{version}.json" + ) + extensions = evidence["contracts"]["language_rule_probe"]["extensions"] + + assert extensions == module._expected_language_rule_extensions(version) + assert extensions == sorted(extensions) + + def test_manifest_rejects_recommended_candidate(tmp_path: Path) -> None: module = load_script() manifest = module.load_json(MANIFEST) diff --git a/tests/test_review_runner.py b/tests/test_review_runner.py index 1b88c74..d541ad6 100644 --- a/tests/test_review_runner.py +++ b/tests/test_review_runner.py @@ -28,13 +28,22 @@ from ocr_toolkit.evidence.artifacts import EvidenceArtifacts, repository_artifacts from ocr_toolkit.evidence.review_context import normalize_merge_request_context from ocr_toolkit.mcp_config import MCPCapability, MCPComposition -from ocr_toolkit.posting import approval, settings +from ocr_toolkit.posting import approval, settings, snapshot, workflow from ocr_toolkit.result_contract import parse_result_outcome -from tests.support import patched_attr, patched_env +from tests.support import gitlab_config, patched_attr, patched_env from tests.test_context_broker import ci_outcome from tests.test_context_policy import ci_policy_value, encoded_policy, remediation_policy_value DEFAULT_IDENTITY = review_runner.ReviewIdentity( + source_sha="a" * 40, + policy_sha="b" * 40, + mr_author_id=41, + context_mode="off", + context=None, + target_sha="b" * 40, + target_protection="protected", +) +LOCAL_IDENTITY = review_runner.ReviewIdentity( source_sha="a" * 40, policy_sha="b" * 40, mr_author_id=None, @@ -738,8 +747,8 @@ def test_ocr_result_requires_builtin_mcp_usage_for_completed_review(tmp_path: Pa "source_sha": "a" * 40, "policy_sha": "b" * 40, "target_sha": "b" * 40, - "target_protection": "local", - "mr_author_id": None, + "target_protection": "protected", + "mr_author_id": 41, }, "context": { "mode": "off", @@ -890,6 +899,117 @@ def test_generated_mr_receipt_is_validated_before_atomic_publication( assert not result.exists() +def test_local_finalization_uses_direct_posting_without_a_provider_receipt( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + """A local review must not invent GitLab protection while remaining postable.""" + + result = tmp_path / "result.json" + result.write_text( + json.dumps( + { + "status": "complete", + "comments": [], + "warnings": [], + "tool_calls": {"total": 1, "by_tool": {"ocr_toolkit_evidence": 1}}, + "manifest": { + "schema_version": "ocr.run-manifest/v1", + "operation": "review", + "terminal_state": "complete", + "coverage": { + "selected": [{"item_id": "synthetic-item"}], + "completed": [{"item_id": "synthetic-item"}], + "reused": [], + "failed": [], + "waived": [], + }, + }, + } + ), + encoding="utf-8", + ) + composition = MCPComposition( + payload={}, + capabilities=(MCPCapability("ocr_toolkit_evidence", BUILTIN_EVIDENCE_TOOLS, True),), + external_servers=(), + secret_values=(), + ) + + assert review_runner._record_ocr_result_mcp_usage( + result, + composition, + LOCAL_IDENTITY, + evidence_action_counts=SUMMARY_ACTION_COUNTS, + ) == {"ocr_toolkit_evidence": 1} + persisted = json.loads(result.read_text(encoding="utf-8")) + assert "_ocr_toolkit" not in persisted + assert "_ocr_toolkit_advisory" not in persisted + + notes: list[str] = [] + monkeypatch.setattr( + workflow, + "collect_previous_bot_comment_refs", + lambda _config: snapshot.BotCommentRefs(), + ) + monkeypatch.setattr( + workflow, + "post_review_note_bounded", + lambda _config, _title, body, _transaction: notes.append(body) or {"id": 1}, + ) + monkeypatch.setattr(workflow, "finalize_posting", lambda *_args: True) + monkeypatch.setattr( + workflow, + "delete_previous_bot_comments_if_collected", + lambda *_args: None, + ) + + assert workflow.post_results(gitlab_config(), persisted) == 0 + assert len(notes) == 1 + assert "Review complete — no findings" in notes[0] + assert "verified MCP calls" not in notes[0] + + +@pytest.mark.parametrize( + "identity", + [ + replace(LOCAL_IDENTITY, mr_author_id=41), + replace(LOCAL_IDENTITY, target_protection="unknown"), + ], +) +def test_non_provider_identity_cannot_emit_receipt_metadata( + tmp_path: Path, + identity: review_runner.ReviewIdentity, +) -> None: + result = tmp_path / "result.json" + result.write_text( + json.dumps( + { + "status": "success", + "tool_calls": {"total": 1, "by_tool": {"ocr_toolkit_evidence": 1}}, + } + ), + encoding="utf-8", + ) + composition = MCPComposition( + payload={}, + capabilities=(MCPCapability("ocr_toolkit_evidence", BUILTIN_EVIDENCE_TOOLS, True),), + external_servers=(), + secret_values=(), + ) + + with pytest.raises(review_runner.ReviewRunnerError, match="invalid review identity"): + review_runner._finalize_ocr_result( + result, + composition, + identity, + None, + SUMMARY_ACTION_COUNTS, + forbidden=(), + ) + + assert not result.exists() + + def test_evidence_action_receipt_reconciles_each_fixed_tool_independently( tmp_path: Path, ) -> None: @@ -1430,7 +1550,7 @@ def test_publication_dlp_allows_tabs_only_in_code_fields_without_skipping_contro assert blocked is True -def test_publication_dlp_retains_only_safe_local_findings_and_closed_receipt( +def test_publication_dlp_retains_only_safe_local_findings_without_provider_receipt( tmp_path: Path, ) -> None: result = tmp_path / "result.json" @@ -1480,7 +1600,7 @@ def test_publication_dlp_retains_only_safe_local_findings_and_closed_receipt( usage, blocked, publication = review_runner._finalize_ocr_result( result, composition, - DEFAULT_IDENTITY, + LOCAL_IDENTITY, None, SUMMARY_ACTION_COUNTS, forbidden=("private discussion sentence",), @@ -1518,7 +1638,7 @@ def test_publication_dlp_retains_only_safe_local_findings_and_closed_receipt( "total": 2, "by_tool": {"ocr_toolkit_evidence": 1, "task_done": 1}, } - assert persisted["_ocr_toolkit"]["publication"] == publication + assert "_ocr_toolkit" not in persisted assert publication["retained"] == {"comments": 2, "warnings": 1} assert publication["omitted"] == {"comments": 1, "warnings": 1, "fields": 2} @@ -3908,7 +4028,7 @@ def test_private_artifact_preservation_is_rejected_for_gitlab_mr_profile() -> No assert ( review_runner._authorize_private_artifact_preservation( - DEFAULT_IDENTITY, + LOCAL_IDENTITY, requested=True, ) is True From 1a16fd0c2d86dd9c4d67b1ef890a15ab83176f2f Mon Sep 17 00:00:00 2001 From: xeonvs <11463419+xeonvs@users.noreply.github.com> Date: Thu, 3 Sep 2026 09:00:31 +0200 Subject: [PATCH 13/15] plan: close retained OCR readback --- PLANS.md | 13 ++++++++----- 1 file changed, 8 insertions(+), 5 deletions(-) diff --git a/PLANS.md b/PLANS.md index 5b06491..7faa691 100644 --- a/PLANS.md +++ b/PLANS.md @@ -10,7 +10,7 @@ before handoff or commit. Completed stable plans are indexed in Status: active Owner: Codex -Last Updated: 2026-09-02 +Last Updated: 2026-09-03 Release classification: `release-required` Target stable version: `0.9.0` Milestone: `v0.9.0` @@ -103,7 +103,7 @@ attribution, detached-pipeline diagnostics, and OCR 1.11.2 compatibility. defect, and the repeated deterministic gates passed with 1,438 tests, 406 subtests, 86.35% coverage, all scoped risk floors, compatibility, Towncrier, Gitleaks, dependency audit, signatures, diff checks, clean build/Twine, archive privacy, and wheel/sdist CLI smokes green. -10. [ ] The checksum-verified PATH-effective OCR 1.11.2 installation and its rollback copy are +10. [x] The checksum-verified PATH-effective OCR 1.11.2 installation and its rollback copy are complete. The one permitted configured-provider review ran over exact range `b9a0e54af7f39a1db21e2a8f4780761e74782bf8..cb9f4d9f39305e4cfdae5d4c91be7138edd0c4e3` with context `off`, public Rules, and concurrency 2, then failed closed before publication because @@ -136,9 +136,12 @@ attribution, detached-pipeline diagnostics, and OCR 1.11.2 compatibility. `local` protection value. Local finalization still enforces result, action, and DLP contracts but remains receipt-less and approval-ineligible; any present invalid receipt still fails closed. The focused remediation and documentation matrix passes 253 tests, compatibility validation, Towncrier, - Ruff format/check, and diff checks. Close this gate only after a copied retained result passes - offline finalization/readback under the corrected code; do not modify either retained OCR run or - run another semantic OCR review. + Ruff format/check, and diff checks. Owner-only offline finalization of an exact copied result and + action receipt confirmed the retained manifest range, terminal 20/20 coverage, six attempted and + six completed evidence summaries, zero unattributed actions, the retained private DLP projection, + a passed publication projection, and corrected receipt-less, approval-ineligible local output. The + finalizer made no network or posting attempt, and recursive before/after hashes confirmed all 15 + files in the retained OCR directory remained byte-identical. No additional semantic OCR ran. 11. [ ] Push the locally closed implementation, finish hosted checks and review with zero unresolved threads, mark the Draft PR ready, and exact-head squash merge. Delete the feature branch. 12. [ ] Verify the protected-main TestPyPI development publication, create `release/v0.9.0`, set From cdf928a2a196700120e5ef5c1eda9186cbff3a29 Mon Sep 17 00:00:00 2001 From: xeonvs <11463419+xeonvs@users.noreply.github.com> Date: Thu, 3 Sep 2026 09:05:27 +0200 Subject: [PATCH 14/15] plan: include OCR 1.11.3 qualification --- PLANS.md | 42 +++++++++++++++++++++++++++++------------- 1 file changed, 29 insertions(+), 13 deletions(-) diff --git a/PLANS.md b/PLANS.md index 7faa691..6040912 100644 --- a/PLANS.md +++ b/PLANS.md @@ -6,7 +6,7 @@ before handoff or commit. Completed stable plans are indexed in ## Active Work -### Release 0.9.0: unprotected-target integrity and OCR 1.11.2 +### Release 0.9.0: unprotected-target integrity and OCR 1.11.2/1.11.3 Status: active Owner: Codex @@ -14,20 +14,22 @@ Last Updated: 2026-09-03 Release classification: `release-required` Target stable version: `0.9.0` Milestone: `v0.9.0` -Authorization issues: `#167`, `#168`, `#169`, `#170` +Authorization issues: `#167`, `#168`, `#169`, `#170`, `#172` Feature branch: `codex/v0.9.0-unprotected-target-integrity` #### Goal Deliver safe limited OCR reviews for explicitly permitted unprotected GitLab targets while preserving immutable identity, comment-only approval boundaries, valid receipt/action -attribution, detached-pipeline diagnostics, and OCR 1.11.2 compatibility. +attribution, detached-pipeline diagnostics, and adjacent OCR 1.11.2/1.11.3 compatibility. #### Locked scope and decisions - The release snapshot contains every issue open at activation: #167-#170. Later issues are - not added automatically. Consumer repositories, B2B, `core/common`, and shared CI templates - are outside scope. + not added automatically. The owner explicitly authorized the late addition of qualification + issue #172 after OCR 1.11.3 was published, so #172 is part of this release without widening + scope to any other later issue. Consumer repositories, B2B, `core/common`, and shared CI + templates are outside scope. - Resolve one effective reviewed source SHA across review, posting, and pre-execution status validation. Accept only lowercase 40-hex values, prefer a valid non-zero MR SHA, and fall back to `CI_COMMIT_SHA` only for an absent or all-zero MR SHA. All malformed, conflicting, @@ -54,6 +56,13 @@ attribution, detached-pipeline diagnostics, and OCR 1.11.2 compatibility. `.mjs`/`.cjs` JavaScript and `.cxx`/`.hxx` C++ routing while `.svh` remains excluded. Viewer, remote-MCP documentation, localization, and Go module-boundary changes have no toolkit runtime impact. +- Qualify OCR 1.11.3 against the branch-qualified 1.11.2 baseline without another semantic OCR + run. Map its raw-traffic capture, failed-tool telemetry, SIGTERM handling, untracked-file error + propagation, and editor-only dependency changes to consumed toolkit boundaries. Raw provider + traffic and private failure details must never enter a toolkit publication, receipt, telemetry, + or approval path; non-zero or malformed failed-tool telemetry must fail closed rather than turn + an attempted evidence action into completed use or approval authority. Promote 1.11.3 only after + deterministic/no-provider compatibility and hostile boundary tests pass. #### Delivery sequence @@ -142,13 +151,19 @@ attribution, detached-pipeline diagnostics, and OCR 1.11.2 compatibility. a passed publication projection, and corrected receipt-less, approval-ineligible local output. The finalizer made no network or posting attempt, and recursive before/after hashes confirmed all 15 files in the retained OCR directory remained byte-identical. No additional semantic OCR ran. -11. [ ] Push the locally closed implementation, finish hosted checks and review with zero unresolved +11. [ ] Implement #172 as a separately reviewed signed slice. Reconcile the hosted 1.11.3 evidence + against branch-qualified 1.11.2, contain raw-capture inheritance and failed-tool diagnostics, + extend the #167-#169 hostile chain where the new additive schema crosses publication or + approval boundaries, promote tested/recommended pins and public contracts, then repeat the + complete deterministic and holistic range gates. This step may use deterministic/no-provider + compatibility probes only: Codex Security and semantic OCR must not run again. +12. [ ] Push the locally closed implementation, finish hosted checks and review with zero unresolved threads, mark the Draft PR ready, and exact-head squash merge. Delete the feature branch. -12. [ ] Verify the protected-main TestPyPI development publication, create `release/v0.9.0`, set +13. [ ] Verify the protected-main TestPyPI development publication, create `release/v0.9.0`, set stable/next versions to `0.9.0`/`0.9.1`, record deterministic source epoch and authorization - issues `[167,168,169,170]`, render Towncrier, archive this plan, and finish the protected release - PR. -13. [ ] Monitor stable publication and independently verify registry/workflow/Release byte equality, + issues `[167,168,169,170,172]`, render Towncrier, archive this plan, and finish the protected + release PR. +14. [ ] Monitor stable publication and independently verify registry/workflow/Release byte equality, PEP 740 and GitHub attestations, annotated tag target, immutable five-asset Release, release receipt, Python 3.12-3.14 wheel/sdist installs, Actions-owned issue receipts, issue/milestone closure, branch cleanup, and clean `main == origin/main == v0.9.0^{}`. @@ -162,6 +177,7 @@ staged diff for correctness, hostile inputs, DLP/privacy, and scope, then run `g Final deterministic gates are `scripts/quality.sh check`, compatibility validation, Towncrier draft, Gitleaks, build/Twine, archive/privacy checks, and clean wheel/sdist CLI smoke. OCR exit zero is not completion without complete selected-item coverage and exact receipt/action reconciliation. The one -owner-authorized repeat semantic OCR run must preserve every private intermediate artifact and may run -only after the root fix is committed; no additional semantic run is authorized. Stable delivery remains -incomplete until external publication and independent reconciliation are complete. +owner-authorized repeat semantic OCR run and its private intermediates remain retained; it is complete +and no additional semantic run is authorized. The completed Codex Security scan must not be rerun. +OCR 1.11.3 qualification is deterministic/no-provider only. Stable delivery remains incomplete until +external publication and independent reconciliation are complete. From 3441e8a77d71b026db89a896d6374f6cb0a09b99 Mon Sep 17 00:00:00 2001 From: xeonvs <11463419+xeonvs@users.noreply.github.com> Date: Thu, 3 Sep 2026 10:10:24 +0200 Subject: [PATCH 15/15] feat: preserve review signal with OCR 1.11.3 --- AGENTS.md | 5 +- PLANS.md | 37 ++- README.md | 12 +- ROADMAP.md | 4 +- changelog.d/169.bugfix.md | 2 +- changelog.d/170.maintenance.md | 2 +- changelog.d/172.maintenance.md | 1 + compatibility/evidence/ocr-1.11.3.json | 240 ++++++++++++++ compatibility/ocr-support.json | 57 +++- docs/README.md | 2 + docs/codex/TASKS_BACKLOG.md | 2 +- docs/compatibility.md | 16 +- docs/configuration.md | 30 +- docs/engineering/m5_context_contracts.md | 4 +- docs/engineering/review_signal_ownership.md | 8 +- docs/engineering/test_evidence_matrix.md | 11 +- docs/engineering/toolkit_strategy.md | 6 +- docs/gitlab.md | 4 +- docs/operations.md | 16 +- docs/review-context.md | 8 +- docs/review-decision-flow.md | 185 +++++++++++ docs/security.md | 16 +- examples/gitlab/README.md | 2 +- .../modes/unprotected-target.gitlab-ci.yml | 2 +- examples/gitlab/ocr-review.gitlab-ci.yml | 6 +- src/ocr_toolkit/ocr_result.py | 2 +- src/ocr_toolkit/posting/formatting.py | 2 +- src/ocr_toolkit/posting/workflow.py | 8 +- src/ocr_toolkit/preflight.py | 2 +- src/ocr_toolkit/review_receipt.py | 33 +- src/ocr_toolkit/review_runner.py | 254 ++++++++++++++- tests/support.py | 7 +- tests/test_environment_contract.py | 12 +- tests/test_gitlab_provider.py | 3 +- tests/test_integration_contracts.py | 8 +- tests/test_ocr_compat.py | 51 +-- tests/test_operations_docs.py | 54 +++- tests/test_posting_approval.py | 105 ++++--- tests/test_posting_helpers.py | 147 ++++++++- tests/test_review_runner.py | 296 +++++++++++++++++- tests/test_runtime_helpers.py | 2 +- 41 files changed, 1449 insertions(+), 215 deletions(-) create mode 100644 changelog.d/172.maintenance.md create mode 100644 compatibility/evidence/ocr-1.11.3.json create mode 100644 docs/review-decision-flow.md diff --git a/AGENTS.md b/AGENTS.md index 695d473..69d8124 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -14,7 +14,8 @@ Use this file as the short repository map and startup workflow for Open Code Rev - `PLANS.md` - active or blocked repository work and its release classification. - `docs/engineering/toolkit_strategy.md` and `ROADMAP.md` - durable direction and outcome state. -- `docs/engineering/project_principles.md` - cross-cutting engineering invariants and ownership boundaries; `docs/engineering/m5_context_contracts.md` owns the active v0.7.0 context checkpoint. +- `docs/engineering/project_principles.md` - cross-cutting engineering invariants and ownership boundaries; `docs/engineering/m5_context_contracts.md` owns the current context and evidence contracts. +- `docs/review-decision-flow.md` - canonical detailed Mermaid map for review, diagnostics, receipts, DLP, publication, and later-action decisions; keep it synchronized with runtime and public contracts. - `docs/development.md` - implementation workflow, boundary checklists, and local validation. - `docs/release.md` - release classification, authorization, publication, and archival lifecycle. - `docs/codex/TASKS_BACKLOG.md` - inactive work with activation conditions. @@ -25,7 +26,7 @@ Use this file as the short repository map and startup workflow for Open Code Rev ## Work Startup 1. Read `PLANS.md`. Before changing the repository, create or update the active plan and classify user-visible work as `no-release`, `release-required`, or `release-deferred`; record the target stable version when applicable. -2. Select canonical guidance by scope: engineering invariants for runtime or trust-boundary work, development procedures for implementation and validation, release guidance for release lifecycle changes, and the relevant public contract for user-facing behavior. Consult the pitfalls catalogue only when diagnosing a matching failure class. +2. Select canonical guidance by scope: engineering invariants for runtime or trust-boundary work, the review decision flow for execution or publication branch changes, development procedures for implementation and validation, release guidance for release lifecycle changes, and the relevant public contract for user-facing behavior. Consult the pitfalls catalogue only when diagnosing a matching failure class. 3. Preserve the requested scope as coherent production-quality slices. Record service boundaries, trust inputs, validation, documentation, and closure gates in the plan before implementation. 4. Use targeted tests while iterating and the boundary checklist for every changed parser, I/O, persistence, Git, subprocess, provider, or reporting boundary. Keep fixtures and public material synthetic and private-safe. 5. Before staging or committing, update the plan and affected status/documentation to post-commit truth, inspect the complete diff, run `git diff --check`, and run the validation owned by the changed subsystem. Use `scripts/quality.sh` for the Python matrix and `scripts/gitleaks.sh` before publishing rewritten or newly committed branch history. diff --git a/PLANS.md b/PLANS.md index 6040912..d22727d 100644 --- a/PLANS.md +++ b/PLANS.md @@ -59,10 +59,15 @@ attribution, detached-pipeline diagnostics, and adjacent OCR 1.11.2/1.11.3 compa - Qualify OCR 1.11.3 against the branch-qualified 1.11.2 baseline without another semantic OCR run. Map its raw-traffic capture, failed-tool telemetry, SIGTERM handling, untracked-file error propagation, and editor-only dependency changes to consumed toolkit boundaries. Raw provider - traffic and private failure details must never enter a toolkit publication, receipt, telemetry, - or approval path; non-zero or malformed failed-tool telemetry must fail closed rather than turn - an attempted evidence action into completed use or approval authority. Promote 1.11.3 only after - deterministic/no-provider compatibility and hostile boundary tests pass. + traffic must never be captured by a toolkit-owned OCR child, while bounded credential-redacted + failure details remain console/CI-job diagnostics only. The additive failure envelope is not + authoritative for the review result: absent, malformed, or contradictory diagnostics must not + discard or suppress a valid manifest, findings, summary, or posting transaction. Provider-backed + receipts retain only a closed diagnostic state and verified aggregate failed count; the + toolkit-owned action receipt remains authoritative for completed evidence. Non-zero or uncertain + diagnostics may block later approval authority, but never publication of an otherwise valid review + signal. Promote 1.11.3 only after deterministic/no-provider compatibility and hostile end-to-end + publication boundary tests pass. #### Delivery sequence @@ -151,12 +156,24 @@ attribution, detached-pipeline diagnostics, and adjacent OCR 1.11.2/1.11.3 compa a passed publication projection, and corrected receipt-less, approval-ineligible local output. The finalizer made no network or posting attempt, and recursive before/after hashes confirmed all 15 files in the retained OCR directory remained byte-identical. No additional semantic OCR ran. -11. [ ] Implement #172 as a separately reviewed signed slice. Reconcile the hosted 1.11.3 evidence - against branch-qualified 1.11.2, contain raw-capture inheritance and failed-tool diagnostics, - extend the #167-#169 hostile chain where the new additive schema crosses publication or - approval boundaries, promote tested/recommended pins and public contracts, then repeat the - complete deterministic and holistic range gates. This step may use deterministic/no-provider - compatibility probes only: Codex Security and semantic OCR must not run again. +11. [x] Implement #172 as a separately reviewed signed slice. Hosted workflow `33725971286`, + checksum-pinned schema-v3 evidence, adjacent-source audit, and independent Darwin arm64 readback + qualify OCR 1.11.3 against the branch-qualified 1.11.2 predecessor without another semantic OCR + run. Toolkit-owned preview and review children now remove inherited `OCR_RAW_LOGGING`; the strict + additive diagnostic parser keeps bounded credential-redacted detail in the local/CI console only, + strips detail and per-tool failure maps before finalization, and records only a closed receipt-v8 + state plus a verified aggregate. Absent, malformed, hostile, non-zero, and action-receipt-conflicting + diagnostics preserve valid local and provider-backed findings, summary, manifest, DLP projection, + and actual posting transaction; toolkit action receipt v3 remains authoritative for completed + evidence, while uncertain or non-zero diagnostics independently block later approval authority. + The new canonical `docs/review-decision-flow.md` supplies three contract-tested Mermaid maps with + stable error, warning, success, auxiliary, and decision colors; the root `AGENTS.md`, strategy, + documentation index, runtime owners, hostile tests, and all affected public contracts link to and + agree with it. The complete deterministic suite passes with 1,486 tests and 408 subtests at 86.38% + coverage and every scoped risk floor green. Compatibility, Towncrier draft, pinned Gitleaks, + dependency audit, frozen Ruff format, signatures, diff checks, deterministic double build, Twine, + archive privacy, and separate clean wheel/sdist CLI smokes also pass. No additional semantic OCR or + Codex Security scan ran. 12. [ ] Push the locally closed implementation, finish hosted checks and review with zero unresolved threads, mark the Draft PR ready, and exact-head squash merge. Delete the feature branch. 13. [ ] Verify the protected-main TestPyPI development publication, create `release/v0.9.0`, set diff --git a/README.md b/README.md index fde54ca..5d0d7c8 100644 --- a/README.md +++ b/README.md @@ -31,11 +31,11 @@ python -m pip install open-code-review-toolkit ``` The toolkit does not bundle OCR. Download the platform asset for exact -[Open Code Review 1.11.2](https://github.com/alibaba/open-code-review/releases/tag/v1.11.2), +[Open Code Review 1.11.3](https://github.com/alibaba/open-code-review/releases/tag/v1.11.3), verify it before installation against the [compatibility manifest](compatibility/ocr-support.json), and place the verified binary on `PATH`. The current Linux amd64 digest is -`fad3c62314478822c1e90f858f3e06b3f8dc5ff0034933927c3ecd7faa5d3731`; Darwin arm64 is -`8c1a58cacca6d82d4616f121e07bd7c586a2e6c88d5b6688e3bfedf135d17ff4`. The manifest owns +`9726204ac81baee153fd65b1ff357c380f73e9d8091c4a73c3c9fb541b5164cb`; Darwin arm64 is +`515cd92ce300b62dea2bcaf53e910cdf57b681e1d58bbd0a9e8e49617bca52fa`. The manifest owns the other platform hashes. Verify the installation without contacting an LLM provider: @@ -45,14 +45,14 @@ ocr --version ocr-ci --help ``` -`ocr --version` must report `open-code-review v1.11.2`; `ocr-ci --help` must exit +`ocr --version` must report `open-code-review v1.11.3`; `ocr-ci --help` must exit successfully. `ocr-ci preflight` is an operational configuration check, not the installation smoke test. The exact recommended OCR release and its verified asset checksums live in the [versioned compatibility manifest](compatibility/ocr-support.json). CI should pin that release and checksum before execution. The [versioned compatibility policy](docs/compatibility.md) records tested assets and evidence and describes the conservative Dependabot-like qualification workflow for later upstream releases. Review output defaults to English. `OCR_REVIEW_LANGUAGE` accepts another explicit language name when a project needs localized review output; for example, `OCR_REVIEW_LANGUAGE=Russian`. -The current OCR 1.11.2 integration defaults `OCR_REVIEW_EFFORT` to `medium` for two review rounds. `low` and `high` are explicit one- and three-round alternatives; see the [configuration reference](docs/configuration.md#review-effort) for cost, budget, and precedence boundaries. +The current OCR 1.11.3 integration defaults `OCR_REVIEW_EFFORT` to `medium` for two review rounds. `low` and `high` are explicit one- and three-round alternatives; see the [configuration reference](docs/configuration.md#review-effort) for cost, budget, and precedence boundaries. Stable distributions are published to [PyPI](https://pypi.org/project/open-code-review-toolkit/) and mirrored as checksum-listed, provenance-attested assets in the corresponding [GitHub Release](https://github.com/xeonvs/open-code-review-toolkit/releases). Development snapshots are published only to TestPyPI. @@ -62,7 +62,7 @@ On a successful rerun, the toolkit replaces untouched OCR-only notes instead of Suppression uses both the GitLab diff position and a stable finding fingerprint, so ordinary line shifts do not normally bring the same bug back. A materially changed finding can still receive a new discussion. See [GitLab review operations](docs/operations.md) for the complete lifecycle, posting modes, permissions, failure behavior, and Mermaid state diagram. -After every current review note publishes, the GitLab adapter can add a conservative approval bound to receipt v7's exact reviewed source SHA, target SHA/protection state, and merge-request author. This write is enabled by default; set `OCR_AUTO_APPROVE=false` when the bot must remain comment-only. DLP-clean metadata, generic discussions, protected same-revision CI outcomes, and adapter records do not independently block approval, while an unprotected target, degraded metadata, DLP rejection, required context degradation, admitted remediation history, legacy receipts, publication filtering, any direct external MCP, author movement, or bot self-authorship prevents an approval write. A CI status is review context, never approval authority. GitLab approval rules, Code Owners, and protected-branch policy remain authoritative merge policy. They are not prerequisites for OCR execution. The toolkit only adds an eligible approval; it never removes an existing approval when a later review is ineligible or disabled. +After every current review note publishes, the GitLab adapter can add a conservative approval bound to receipt v8's exact reviewed source SHA, target SHA/protection state, and merge-request author. This write is enabled by default; set `OCR_AUTO_APPROVE=false` when the bot must remain comment-only. DLP-clean metadata, generic discussions, protected same-revision CI outcomes, and adapter records do not independently block approval, while an unprotected target, degraded metadata, DLP rejection, required context degradation, admitted remediation history, legacy receipts, publication filtering, any direct external MCP, author movement, or bot self-authorship prevents an approval write. A CI status is review context, never approval authority. GitLab approval rules, Code Owners, and protected-branch policy remain authoritative merge policy. They are not prerequisites for OCR execution. The toolkit only adds an eligible approval; it never removes an existing approval when a later review is ineligible or disabled. Accepted tradeoffs can be recorded in `.opencodereview/accepted-decisions.md`; the evidence collector supplies only applicable target-ref decisions and never lets a source change self-authorize its review. Root and nested target `AGENTS.md`/`CLAUDE.md` guidance is similarly exposed through the existing evidence MCP with deterministic scope and precedence, while any guidance touched by the merge request is excluded. See [Accepted project decisions](docs/configuration.md#accepted-project-decisions) and [Target project guidance](docs/configuration.md#target-project-guidance) for formats and trust boundaries. diff --git a/ROADMAP.md b/ROADMAP.md index db37a2c..895c424 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -34,7 +34,7 @@ flowchart LR | M2 Ecosystem and framework coverage | Established | Supply framework and template evidence selected from demonstrated use without creating framework-specific review engines. | Established evidence, snapshot/delta, scoped-completeness, and built-in MCP contracts. | Selected static plugins and template review rules have deterministic fixtures, bounds, provenance, component ownership, completeness, first-class source/target delta queries, installed-artifact validation, verified use through the existing built-in MCP, and independently read-back stable delivery. | | M3 External MCP hardening | Established | Qualify and document the safe-use envelope and residual limits of the shipped generic external-MCP composition boundary. | Existing external MCP and built-in composition plus BL-011 real-OCR qualification. | Canonical security and configuration guidance records the direct-composition trust boundaries, tool-name allowlist limits, server-owned object authorization, shared plan/main exposure, response/session persistence, failure degradation, and receipt non-claims observed with checksum-verified OCR and a real synthetic stdio peer. Managed OAuth remains conditional. | | M4 Policy and project guidance | Established | Supply relevant target-branch decisions and guidance without allowing self-whitelisting. | Evidence scoping and target/source snapshots. | Stable delivery independently proves backward-compatible structured decisions, bounded target-derived guidance, one read-only MCP lifecycle, and closure of the tracked release work. | -| M5 Bounded review-context enrichment | Established | Extend invocation evidence with bounded forge discussions, verified remediation history, protected same-revision CI outcomes, and optional external records through one provider-neutral, capability-constrained context lifecycle, without a second review engine. | Established M1, M3, and M4 boundaries plus the v0.7.0 BL-023 delivery. | v0.7.0 establishes bounded discussion/reference acquisition. The v0.8.0 release tree adds policy-v2 remediation selection, context-store v2 and fixed MCP projection, live bot-root/mention identity, DLP isolation, comment-only remediation, and provider-neutral reuse boundaries. v0.8.7 adds policy-v3 exact-head CI outcomes as scoped review evidence without suppression or approval authority. Toolkit 0.9.0 adds receipt-v7 source/target/protection binding and a separate comment-only path for explicitly allowed unprotected targets; that path rejects protected policy, enrichment, adapters, external MCP, accepted decisions, and structured target guidance rather than claiming protected-policy equivalence. The owner waived the separate enriched OCR+LLM qualification, so no receipt proves model-time `context_list`/`context_get`, still-present/evidence-resolved scenarios, or receipt-level raw-data leakage inspection. Protected release publication and independent external readback remain mandatory delivery evidence but do not replace that absent qualification. | +| M5 Bounded review-context enrichment | Established | Extend invocation evidence with bounded forge discussions, verified remediation history, protected same-revision CI outcomes, and optional external records through one provider-neutral, capability-constrained context lifecycle, without a second review engine. | Established M1, M3, and M4 boundaries plus the v0.7.0 BL-023 delivery. | v0.7.0 establishes bounded discussion/reference acquisition. The v0.8.0 release tree adds policy-v2 remediation selection, context-store v2 and fixed MCP projection, live bot-root/mention identity, DLP isolation, comment-only remediation, and provider-neutral reuse boundaries. v0.8.7 adds policy-v3 exact-head CI outcomes as scoped review evidence without suppression or approval authority. Toolkit 0.9.0 adds receipt-v8 source/target/protection binding and a separate comment-only path for explicitly allowed unprotected targets; that path rejects protected policy, enrichment, adapters, external MCP, accepted decisions, and structured target guidance rather than claiming protected-policy equivalence. The owner waived the separate enriched OCR+LLM qualification, so no receipt proves model-time `context_list`/`context_get`, still-present/evidence-resolved scenarios, or receipt-level raw-data leakage inspection. Protected release publication and independent external readback remain mandatory delivery evidence but do not replace that absent qualification. | | M6 Profiles and quality measurement | Established / conditional | Keep the completed review-signal ownership audit current; add model-profile aliases only after demonstrated operational need. | The BL-017 audit establishes OCR/provider telemetry and toolkit lifecycle-signal ownership; a demonstrated alias need and owner-approved matrix are required only for profile implementation. | The audit concludes `no-new-layer`; any later model profiles remain conditional and independent from explicit coverage and budget controls. | | M7 Later and conditional work | Conditional | Activate routing, more ecosystems, fuzzing, configuration, forge adapters, or governance work only from demonstrated need. | Milestone-specific activation signals and stable preceding contracts. | Each item meets its own trigger and ships as a coherent validated slice without weakening core invariants. | @@ -45,7 +45,7 @@ flowchart LR - M2 is established through independently verified stable delivery of its framework plugins, template rules, scoped evidence, deltas, and built-in MCP projection. Conditional future ecosystem packs remain in M7 and do not reopen M2. - M4 is established through independently verified v0.6.0 artifacts and later protected-target identity improvements. M5 consumes but does not reopen its policy boundary. - M5's foundation is established by the v0.7.0 BL-023 release content; the v0.8.0 release tree completes its remediation/provider-neutral extension under an explicit owner waiver for the separate enriched OCR+LLM receipt. The waiver is a permanent evidence limitation, not a successful qualification: model-time `context_list`/`context_get`, still-present/evidence-resolved scenarios, and receipt-level raw-data leakage inspection remain unproven. Direct provider-specific MCP enforcement is not a second M5 path: external records stay behind the broker, while generic direct MCP retains the established M3 operator boundary. Its dependency graph is exactly `M1 -> M5`, `M3 -> M5`, and `M4 -> M5`; it is not a gate for M6 or M7. The protected release workflow, immutable receipt, and independent registry/GitHub readback remain mandatory external delivery closure and do not erase the waiver. -- The completed M6 BL-017 audit maps the versioned toolkit receipt, privacy-safe normalized token buckets, reconciled MCP/evidence-action counts, and OCR 1.10.0 grouping/round telemetry to their existing owners and concludes `no-new-layer`. Current receipt v7 adds immutable target/protection identity and exact action-receipt-v2 reconciliation without creating telemetry. M5 does not create a second telemetry implementation or exporter. BL-016 remains parked because OCR already exposes direct run-level selection; BL-018 remains conditional. +- The completed M6 BL-017 audit maps the versioned toolkit receipt, privacy-safe normalized token buckets, reconciled MCP/evidence-action counts, and OCR 1.10.0 grouping/round telemetry to their existing owners and concludes `no-new-layer`. Current receipt v8 adds immutable target/protection identity and exact action-receipt-v3 reconciliation without creating telemetry. M5 does not create a second telemetry implementation or exporter. BL-016 remains parked because OCR already exposes direct run-level selection; BL-018 remains conditional. - Versioned documentation remains a separate MCP integration: the toolkit supplies package/version evidence but does not store documentation. - Additional code-hosting adapters remain conditional and GitLab-first M5 does not depend on them. - Historical roadmap names, release plans, changelog entries, closed issues/PRs, and receipts retain their original identities. BL-022 is historical and is not reused. diff --git a/changelog.d/169.bugfix.md b/changelog.d/169.bugfix.md index 77b245c..55b5a3a 100644 --- a/changelog.d/169.bugfix.md +++ b/changelog.d/169.bugfix.md @@ -1 +1 @@ -Reconcile OCR 1.11.2's attempted built-in evidence tool counts with private action receipt v3 while retaining completed actions as the only source of successful evidence use and approval authority; failed or malformed calls cannot satisfy the mandatory summary. Local reviews remain receipt-less instead of emitting receipt v7 with a non-GitLab protection state, while every present invalid receipt still fails closed. +Reconcile OCR 1.11.2's attempted built-in evidence tool counts with private action receipt v3 while retaining completed actions as the only source of successful evidence use and approval authority; failed or malformed calls cannot satisfy the mandatory summary. Local reviews remain receipt-less instead of emitting receipt v8 with a non-GitLab protection state, while every present invalid receipt still fails closed. diff --git a/changelog.d/170.maintenance.md b/changelog.d/170.maintenance.md index 9356328..9952d5e 100644 --- a/changelog.d/170.maintenance.md +++ b/changelog.d/170.maintenance.md @@ -2,6 +2,6 @@ Target checksum-verified Open Code Review 1.11.2 as the sole accepted runtime fo Changed: built-in language routing admits `.mjs` and `.cjs` under JavaScript Rules and `.cxx` and `.hxx` under C++ Rules; `.svh` remains excluded. Hosted Linux and independent Darwin qualification use checksum-verified release artifacts. -Unchanged: result and manifest schemas, inherited completion cap `16384`, explicit positive completion-cap transport, default `medium` effort, effective max-tools, private reasoning/session data, receipt v7, DLP, telemetry ownership, and approval authority. +Unchanged: result and manifest schemas, inherited completion cap `16384`, explicit positive completion-cap transport, default `medium` effort, effective max-tools, private reasoning/session data, receipt v8, DLP, telemetry ownership, and approval authority. Deployment: install OCR 1.11.2 directly and verify the platform checksum from the compatibility manifest; OCR 1.11.1 is historical comparison evidence, not a runtime fallback. diff --git a/changelog.d/172.maintenance.md b/changelog.d/172.maintenance.md new file mode 100644 index 0000000..f3a10c8 --- /dev/null +++ b/changelog.d/172.maintenance.md @@ -0,0 +1 @@ +Promote checksum-verified Open Code Review 1.11.3, prevent inherited raw-provider capture, and preserve valid findings and summaries when additive failed-tool diagnostics are absent, malformed, non-zero, or contradictory. diff --git a/compatibility/evidence/ocr-1.11.3.json b/compatibility/evidence/ocr-1.11.3.json new file mode 100644 index 0000000..ac6a96d --- /dev/null +++ b/compatibility/evidence/ocr-1.11.3.json @@ -0,0 +1,240 @@ +{ + "assets": [ + { + "name": "opencodereview-darwin-amd64", + "sha256": "e2dfc9a80f2368209c0238b94aab0bb2d4d9839eb08c7fcbebb7a5897e05634c", + "size": 57791184 + }, + { + "name": "opencodereview-darwin-arm64", + "sha256": "515cd92ce300b62dea2bcaf53e910cdf57b681e1d58bbd0a9e8e49617bca52fa", + "size": 54828322 + }, + { + "name": "opencodereview-linux-amd64", + "sha256": "9726204ac81baee153fd65b1ff357c380f73e9d8091c4a73c3c9fb541b5164cb", + "size": 56049826 + }, + { + "name": "opencodereview-linux-arm64", + "sha256": "e04a44bec440c20c22141678412052e976a8bbb698824482a1f8832dfc7a0687", + "size": 52756642 + }, + { + "name": "opencodereview-windows-amd64.exe", + "sha256": "d643d81f39eeba14c9439e2432ec263f9a2b90e606dd7e639eb5340dfbd3d643", + "size": 57510400 + }, + { + "name": "opencodereview-windows-arm64.exe", + "sha256": "56d1a9e12dd053272649a1435484cf9c89bbdecf070a87c7c58e13dc84b9f0f9", + "size": 53474816 + }, + { + "name": "sha256sum.txt", + "sha256": "472a3ce64834f4909bc9fac50e6de08a3c8ef5c0489b755dd1d2b19c14c8792e", + "size": 572 + } + ], + "classification": "human-review-required", + "classification_reasons": [ + "candidate is not a newer patch in the tested major/minor line", + "release notes contain a material or ambiguous compatibility signal" + ], + "comparison_version": "1.11.2", + "contracts": { + "comment_thinking_probe": { + "additive_field_preserved": true, + "posting_exposes_thinking": false, + "result": "passed" + }, + "completion_cap_probe": { + "explicit": 4096, + "inherited": 16384, + "result": "passed", + "wire_field": "max_completion_tokens" + }, + "language_rule_probe": { + "excluded_extensions": [ + ".svh" + ], + "extensions": [ + ".cjs", + ".cxx", + ".hxx", + ".mjs", + ".pug", + ".sv", + ".v", + ".vh", + ".vhd", + ".vhdl" + ], + "result": "passed", + "rule_source": "system_builtin", + "selected": 10 + }, + "numeric_cli_probe": { + "max_tokens_budget": { + "cases": { + "invalid_below": { + "effective": null, + "input": -1, + "outcome": "rejected" + }, + "minimum": { + "effective": 1, + "input": 1, + "outcome": "accepted" + }, + "omitted": { + "effective": "unlimited", + "input": null, + "outcome": "accepted" + }, + "representative": { + "effective": 30000, + "input": 30000, + "outcome": "accepted" + }, + "sentinel": { + "effective": "unlimited", + "input": 0, + "outcome": "accepted" + } + }, + "maximum": null, + "owner": "ocr-cli" + }, + "max_tools": { + "cases": { + "invalid_below": { + "effective": null, + "input": -1, + "outcome": "rejected" + }, + "minimum": { + "effective": 100, + "input": 50, + "outcome": "accepted" + }, + "minimum_minus_one": { + "effective": 100, + "input": 49, + "outcome": "normalized", + "reported_normalization": 50 + }, + "omitted": { + "effective": 100, + "input": null, + "outcome": "accepted" + }, + "representative": { + "effective": 101, + "input": 101, + "outcome": "accepted" + }, + "sentinel": { + "effective": 100, + "input": 0, + "outcome": "accepted" + } + }, + "maximum": null, + "owner": "ocr-template-or-higher-cli", + "reported_minimum": 50 + }, + "result": "passed" + }, + "optional_capabilities": [ + "llm_result_identity", + "per_run_model_override", + "per_run_provider_override", + "review_effort", + "semantic_grouping" + ], + "preview_probe": { + "format": "json", + "path": "example.py", + "result": "passed", + "session_store_created": false + }, + "required_review_flags": [ + "--audience", + "--background-file", + "--effort", + "--format", + "--from", + "--max-tokens-budget", + "--max-tools", + "--preview", + "--rule", + "--to" + ], + "result_contract_probe": { + "additive_fields_allowed": true, + "comment_fields": [ + "category", + "content", + "end_line", + "existing_code", + "path", + "severity", + "start_line", + "thinking" + ], + "manifest_schema": "ocr.run-manifest/v1", + "normalized_outcome": "clean", + "result": "passed" + }, + "review_budget_probe": { + "budget": 30000, + "completed": 2, + "failed_budget": 1, + "grouping_requests": 0, + "grouping_strategy": "per_file", + "partial_findings_preserved": true, + "result": "passed", + "selected": 3 + }, + "semantic_grouping_probe": { + "default_effort": "medium", + "files": 4, + "filter_requests": 1, + "grouping_completion_cap": 16384, + "grouping_requests": 1, + "main_requests": 3, + "prior_finding_semantics": "filter_survivors_as_confirmed", + "recheck_instruction_requests": 3, + "result": "passed", + "review_rounds": 2 + }, + "small_change_grouping_probe": { + "grouping_requests": 0, + "high_churn": "per_file", + "low_churn": "bundle_all", + "result": "passed", + "single_file": "per_file", + "threshold_files": 4 + }, + "target_rule_selection_probe": { + "format": "json", + "from_to_unchanged": true, + "path": "synthetic-template.ocrfixture", + "result": "passed", + "source_exclusion": "unsupported_ext", + "target_selected": true + }, + "version_probe": "passed" + }, + "published_at": "2026-09-03T03:32:58Z", + "release_changes": "## 🚀 Features\n\n- feat(raw): capture raw LLM traffic via SDK middleware (opt-in) (#1109)\n\n## 🐛 Bug Fixes\n\n- fix(tool): report tool call failures (#1144)\n- fix(diff): propagate untracked file listing errors (#1140)\n- fix(review): handle SIGTERM for graceful shutdown (#1111)\n\n## Other Changes\n\n- chore(deps-dev): bump the vscode-dependencies group (#1129)\n\n**Full Changelog**: https://github.com/alibaba/open-code-review/compare/v1.11.2...v1.11.3", + "release_notes_sha256": "a9b03fa2751a8094f5c9d89b336b52f9c17b62e5cec8fdc40601d4166d072313", + "release_url": "https://github.com/alibaba/open-code-review/releases/tag/v1.11.3", + "result": "compatible", + "schema_version": 3, + "tag": "v1.11.3", + "tested_baseline_version": "1.11.2", + "upstream_repository": "alibaba/open-code-review", + "version": "1.11.3" +} diff --git a/compatibility/ocr-support.json b/compatibility/ocr-support.json index f194294..ede7f00 100644 --- a/compatibility/ocr-support.json +++ b/compatibility/ocr-support.json @@ -1,6 +1,6 @@ { - "monitoring_floor": "1.11.2", - "recommended_version": "1.11.2", + "monitoring_floor": "1.11.3", + "recommended_version": "1.11.3", "releases": [ { "assets": [ @@ -1452,6 +1452,59 @@ "release_url": "https://github.com/alibaba/open-code-review/releases/tag/v1.11.2", "status": "tested", "version": "1.11.2" + }, + { + "assets": [ + { + "name": "opencodereview-darwin-amd64", + "sha256": "e2dfc9a80f2368209c0238b94aab0bb2d4d9839eb08c7fcbebb7a5897e05634c", + "size": 57791184 + }, + { + "name": "opencodereview-darwin-arm64", + "sha256": "515cd92ce300b62dea2bcaf53e910cdf57b681e1d58bbd0a9e8e49617bca52fa", + "size": 54828322 + }, + { + "name": "opencodereview-linux-amd64", + "sha256": "9726204ac81baee153fd65b1ff357c380f73e9d8091c4a73c3c9fb541b5164cb", + "size": 56049826 + }, + { + "name": "opencodereview-linux-arm64", + "sha256": "e04a44bec440c20c22141678412052e976a8bbb698824482a1f8832dfc7a0687", + "size": 52756642 + }, + { + "name": "opencodereview-windows-amd64.exe", + "sha256": "d643d81f39eeba14c9439e2432ec263f9a2b90e606dd7e639eb5340dfbd3d643", + "size": 57510400 + }, + { + "name": "opencodereview-windows-arm64.exe", + "sha256": "56d1a9e12dd053272649a1435484cf9c89bbdecf070a87c7c58e13dc84b9f0f9", + "size": 53474816 + }, + { + "name": "sha256sum.txt", + "sha256": "472a3ce64834f4909bc9fac50e6de08a3c8ef5c0489b755dd1d2b19c14c8792e", + "size": 572 + } + ], + "capabilities": [ + "llm_result_identity", + "per_run_model_override", + "per_run_provider_override", + "review_effort", + "semantic_grouping" + ], + "evidence": "compatibility/evidence/ocr-1.11.3.json", + "evidence_sha256": "6ad0e1f607b2ddcbb9e7b25e4432d6102ec22d7ef6847c2858c8fb22d38cc0f2", + "human_conclusion": "Compatible after hosted checksum-verified Linux qualification in workflow 33725971286, independent Darwin arm64 checksum/version readback, adjacent v1.11.2...v1.11.3 source review, and deterministic toolkit boundary tests. The toolkit removes inherited OCR_RAW_LOGGING from preview and review children. Failed-tool telemetry is bounded, credential-redacted console diagnostics only: valid review findings, summaries, manifests, and posting transactions survive absent, malformed, non-zero, hostile, or contradictory additive diagnostics, while receipt v8 keeps only a closed state and verified aggregate. Toolkit action receipt v3 remains authoritative for completed evidence. SIGTERM handling is compatible with toolkit masking and cleanup; untracked-file propagation concerns workspace mode while production uses immutable --from/--to refs; VS Code dependency updates are not consumed.", + "published_at": "2026-09-03T03:32:58Z", + "release_url": "https://github.com/alibaba/open-code-review/releases/tag/v1.11.3", + "status": "tested", + "version": "1.11.3" } ], "schema_version": 1, diff --git a/docs/README.md b/docs/README.md index 6afcce5..5eb2451 100644 --- a/docs/README.md +++ b/docs/README.md @@ -10,6 +10,7 @@ This page routes readers to the canonical document for each topic. It does not r - [engineering/README.md](engineering/README.md) - [gitlab.md](gitlab.md) - [operations.md](operations.md) +- [review-decision-flow.md](review-decision-flow.md) - [release.md](release.md) - [review-context.md](review-context.md) - [security.md](security.md) @@ -20,6 +21,7 @@ This page routes readers to the canonical document for each topic. It does not r - [GitLab integration](gitlab.md) — installation, operating modes, bot setup, and provider behavior. - [Environment configuration](configuration.md) — supported variables, owners, exact defaults, and trust boundaries. - [GitLab review operations](operations.md) — posting, approval, discussion commands, reruns, and failure handling. +- [Review decision flow](review-decision-flow.md) — canonical Mermaid maps for execution, diagnostics, DLP, publication, and later actions. - [Bounded review context](review-context.md) — context modes, policy selection, adapters, DLP, store/MCP, and remediation threads. - [OCR compatibility](compatibility.md) — supported upstream versions, qualification, and checksums. - [Security and trust model](security.md) — threat boundaries and safe deployment requirements. diff --git a/docs/codex/TASKS_BACKLOG.md b/docs/codex/TASKS_BACKLOG.md index b3f4b42..4175f6f 100644 --- a/docs/codex/TASKS_BACKLOG.md +++ b/docs/codex/TASKS_BACKLOG.md @@ -44,7 +44,7 @@ M3 is established. BL-011 is complete and recorded above rather than retained as ## M6 Profiles and quality measurement -Provider/network telemetry remains outside M1 and M5. OCR owns token, cost, budget, provider-level review duration, request, and tool-call telemetry. The toolkit exposes only validated provider-neutral token buckets, distinguishes OCR-wide tool totals from verified MCP-server and count-only evidence-action use, and carries deterministic publication-DLP state in receipt v7, a parseable GitLab summary marker, and a structured local log event; it adds no exporter or endpoint. The 0.8.5 development line adds at most one bounded toolkit-authored CI failure diagnostic from closed retry-report enums and counts, not provider telemetry or an export path. M6 audits whether result-derived lifecycle signals need provider-neutral export/alert routing instead of duplicating OCR telemetry. +Provider/network telemetry remains outside M1 and M5. OCR owns token, cost, budget, provider-level review duration, request, and tool-call telemetry. The toolkit exposes only validated provider-neutral token buckets, distinguishes OCR-wide tool totals from verified MCP-server and count-only evidence-action use, and carries deterministic publication-DLP state in receipt v8, a parseable GitLab summary marker, and a structured local log event; it adds no exporter or endpoint. The 0.8.5 development line adds at most one bounded toolkit-authored CI failure diagnostic from closed retry-report enums and counts, not provider telemetry or an export path. M6 audits whether result-derived lifecycle signals need provider-neutral export/alert routing instead of duplicating OCR telemetry. ### BL-016: Evaluate explicit run-level model profiles diff --git a/docs/compatibility.md b/docs/compatibility.md index 029f213..bee6b0a 100644 --- a/docs/compatibility.md +++ b/docs/compatibility.md @@ -99,20 +99,30 @@ The project `rule.json` layer now confines the file itself and referenced rule f Deploy toolkit 0.8.7 directly with OCR 1.11.1. The public Linux amd64 example verifies SHA-256 `1cdc7d1f776f1cdb69850130b930e40f64accc86ecaf09600573b3600456322f`; Darwin arm64 verifies `5fdf72e51aae021ac7bf43d7b9dcb160f04880f623c66e8ada5e6ae5a92e172c`; and upstream `sha256sum.txt` verifies `8760d31184c12f947c182fcb00114730707892524ddf1beac78fc415cb61b37b`. -### OCR 1.11.2 — toolkit 0.9.0 target +### OCR 1.11.2 — toolkit 0.9.0 qualification predecessor -OCR 1.11.2 is the sole runtime accepted by toolkit 0.9.0 preflight and pinned by its GitLab example. OCR 1.11.1 remains adjacent historical comparison evidence, not a supported fallback. Hosted workflow `33508349494`, issue #170, strict schema-v3 evidence, direct adjacent source review, and an independent checksum-verified Darwin arm64 no-LLM probe preserve the OCR result and manifest schemas, inherited completion cap `16384`, explicit positive completion-cap transport, default `medium` effort, effective max-tools, private reasoning/session boundary, receipt v7, DLP, toolkit telemetry ownership, and approval authority. The toolkit default remains unset; qualification does not recommend a provider-specific cap. +OCR 1.11.2 was the initial runtime qualified during toolkit 0.9.0 development and is the adjacent predecessor for final 1.11.3 qualification. It is not accepted by the final 0.9.0 preflight. Hosted workflow `33508349494`, issue #170, strict schema-v3 evidence, direct adjacent source review, and an independent checksum-verified Darwin arm64 no-LLM probe preserved the then-current OCR result and manifest schemas, inherited completion cap `16384`, explicit positive completion-cap transport, default `medium` effort, effective max-tools, private reasoning/session boundary, receipt v7, DLP, toolkit telemetry ownership, and approval authority. The toolkit default remained unset; qualification did not recommend a provider-specific cap. Built-in language selection now routes JavaScript modules (`.mjs`, `.cjs`) through exact pattern `**/*.{ts,js,tsx,jsx,mjs,cjs}` and C++ sources and headers (`.cxx`, `.hxx`) through exact pattern `**/*.{cpp,cc,cxx,hpp,hxx}`. Qualification proves all four files are selected, resolves their Rules from `System built-in`, and preserves the established Pug, Verilog/SystemVerilog, and VHDL coverage. `.svh` remains excluded as `unsupported_ext` rather than being inferred from the SystemVerilog name. The viewer URL-opening change is outside the toolkit execution path. Remote-MCP setup and localization changes are documentation-only, and the nested `pages/go.mod` boundary only excludes documentation-site `node_modules` from upstream Go package enumeration. None changes the toolkit's MCP composition, provider transport, publication, receipt, or approval contract. -Deploy toolkit 0.9.0 directly with OCR 1.11.2. The public Linux amd64 example verifies SHA-256 `fad3c62314478822c1e90f858f3e06b3f8dc5ff0034933927c3ecd7faa5d3731`; the independently verified Darwin arm64 artifact uses `8c1a58cacca6d82d4616f121e07bd7c586a2e6c88d5b6688e3bfedf135d17ff4`; and upstream `sha256sum.txt` verifies `1d997d5ced335750e50df8af0d8a0ec8b9978937d1f26047b0a2114b7a8fa9e1`. +The historical 1.11.2 Linux amd64 evidence verifies SHA-256 `fad3c62314478822c1e90f858f3e06b3f8dc5ff0034933927c3ecd7faa5d3731`; Darwin arm64 verifies `8c1a58cacca6d82d4616f121e07bd7c586a2e6c88d5b6688e3bfedf135d17ff4`; and upstream `sha256sum.txt` verifies `1d997d5ced335750e50df8af0d8a0ec8b9978937d1f26047b0a2114b7a8fa9e1`. Use the final 1.11.3 section below for toolkit 0.9.0 deployment. The contiguous 1.9.7 through 1.9.8 promotion combines the repository-owned hosted Linux qualification with adjacent upstream source review. It also repeats the required 1.9.8 contract probes through the checksum-verified Darwin arm64 artifact. Multi-tool MCP initialization, fixed tool discovery and dispatch, and owner-only session persistence remain compatible with the M5 design: the toolkit may expose several fixed tools from its one built-in process, while an isolated `HOME` contains OCR's `.opencodereview/sessions` state for deterministic cleanup. Gemini and Bedrock are optional upstream provider capabilities that the toolkit neither selects nor configures; mirror-aware installation is outside the toolkit-owned download path; native severity retains the structured result fields already consumed; and human-audience progress routing does not affect the toolkit's agent-audience invocation. Therefore this promotion requires no toolkit CI behavior adaptation beyond version and checksum pins. These source observations establish capability only; the M5 production-path tests remain responsible for proving the toolkit's composition and containment. An automatic-safe result is not an automatic stable release. It must still pass a normal protected compatibility PR and a separate signed stable-release PR. If a dedicated OCR update bot credential is not configured, the workflow publishes the exact patch as an artifact and records the resume action in the issue; the default `GITHUB_TOKEN` is intentionally not used to create a PR that would fail to trigger the full protected workflow set. +### OCR 1.11.3 — toolkit 0.9.0 target + +OCR 1.11.3 supersedes 1.11.2 as the sole runtime accepted by toolkit 0.9.0 preflight and pinned by its GitLab example. Hosted workflow `33725971286`, issue #172, checksum-verified schema-v3 evidence, adjacent `v1.11.2...v1.11.3` source review, independent Darwin arm64 checksum/version readback, and deterministic toolkit boundary tests preserve the qualified result, manifest, language-selection, inherited completion cap `16384`, explicit positive completion-cap transport, effort, max-tools, and immutable-range contracts. The toolkit default remains unset and qualification does not recommend a provider-specific cap. No additional semantic OCR run was used for this promotion. + +The new opt-in upstream raw-traffic capture is never inherited by a toolkit-owned OCR preview or review child: `OCR_RAW_LOGGING` is removed while the remaining validated provider environment is preserved. OCR's new `failure`, `failure_by_tool`, and `failure_details` fields are additive diagnostics rather than review authority. The toolkit validates their complete bounded shape, renders valid details only to credential-redacted control-safe local/CI stderr, and never persists dynamic details or per-tool failure maps in the finalized result, receipt, GitLab comments, or toolkit telemetry. Absent, malformed, non-zero, hostile, or action-receipt-conflicting diagnostics cannot discard a valid manifest, findings, summary, or posting transaction. Receipt v8 retains only a closed `absent|verified|invalid|conflicting` state plus a bounded aggregate count for `verified`; action receipt v3 remains authoritative for completed evidence. Uncertain or non-zero diagnostics can block a separate later action without suppressing publication. The canonical branches are shown in the [review decision flow](review-decision-flow.md). + +Upstream SIGTERM handling is compatible with the toolkit's termination masking and cleanup boundary. Untracked-file listing error propagation affects OCR workspace discovery, while the production GitLab path supplies immutable explicit `--from`/`--to` refs. VS Code dependency updates are not consumed. Built-in language routing remains the exact ten-extension set qualified for 1.11.2, with `.svh` still excluded. + +Deploy toolkit 0.9.0 directly with OCR 1.11.3. The public Linux amd64 example verifies SHA-256 `9726204ac81baee153fd65b1ff357c380f73e9d8091c4a73c3c9fb541b5164cb`; Darwin arm64 verifies `515cd92ce300b62dea2bcaf53e910cdf57b681e1d58bbd0a9e8e49617bca52fa`; and upstream `sha256sum.txt` verifies `472a3ce64834f4909bc9fac50e6de08a3c8ef5c0489b755dd1d2b19c14c8792e`. + ## Promotion and rollback Promotion changes `recommended_version`, advances `monitoring_floor`, adds the tested release and evidence, and updates every machine-readable or executable version/checksum pin. Never edit only one copy. Human-qualified candidates must record the compatibility conclusion and release-note impact; an automatic-safe candidate may also record a reviewed conclusion when it is delivered with a human-reviewed chain instead of using the generic machine conclusion. Conclusions may name only versions present in that promotion. A reviewed promotion may cross only one adjacent semantic-version boundary at a time: the next patch, the next minor at `.0`, or the next major at `.0.0`; minor and major transitions always require an explicit human conclusion. Automatic-safe preparation remains limited to adjacent patches in the already-tested major/minor line and retains the same protected review boundary even though the patch itself is mechanical. diff --git a/docs/configuration.md b/docs/configuration.md index d5f0b26..b803677 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -22,7 +22,7 @@ These are the complete supported toolkit-owned runtime inputs. `Required` is sco | `OCR_LLM_VALIDATE_MODEL` | Operator / `ocr-ci preflight` | No | `false` | `true` validates through `/models`; `auto` may use the offline allowlist; false values skip validation. | | `OCR_LLM_MODELS_URL` | Operator / `ocr-ci preflight` | No | Derived from `OCR_LLM_URL` | Explicit absolute credential-free HTTPS metadata URL when validation is enabled or inference query parameters make derivation ambiguous. | | `OCR_LLM_ALLOWED_MODELS` | Operator / `ocr-ci preflight` | No | Empty list | Comma-separated exact model identifiers for offline or `auto` validation. | -| `OCR_TELEMETRY_ENABLED` | Operator / `ocr-ci configure` | No | `false` | Exact `true` enables OCR telemetry configuration; OCR 1.11.2 spans may include path-derived group keys, model-produced labels, and local grouping decisions. | +| `OCR_TELEMETRY_ENABLED` | Operator / `ocr-ci configure` | No | `false` | Exact `true` enables OCR telemetry configuration; OCR 1.11.3 spans may include path-derived group keys, model-produced labels, and local grouping decisions. | | `OCR_TELEMETRY_CONTENT_LOGGING` | Operator / `ocr-ci configure` | No | `false` | Exact `true` enables OCR content logging; keep disabled for private review data. | | `OCR_TELEMETRY_EXPORTER` | Operator / `ocr-ci configure` | No | Empty string | Exporter name written only when telemetry is enabled. | | `OCR_TELEMETRY_OTLP_ENDPOINT` | Operator / `ocr-ci configure` | No | Unset | OTLP endpoint written only when telemetry is enabled and the value is non-empty. | @@ -60,13 +60,13 @@ If `OCR_LLM_EXTRA_BODY` already owns that field, an exactly equal JSON integer i The toolkit does not derive this value from `/models.max_completion_tokens`. That metadata is a model capability boundary, not an account spending limit or proof of how a gateway reserves request cost. -The inherited value is version-owned and therefore changes with a qualified OCR upgrade. The toolkit observed `max_completion_tokens=58888` from OCR 1.9.10 and `16384` from OCR 1.10.0 through 1.11.2 when the variable was unset. Grouping requests use the same template-owned cap; an explicit toolkit override still applies to every protocol request. Deployments that require an invariant gateway-specific cap must set `OCR_LLM_MAX_COMPLETION_TOKENS` explicitly rather than depending on an OCR default. +The inherited value is version-owned and therefore changes with a qualified OCR upgrade. The toolkit observed `max_completion_tokens=58888` from OCR 1.9.10 and `16384` from OCR 1.10.0 through 1.11.3 when the variable was unset. Grouping requests use the same template-owned cap; an explicit toolkit override still applies to every protocol request. Deployments that require an invariant gateway-specific cap must set `OCR_LLM_MAX_COMPLETION_TOKENS` explicitly rather than depending on an OCR default. ### Review effort -`OCR_REVIEW_EFFORT` defaults to `medium` and is written to OCR's root `effort` configuration key. OCR 1.11.2 maps `low`, `medium`, and `high` to one, two, and three review rounds and scales its 15-minute subtask base to 15, 30, or 45 minutes. The environment is operator-owned; merge-request text cannot change it. An explicit caller `--effort` passed after `ocr-ci review --` has normal OCR CLI precedence over the generated config, while an unknown environment value fails configuration before preview or model execution. +`OCR_REVIEW_EFFORT` defaults to `medium` and is written to OCR's root `effort` configuration key. OCR 1.11.3 maps `low`, `medium`, and `high` to one, two, and three review rounds and scales its 15-minute subtask base to 15, 30, or 45 minutes. The environment is operator-owned; merge-request text cannot change it. An explicit caller `--effort` passed after `ocr-ci review --` has normal OCR CLI precedence over the generated config, while an unknown environment value fails configuration before preview or model execution. -OCR 1.11.2 may present filter-surviving comments to a later round as previously confirmed, but the toolkit does not accept that wording as validation. Its mandatory background prefix travels with every main request and requires prior/filter-surviving findings to remain unverified until current code, tests, or trusted evidence support them. Survival cannot change severity, suppress or resolve a finding, authorize approval, or enter a receipt as independent validation. +OCR 1.11.3 may present filter-surviving comments to a later round as previously confirmed, but the toolkit does not accept that wording as validation. Its mandatory background prefix travels with every main request and requires prior/filter-surviving findings to remain unverified until current code, tests, or trusted evidence support them. Survival cannot change severity, suppress or resolve a finding, authorize approval, or enter a receipt as independent validation. Effort controls review depth, not the prompt/context ceiling, per-call completion cap, aggregate token budget, or per-round tool limit. Semantic grouping and filtering can add requests even at `low`; higher effort can add further rounds until OCR stops early, reaches a coverage/budget boundary, or completes the configured depth. @@ -95,13 +95,13 @@ These names belong to `examples/gitlab/ocr-review.gitlab-ci.yml`; they are shell | Variable | Source / owner | Required | Exact default | Behavior | | --- | --- | --- | --- | --- | -| **`OCR_VERSION`** | Example pipeline | Yes | `v1.11.2` | Checksum-pinned recommended OCR binary release for toolkit 0.9.0. | -| **`OCR_SHA256`** | Example pipeline | Yes | `fad3c62314478822c1e90f858f3e06b3f8dc5ff0034933927c3ecd7faa5d3731` | Expected Linux AMD64 OCR binary digest. | +| **`OCR_VERSION`** | Example pipeline | Yes | `v1.11.3` | Checksum-pinned recommended OCR binary release for toolkit 0.9.0. | +| **`OCR_SHA256`** | Example pipeline | Yes | `9726204ac81baee153fd65b1ff357c380f73e9d8091c4a73c3c9fb541b5164cb` | Expected Linux AMD64 OCR binary digest. | | **`OCR_TOOLKIT_VERSION`** | Example pipeline | Yes | `0.8.7` | Exact toolkit wheel release installed by the current published example. | | **`OCR_TOOLKIT_CHECKSUMS_URL`** | Example pipeline | Yes | Release URL derived from `OCR_TOOLKIT_VERSION` | Toolkit `SHA256SUMS` URL. | | `OCR_TOOLKIT_WHEEL` | Example shell | Computed | `open_code_review_toolkit-${OCR_TOOLKIT_VERSION}-py3-none-any.whl` | Exact wheel filename selected from the release. | | `OCR_TOOLKIT_WHEEL_SHA256` | Example shell | Computed | Matching value from `SHA256SUMS` | Digest checked before installing the toolkit wheel. | -| `OCR_MAX_TOOLS` | Example pipeline / OCR CLI | No | `0` | OCR 1.11.2 uses template default `100`; `1-49` reports normalization to `50` but remains effectively `100`, and only a value above `100` raises the cap. | +| `OCR_MAX_TOOLS` | Example pipeline / OCR CLI | No | `0` | OCR 1.11.3 uses template default `100`; `1-49` reports normalization to `50` but remains effectively `100`, and only a value above `100` raises the cap. | | `OCR_MAX_TOKENS_BUDGET` | Example pipeline / OCR CLI | No | `0` | Non-negative aggregate OCR token ceiling; `0` is unlimited. | ## Dynamic adapter and MCP inputs @@ -136,13 +136,13 @@ Generic external MCP is privileged operator configuration, not safe author-trigg Use direct composition only for reviewed narrow read-only tools, dedicated least-privilege credentials, server-enforced resource authorization, bounded server responses, and data acceptable for both model egress and OCR-session retention. A direct tool must be safe in both phases. Do not expose generic search, arbitrary URL/ID fetch, recursive traversal, writes, or broad service credentials to references that merge-request text can select. Treat command, endpoint, environment, headers, setup, descriptions, schemas, arguments, and responses according to their separate executable or untrusted boundaries. Local-profile `setup` runs as operator-owned shell configuration in the analyzed repository; keep it empty unless explicitly reviewed. Do not expose raw endpoint, setup, or credential values through toolkit diagnostics; OCR may emit operator-owned setup or transport details, so keep those values non-sensitive and retain OCR stderr privately. -An unavailable optional server or tool/protocol error can degrade while OCR continues. Check the private OCR stderr and result rather than assuming configured context was used. Receipt v7 stores the complete bounded configured capability inventory (server, `builtin|stdio|remote` transport, and allowlisted tool names) plus positive per-server use counts. It never stores commands, URLs, headers, setup, arguments, results, or repository/provider content. A count proves only that OCR recorded a call; it does not prove object authorization, completeness, content safety, response use, or correct model judgment. The mandatory evidence MCP remains independently required. Every configured direct external MCP makes a protected-target review comment-only even when unused and is rejected entirely for an actually unprotected target; server-authored tool annotations are not an enforceable same-session read-only guarantee. Direct external MCP remains privileged operator configuration. M5 external records use the separate toolkit-owned broker described in [Bounded review context](review-context.md), so provider schemas and arbitrary arguments never enter OCR. +An unavailable optional server or tool/protocol error can degrade while OCR continues. Check the private OCR stderr and result rather than assuming configured context was used. Receipt v8 stores the complete bounded configured capability inventory (server, `builtin|stdio|remote` transport, and allowlisted tool names) plus positive per-server use counts. It never stores commands, URLs, headers, setup, arguments, results, or repository/provider content. A count proves only that OCR recorded a call; it does not prove object authorization, completeness, content safety, response use, or correct model judgment. The mandatory evidence MCP remains independently required. Every configured direct external MCP makes a protected-target review comment-only even when unused and is rejected entirely for an actually unprotected target; server-authored tool annotations are not an enforceable same-session read-only guarantee. Direct external MCP remains privileged operator configuration. M5 external records use the separate toolkit-owned broker described in [Bounded review context](review-context.md), so provider schemas and arbitrary arguments never enter OCR. ### GitLab target-protection selector `OCR_GITLAB_TARGET_PROTECTION_MODE` is operator-owned and parsed before policy acquisition or OCR execution. If it is unset, `required` preserves the secure default: GitLab must report the captured target branch as protected. The exact opt-in `unprotected` permits an unprotected target; it does not force limited behavior when GitLab reports that target as protected. Explicit empty strings and every value other than exact lowercase `required` or `unprotected` fail closed. -When the opt-in meets an actually unprotected target, the toolkit binds the exact source SHA, target SHA, and `unprotected` state in receipt v7 and enforces a constrained review. Context `off` and bounded untrusted `metadata` are allowed. `enriched`, any configured `OCR_REVIEW_CONTEXT_ADAPTERS_JSON` value (including an empty value or `[]`), protected-policy discussion/remediation/CI acquisition, direct external MCP, and inherited external OCR MCP configuration are rejected before OCR. The toolkit-owned immutable repository-evidence MCP remains mandatory. Repository Rules are required and are read only as bounded untrusted model guidance from the exact captured target SHA; source Rules cannot authorize their own review. Structured target guidance and accepted decisions are omitted. No target-derived input can enable tools, external acquisition, suppression, posting authority, or approval. +When the opt-in meets an actually unprotected target, the toolkit binds the exact source SHA, target SHA, and `unprotected` state in receipt v8 and enforces a constrained review. Context `off` and bounded untrusted `metadata` are allowed. `enriched`, any configured `OCR_REVIEW_CONTEXT_ADAPTERS_JSON` value (including an empty value or `[]`), protected-policy discussion/remediation/CI acquisition, direct external MCP, and inherited external OCR MCP configuration are rejected before OCR. The toolkit-owned immutable repository-evidence MCP remains mandatory. Repository Rules are required and are read only as bounded untrusted model guidance from the exact captured target SHA; source Rules cannot authorize their own review. Structured target guidance and accepted decisions are omitted. No target-derived input can enable tools, external acquisition, suppression, posting authority, or approval. An unprotected receipt is structurally automatic-approval-ineligible regardless of `OCR_AUTO_APPROVE`; the approval executor and GitLab approval endpoint are not reached. A fully validated receipt alone adds *The target branch was not protected in GitLab. This review ran in limited, comment-only mode.* immediately after the normal primary status line. The line does not make complete coverage partial and does not alter clean, findings, warning, partial, budget-stopped, failed, or publication-filtered outcome semantics. A protected target running under the permissive setting keeps normal protected-policy behavior and does not show the line. Missing, malformed, legacy, or contradictory receipt state cannot authorize it. @@ -177,7 +177,7 @@ finish and OCR accounts the provider-reported input plus output tokens. `OCR_MAX_TOOLS` is independent of all three token controls. Leave the example default at `0` so OCR uses its embedded template limit of `100` per subtask. -OCR 1.11.2 reports values `1-49` as normalized to the minimum `50`, but both +OCR 1.11.3 reports values `1-49` as normalized to the minimum `50`, but both that target and explicit `50` remain below the template default and therefore remain effectively `100`. Use `101` or greater only when deliberately raising the loop cap. OCR 1.11.0 corrected stale help text for this already-qualified @@ -217,15 +217,15 @@ The initial policy is fixed: zero findings, or at most three findings whose severity is exactly `low` and category is exactly `style`, `documentation`, or `maintainability`, are eligible. Missing, unknown, differently cased, or non-string metadata blocks approval, as do warnings, failed or waived coverage, -partial/budget outcomes, any receipt other than v7, an unprotected target, degraded selected metadata, any configured direct external MCP, required context degradation, a DLP-rejected selected source, admitted remediation context, -and findings omitted by `OCR_MAX_POST_COMMENTS`. For receipt v7 on a protected target, complete metadata, complete non-remediation enrichment, private-only sanitization, and the built-in evidence/context MCP are not blockers. GitLab posting also revalidates the receipt-bound source SHA and author ID, and skips without writing when the author changed or the toolkit user authored the merge request. There are intentionally no +partial/budget outcomes, any receipt other than v8, an unprotected target, degraded selected metadata, any configured direct external MCP, required context degradation, a DLP-rejected selected source, admitted remediation context, +and findings omitted by `OCR_MAX_POST_COMMENTS`. For receipt v8 on a protected target, complete metadata, complete non-remediation enrichment, private-only sanitization, and the built-in evidence/context MCP are not blockers. GitLab posting also revalidates the receipt-bound source SHA and author ID, and skips without writing when the author changed or the toolkit user authored the merge request. There are intentionally no environment variables for policy thresholds or category lists in this release. -`ocr-ci review --result PATH --stderr PATH -- ...` executes OCR without posting, creates private artifacts, and prints a bounded redacted stderr excerpt to the CI log when OCR fails. It accepts only a regular, single-link result artifact and, after a successful ordinary OCR process, atomically replaces that artifact with an owner-only DLP-checked copy. A validated GitLab merge-request review receives receipt v7; a local review has no actual GitLab target-protection fact, so it remains receipt-less rather than claiming `protected` or emitting an invalid receipt. The compatible direct-posting path can consume a genuinely receipt-less result but cannot use receipt-derived MCP status, the unprotected-target limitation, or automatic approval. For a local diagnosis only, `--preserve-private-artifacts` retains the owner-only isolated OCR home and repository-local review artifacts and leaves the OCR result without a posting receipt. It also writes `.review-context/private-dlp-decisions.json` using schema `ocr.private-dlp-decisions/v1`: up to 1,000 rejected keys or values have only their bounded JSON path, scope/action, public reason, detector subtype, value type, character/byte/line counts, and SHA-256; `truncated` plus `omitted_decisions` report any remaining decisions. The sidecar never contains the rejected value, replaces unsafe key names with ``, collapses excessive path depth to ``, and can correlate repeated technical values by digest so a local operator can distinguish likely false positives from genuine PII, secret, limit, or laundering detections. The retained result and other paths can still contain repository, provider, model, tool-argument, tool-result, and credential-adjacent data: inspect them locally, never upload or post them, and delete them after diagnosis. Ordinary execution removes a stale sidecar and never creates a new one. A validated GitLab merge-request profile rejects this flag before OCR execution; CI detection variables do not authorize it. `OCR_POST_ERROR_DETAILS=1` separately opts into including the same safe stderr excerpt in the GitLab failure note; leave it unset when diagnostics should remain runner-only. +`ocr-ci review --result PATH --stderr PATH -- ...` executes OCR without posting, creates private artifacts, and prints a bounded redacted stderr excerpt to the CI log when OCR fails. It accepts only a regular, single-link result artifact and, after a successful ordinary OCR process, atomically replaces that artifact with an owner-only DLP-checked copy. A validated GitLab merge-request review receives receipt v8; a local review has no actual GitLab target-protection fact, so it remains receipt-less rather than claiming `protected` or emitting an invalid receipt. The compatible direct-posting path can consume a genuinely receipt-less result but cannot use receipt-derived MCP status, the unprotected-target limitation, or automatic approval. For a local diagnosis only, `--preserve-private-artifacts` retains the owner-only isolated OCR home and repository-local review artifacts and leaves the OCR result without a posting receipt. It also writes `.review-context/private-dlp-decisions.json` using schema `ocr.private-dlp-decisions/v1`: up to 1,000 rejected keys or values have only their bounded JSON path, scope/action, public reason, detector subtype, value type, character/byte/line counts, and SHA-256; `truncated` plus `omitted_decisions` report any remaining decisions. The sidecar never contains the rejected value, replaces unsafe key names with ``, collapses excessive path depth to ``, and can correlate repeated technical values by digest so a local operator can distinguish likely false positives from genuine PII, secret, limit, or laundering detections. The retained result and other paths can still contain repository, provider, model, tool-argument, tool-result, and credential-adjacent data: inspect them locally, never upload or post them, and delete them after diagnosis. Ordinary execution removes a stale sidecar and never creates a new one. A validated GitLab merge-request profile rejects this flag before OCR execution; CI detection variables do not authorize it. `OCR_POST_ERROR_DETAILS=1` separately opts into including the same safe stderr excerpt in the GitLab failure note; leave it unset when diagnostics should remain runner-only. ## Repository evidence -`ocr-ci review` owns this lifecycle. Before OCR starts it collects the exact immutable `--from`/`--to` refs (or the parent/commit pair selected by `--commit`), writes bounded redacted schema-versioned evidence, builds OCR's MCP registry with the mandatory evidence entry plus each independently configured optional server, reads the registry back, self-queries the evidence summary/list/get contract, and supplies the matching compact bootstrap to OCR. Those parent-process preflight calls are not counted as model use. The same preflight-qualified OCR executable first receives the exact production refs, rules, selection options, and background under `review --preview`; there is no toolkit threshold setting or duplicated OCR threshold constant. An exact recognized OCR soft background diagnostic becomes a toolkit-authored numeric `ocr.toolkit-advisory/v1` value only after publication DLP and appears in the bounded CI log plus GitLab Technical details. It is not an OCR warning, receipt or DLP input, coverage signal, telemetry field, or approval blocker. An exact recognized hard character/file-size rejection stops before model execution and leaves only an identity-bound numeric `ocr.pre-execution-status/v2` outcome for static GitLab reporting; the private path and raw OCR diagnostic are not published. Unknown preview failures fail closed through the generic diagnostic path, and the actual review independently revalidates the background. During OCR, the built-in MCP records count-only attempted and completed `summary`, `list`, `get`, `search`, and `coverage` actions without arguments, queries, IDs, paths, results, or content. Unknown or malformed MCP-dispatched primary-tool actions increment only a closed `unattributed` attempt counter. OCR also counts a dynamic tool request before parsing its JSON arguments; a parse failure never reaches the MCP owner, so receipt finalization adds only that count-only by-tool residual to `unattributed`. The parent reads and removes private action receipt v3 before cleanup. Receipt v7 is approval-valid only when MCP-received attempts do not exceed OCR's authoritative `tool_calls.by_tool` entries and every residual OCR attempt is accounted as unattributed; failed or malformed attempts cannot satisfy the mandatory completed `summary`, become successful evidence use, or authorize approval. Missing, malformed, raced, or mismatched attribution fails review finalization before a normal publishable result exists. Since OCR 1.9.9, `--background-file` takes precedence over inline `--background`, so `ocr-ci review` rejects caller forms of both options, including split and `--option=value` syntax, and remains the sole owner of the bootstrap input; caller `--preview` is likewise rejected because the toolkit owns this gate. OCR 1.10.0 adds `--output`, but `ocr-ci review` rejects its long, equals, short, and attached forms because the toolkit must remain the sole owner of the private result descriptor, atomic parsing, cleanup, and posting handoff. The 1.9.10 stage-grouped terminal retry report remains private and does not enter toolkit telemetry, receipts, DLP, findings, severity, outcomes, or approval. A completed OCR review is accepted only when structured `tool_calls.by_tool` proves at least one `ocr_toolkit_evidence` attempt and action receipt v3 proves a completed summary; a legitimately skipped no-supported-files review remains exempt. +`ocr-ci review` owns this lifecycle. Before OCR starts it collects the exact immutable `--from`/`--to` refs (or the parent/commit pair selected by `--commit`), writes bounded redacted schema-versioned evidence, builds OCR's MCP registry with the mandatory evidence entry plus each independently configured optional server, reads the registry back, self-queries the evidence summary/list/get contract, and supplies the matching compact bootstrap to OCR. Those parent-process preflight calls are not counted as model use. The same preflight-qualified OCR executable first receives the exact production refs, rules, selection options, and background under `review --preview`; there is no toolkit threshold setting or duplicated OCR threshold constant. An exact recognized OCR soft background diagnostic becomes a toolkit-authored numeric `ocr.toolkit-advisory/v1` value only after publication DLP and appears in the bounded CI log plus GitLab Technical details. It is not an OCR warning, receipt or DLP input, coverage signal, telemetry field, or approval blocker. An exact recognized hard character/file-size rejection stops before model execution and leaves only an identity-bound numeric `ocr.pre-execution-status/v2` outcome for static GitLab reporting; the private path and raw OCR diagnostic are not published. Unknown preview failures fail closed through the generic diagnostic path, and the actual review independently revalidates the background. During OCR, the built-in MCP records count-only attempted and completed `summary`, `list`, `get`, `search`, and `coverage` actions without arguments, queries, IDs, paths, results, or content. Unknown or malformed MCP-dispatched primary-tool actions increment only a closed `unattributed` attempt counter. OCR also counts a dynamic tool request before parsing its JSON arguments; a parse failure never reaches the MCP owner, so receipt finalization adds only that count-only by-tool residual to `unattributed`. The parent reads and removes private action receipt v3 before cleanup. Receipt v8 is approval-valid only when MCP-received attempts do not exceed OCR's authoritative `tool_calls.by_tool` entries and every residual OCR attempt is accounted as unattributed; failed or malformed attempts cannot satisfy the mandatory completed `summary`, become successful evidence use, or authorize approval. Missing, malformed, raced, or mismatched attribution fails review finalization before a normal publishable result exists. Since OCR 1.9.9, `--background-file` takes precedence over inline `--background`, so `ocr-ci review` rejects caller forms of both options, including split and `--option=value` syntax, and remains the sole owner of the bootstrap input; caller `--preview` is likewise rejected because the toolkit owns this gate. OCR 1.10.0 adds `--output`, but `ocr-ci review` rejects its long, equals, short, and attached forms because the toolkit must remain the sole owner of the private result descriptor, atomic parsing, cleanup, and posting handoff. The 1.9.10 stage-grouped terminal retry report remains private and does not enter toolkit telemetry, receipts, DLP, findings, severity, outcomes, or approval. A completed OCR review is accepted only when structured `tool_calls.by_tool` proves at least one `ocr_toolkit_evidence` attempt and action receipt v3 proves a completed summary; a legitimately skipped no-supported-files review remains exempt. The private `.review-context/evidence.json`, `.review-context/bootstrap.md`, repository-policy `.review-context/policy-rules.json`, and count-only evidence-action receipt/lock are internal implementation details, not public path configuration. Keep `.review-context/` ignored. The directory is mode `0700`, regular files are mode `0600`, and symlink, hard-link, non-regular, or unexpectedly permissive receipt targets are rejected. In GitLab MR pipelines, the provider adapter captures the current protected target SHA, fetches that exact immutable object when needed, and materializes only an in-repository `--rule` blob from it; explicit absolute rules outside the repository remain operator-owned. OCR still reviews the original forge diff-base-to-source-head range. The collector reads Git objects without checkout, does not follow repository symlinks or submodules, never executes repository content, and treats source-ref policy changes as untrusted. @@ -241,7 +241,7 @@ Evidence-store schema v4 retains v1-v3 readback and adds a distinct immutable po In `metadata` mode, GitLab MR acquisition normalizes only title, description, labels, optional source branch, and the reviewed source SHA into `review.merge_request_context/v1`. Values are complete-field bounded, NFC-normalized, control-stripped, redacted, source-head-bound invocation data. Raw values never enter bootstrap, argv, environment, diagnostics, or receipts; bootstrap lists only field statuses and toolkit-authored comparison guidance. In `off` mode none of those mutable text fields reaches normalization or persistence. OCR may treat matching intent as evidence against an assumption-dependent concern, contradictory intent as mismatch evidence, and missing intent as unknown. The source-branch hint is weaker than an explicit description and cannot establish rollout intent by itself. Metadata cannot authorize tools, policy, suppression, posting, or approval. In `enriched`, references are extracted only from admitted metadata and admitted discussion bodies; adapters authorize them before local handle minting. There is no generic URL, identifier, search, or provider-tool path in the model loop. -For a validated GitLab merge-request profile, the review step writes exact closed receipt v7 inside the private result only after cleanup and the inode-checked atomic publication transformation. It binds source, policy, and target SHA, the actual closed `protected|unprotected` target state, merge-request author ID, context mode/state/classes, per-source completeness/degradation, admitted-mutable state, the complete bounded MCP capability inventory, positive known-server and fixed tool usage, verified action-receipt v3 attempted/completed counts, mandatory-evidence state, publication-DLP result, and cleanup result. The current closed states are `passed`, `private-sanitized`, and `publication-filtered`. A pure canonical projection covers the normalized outcome/message, ordered allowlisted finding fields and warnings, manifest coverage/failure details, displayed tool counters, normalized token telemetry, omission/completeness, and approval inputs. Token telemetry has a closed provider-neutral vocabulary: input, output, cached as a subset of input, reasoning as a subset of output, optional validated total, and mathematically derived other; malformed or contradictory telemetry is unavailable and unknown provider keys are never published. Private sanitization may retain the original complete result and continue through existing approval gates only when that projection is byte-equivalent before and after sanitization. Any changed, malformed, or incomparable projection becomes a safe `publication-filtered` result with closed retained/omitted/original counts; it preserves the previous review and cannot authorize approval. The public projection may be incomplete while its validated original coverage counts still prove a complete OCR run, so the GitLab summary reports those dimensions separately and never feeds filtered warnings into legacy failed-item inference. Horizontal tab is admitted only in the closed `existing_code` and `suggestion_code` finding fields and remains subject to every non-control DLP check; tabs in all other fields and every other control/format character remain invalid. Receipt v1-v6 has no posting or approval compatibility. A present incomplete or invalid v7 receipt is rejected before previous review state is read or normal findings are published; only a genuinely absent receipt retains the compatible direct-posting path. The later GitLab posting step reads a complete valid v7 receipt instead of rebuilding context or MCP facts from a possibly changed environment. Its summary exposes only reconciled non-zero numeric server/action counts; the receipt/event never stores search queries, coverage scopes, rejected text/locations, provider/context text, upstream IDs, server URLs, commands, setup, arguments, headers, tool inputs/results, credentials, or repository contents. +For a validated GitLab merge-request profile, the review step writes exact closed receipt v8 inside the private result only after cleanup and the inode-checked atomic publication transformation. It binds source, policy, and target SHA, the actual closed `protected|unprotected` target state, merge-request author ID, context mode/state/classes, per-source completeness/degradation, admitted-mutable state, the complete bounded MCP capability inventory, positive known-server and fixed tool usage, verified action-receipt v3 attempted/completed counts, mandatory-evidence state, publication-DLP result, and cleanup result. The current closed states are `passed`, `private-sanitized`, and `publication-filtered`. A pure canonical projection covers the normalized outcome/message, ordered allowlisted finding fields and warnings, manifest coverage/failure details, displayed tool counters, normalized token telemetry, omission/completeness, and approval inputs. Token telemetry has a closed provider-neutral vocabulary: input, output, cached as a subset of input, reasoning as a subset of output, optional validated total, and mathematically derived other; malformed or contradictory telemetry is unavailable and unknown provider keys are never published. Private sanitization may retain the original complete result and continue through existing approval gates only when that projection is byte-equivalent before and after sanitization. Any changed, malformed, or incomparable projection becomes a safe `publication-filtered` result with closed retained/omitted/original counts; it preserves the previous review and cannot authorize approval. The public projection may be incomplete while its validated original coverage counts still prove a complete OCR run, so the GitLab summary reports those dimensions separately and never feeds filtered warnings into legacy failed-item inference. Horizontal tab is admitted only in the closed `existing_code` and `suggestion_code` finding fields and remains subject to every non-control DLP check; tabs in all other fields and every other control/format character remain invalid. Receipt v1-v7 has no posting or approval compatibility. A present incomplete or invalid v8 receipt is rejected before previous review state is read or normal findings are published; only a genuinely absent receipt retains the compatible direct-posting path. The later GitLab posting step reads a complete valid v8 receipt instead of rebuilding context or MCP facts from a possibly changed environment. Its summary exposes only reconciled non-zero numeric server/action counts; the receipt/event never stores search queries, coverage scopes, rejected text/locations, provider/context text, upstream IDs, server URLs, commands, setup, arguments, headers, tool inputs/results, credentials, or repository contents. ### Accepted project decisions diff --git a/docs/engineering/m5_context_contracts.md b/docs/engineering/m5_context_contracts.md index 95eb6d5..2f743a4 100644 --- a/docs/engineering/m5_context_contracts.md +++ b/docs/engineering/m5_context_contracts.md @@ -101,11 +101,11 @@ The bootstrap requires a model-recorded `ocr_toolkit_evidence(action=summary)` c OCR runs under a fresh owner-only isolated `HOME` containing only toolkit-validated OCR configuration/composition. One exact resolved executable from an absolute search-path entry, outside the reviewed repository, first receives one no-LLM preview and, only after background acceptance, one review with the same immutable range and selection/background inputs. Context acquisition finishes before preview/model execution; adapters and forge network paths are unavailable in the model loop. Ordinary runs remove preview output, the home, context store, adapter scratch space, and OCR session symlink-safely after success, failure, or interruption. Cleanup uncertainty makes the run non-publishable. An explicit local diagnostic may retain owner-only session/context state, but it creates no posting receipt; the authoritative GitLab MR profile rejects that exception before OCR starts and follows ordinary cleanup. -Publication validation runs after OCR and cleanup, within the same inode-checked atomic read/replace that attaches receipt v7. It compares both decoded source and rendered approximations against whole forbidden/non-publishable values and normalized contiguous excerpts of at least 24 characters. Closed checks cover nested HTML entities, comments/tags, inline/reference/autolink Markdown destinations, escapes/formatting, configured secrets, formatted-phone/email patterns, controls, and Unicode deception. Bare SHAs, build identifiers, and unformatted digit strings are not classified as phone numbers. A comparison that would exceed the fixed work bound is uncertainty. +Publication validation runs after OCR and cleanup, within the same inode-checked atomic read/replace that attaches receipt v8. It compares both decoded source and rendered approximations against whole forbidden/non-publishable values and normalized contiguous excerpts of at least 24 characters. Closed checks cover nested HTML entities, comments/tags, inline/reference/autolink Markdown destinations, escapes/formatting, configured secrets, formatted-phone/email patterns, controls, and Unicode deception. Bare SHAs, build identifiers, and unformatted digit strings are not classified as phone numbers. A comparison that would exceed the fixed work bound is uncertainty. The result has two explicit DLP projections. Publication sinks are exactly values the posting owner may render: outcome message, allowlisted finding fields, warnings, displayed tool names, and manifest-failure path/reason fields. If one is unsafe, the raw result is atomically replaced by an explicit safe filtered result containing independently passed findings/warnings plus closed reason, omission, original-coverage, and tool-use facts. Horizontal tab is admitted as a control character only in `existing_code` and `suggestion_code`; the unchanged code value is retained only after all remaining DLP checks pass. Non-rendered OCR metadata is a separate private-retention projection: unsafe keys are removed and unsafe string values receive stable non-reversible placeholders before the result is retained. When that sanitized result still satisfies the OCR result contract, its original status, manifest, and safe findings remain intact; a required structural-field loss falls back to the filtered form. Neither path retains the rejected value or its location. GitLab may publish the safe result with a filtered signal, while retaining the previous review, consuming prior matching fingerprints one-for-one, replacing only an earlier toolkit setup-pending note on retry, and blocking approval. Validated original coverage kind/counts remain presentation evidence distinct from public-projection completeness; filtered warnings never create legacy coverage failures. This is containment of deterministic exact-output classes, not a claim to detect shorter arbitrary excerpts, arbitrary semantic paraphrase, or undo content already sent to the model. -Receipt schema `ocr.toolkit-receipt/v7` stores only closed source, policy, target, actual target-protection, and author identities; context mode, per-source completeness and degradation counts; admitted-mutable state; fixed tool usage with complete action-receipt-v3 attempted/completed accounting; publication-DLP result; and cleanup result. Policy and target SHA are identical because all target-derived material is read from that exact immutable commit, including the constrained Rules-only path. Its publication states are `passed`, `private-sanitized`, and `publication-filtered`. Private sanitization keeps normal approval evaluation only when the canonical publication/approval projection is byte-equivalent; changed, malformed, or incomparable projections become safe partial results. The corresponding closed counts form an `ocr.publication-dlp-signal/v2` GitLab-summary marker and structured log event; neither is a new network telemetry exporter. The receipt/event never stores rejected text or locations, context text, upstream IDs, URLs, commands, arguments, headers, adapter results, personal display data, or transport diagnostics. v1-v6 result receipts are rejected rather than migrated. A fully validated unprotected receipt alone authorizes the static public limitation; it is structurally approval-ineligible. +Receipt schema `ocr.toolkit-receipt/v8` stores only closed source, policy, target, actual target-protection, and author identities; context mode, per-source completeness and degradation counts; admitted-mutable state; fixed tool usage with complete action-receipt-v3 attempted/completed accounting; closed failed-tool diagnostic state and optional aggregate; publication-DLP result; and cleanup result. Policy and target SHA are identical because all target-derived material is read from that exact immutable commit, including the constrained Rules-only path. Its publication states are `passed`, `private-sanitized`, and `publication-filtered`. Private sanitization keeps normal approval evaluation only when the canonical publication/approval projection is byte-equivalent; changed, malformed, or incomparable projections become safe partial results. The corresponding closed counts form an `ocr.publication-dlp-signal/v2` GitLab-summary marker and structured log event; neither is a new network telemetry exporter. The receipt/event never stores rejected text or locations, context text, upstream IDs, URLs, commands, arguments, headers, adapter results, personal display data, or transport diagnostics. v1-v7 result receipts are rejected rather than migrated. A fully validated unprotected receipt alone authorizes the static public limitation; it is structurally approval-ineligible. Schema versions protect serialized trust boundaries; they are not a database-retention promise. The review result crosses from the review process/job to hostile posting readback, so its version prevents an older field set from inheriting newer approval guarantees. Policy and adapter versions similarly bind independent producers/consumers. Ephemeral evidence/context stores accept only their exact current schema and intentionally have no migration or upgrade path. diff --git a/docs/engineering/review_signal_ownership.md b/docs/engineering/review_signal_ownership.md index be310e0..955937c 100644 --- a/docs/engineering/review_signal_ownership.md +++ b/docs/engineering/review_signal_ownership.md @@ -9,14 +9,14 @@ a new telemetry API. | Signal | Authoritative source | Toolkit projection | Privacy and authority boundary | | --- | --- | --- | --- | -| Provider/model identity, request/session correlation, retries, latency, HTTP outcome, and cost | OCR and its configured provider telemetry | One closed provider-failure class for static GitLab text; on failure, at most one local toolkit-authored line may add closed protocol detail, one shared HTTP status, and non-zero bounded retry counters | Raw identities, request IDs, response text, provider codes, URLs, paths, warnings, and stderr do not enter the projection, GitLab notes, receipt v7, DLP signals, toolkit telemetry, or approval. HTTP detail does not prove a provider business cause. | +| Provider/model identity, request/session correlation, retries, latency, HTTP outcome, and cost | OCR and its configured provider telemetry | One closed provider-failure class for static GitLab text; on failure, at most one local toolkit-authored line may add closed protocol detail, one shared HTTP status, and non-zero bounded retry counters | Raw identities, request IDs, response text, provider codes, URLs, paths, warnings, and stderr do not enter the projection, GitLab notes, receipt v8, DLP signals, toolkit telemetry, or approval. HTTP detail does not prove a provider business cause. | | Prompt, completion, cached, reasoning, and total tokens | OCR result and OCR telemetry | Closed non-negative provider-neutral token buckets in the result summary and canonical publication comparison | Unknown fields are ignored; malformed or contradictory counters are unavailable. Token counts never authorize approval or automatic routing. | | Review effort and executed rounds | Operator-owned root `effort` config and OCR runtime | `OCR_REVIEW_EFFORT` writes one closed `low`, `medium`, or `high` setting; the toolkit does not publish inferred round telemetry | Merge-request content cannot select effort. Budget or incomplete coverage remains approval-ineligible through the existing result contract. | -| Semantic grouping, group file membership, per-group spans, and filter activity | OCR runtime and OCR telemetry | Additive private result fields may be DLP-sanitized; no group or round field enters receipt v7, GitLab text, fingerprints, severity, lifecycle commands, toolkit telemetry, or approval | Group labels are model-produced. Group keys are sorted changed paths, so both are untrusted and potentially high-cardinality. | -| Tool requests and MCP use | OCR result for aggregate tool attempts; each toolkit MCP owner for authenticated local attempts and completions | Bounded known-server attempt counts and mandatory action-receipt-v3 attempted/completed accounting in receipt v7; the summary renders only completed named evidence actions | Tool names and counts are closed; unattributed attempts stay private, and arguments, paths, queries, scopes, IDs, results, headers, and content are excluded. | +| Semantic grouping, group file membership, per-group spans, and filter activity | OCR runtime and OCR telemetry | Additive private result fields may be DLP-sanitized; no group or round field enters receipt v8, GitLab text, fingerprints, severity, lifecycle commands, toolkit telemetry, or approval | Group labels are model-produced. Group keys are sorted changed paths, so both are untrusted and potentially high-cardinality. | +| Tool requests and MCP use | OCR result for aggregate tool attempts; each toolkit MCP owner for authenticated local attempts and completions | Bounded known-server attempt counts and mandatory action-receipt-v3 attempted/completed accounting in receipt v8; the summary renders only completed named evidence actions | Tool names and counts are closed; unattributed attempts stay private, and arguments, paths, queries, scopes, IDs, results, headers, and content are excluded. | | Selection, completed/reused/failed/waived coverage, and aggregate-budget stop | OCR run manifest | Closed result outcome, summary, receipt validation, and approval blockers | Incomplete, malformed, failed, waived, or budget-stopped coverage fails closed; no duplicate toolkit budget metric is needed. | | Findings, severity, fingerprints, suppression, resolution, and repeated discussions | OCR findings plus toolkit-owned posting snapshots and human commands | GitLab discussions, summary, exact fingerprints, and closed lifecycle state | Remediation text and additive group metadata cannot change severity, prove resolution, suppress findings, or issue commands. | -| Context admission, degradation, mutation, and evidence use | Toolkit broker/store and fixed MCP receipts | Count-only receipt-v7 context/evidence state | Raw merge-request title, description, discussions, CI provider identities/payloads, rejected text, and record contents do not enter the receipt or telemetry event. | +| Context admission, degradation, mutation, and evidence use | Toolkit broker/store and fixed MCP receipts | Count-only receipt-v8 context/evidence state | Raw merge-request title, description, discussions, CI provider identities/payloads, rejected text, and record contents do not enter the receipt or telemetry event. | | Publication DLP and posting transaction state | Toolkit result projection and GitLab transaction owner | Receipt-v5 publication state, one parseable summary marker, and one local count-only log event | `private-sanitized` can preserve approval only when the canonical projection is unchanged; `publication-filtered` makes the public projection incomplete and blocks approval without relabelling independently complete OCR coverage. No rejected value or location is emitted. | | OCR compatibility qualification | Compatibility workflow and checksum-pinned evidence | Canonical issue plus bounded success or failure artifact | Public failure status contains only closed phase/reason/version/run fields. Raw qualification exceptions stay in the job log. | diff --git a/docs/engineering/test_evidence_matrix.md b/docs/engineering/test_evidence_matrix.md index f7e67ae..a6c0a7d 100644 --- a/docs/engineering/test_evidence_matrix.md +++ b/docs/engineering/test_evidence_matrix.md @@ -140,7 +140,7 @@ The M5 negative suite is an attacker matrix, not merely a replay of observed rev | --- | --- | --- | --- | --- | --- | | One effective reviewed source identity | `review_identity.effective_reviewed_sha` shared by review, posting, and pre-execution status validation | a lowercase 40-hex non-zero MR SHA wins; only an absent or all-zero MR SHA may use the exact `CI_COMMIT_SHA`; malformed populated values, stale receipts, and cross-boundary mismatches fail closed | strict scalar/type/value matrix, detached setup/background status cases, provider diff-ref assertions, and stale/malformed receipt cases in `test_gitlab_provider.py`, `test_posting_helpers.py`, and `test_review_runner.py` | environment and local-TLS fixtures prove deterministic parser and production-provider wiring, not GitLab's truthfulness or runner integrity | proven for deterministic identity selection and binding | | Evidence action receipt cannot be forged or skipped | fixed evidence MCP owner -> owner-only action receipt v3 -> `review_receipt.verified_evidence_actions` -> inode-checked atomic result publication | MCP-received attempts cannot exceed OCR by-tool totals, OCR-preparse residuals become count-only unattributed attempts, and only completed actions establish successful use; missing, malformed, incomplete, unwritable, type-confused, overflowed, unattributed, completion-without-attempt, or per-tool-mismatched action attribution blocks finalization before findings, advisories, or approval; a failed or malformed attempt cannot satisfy the mandatory summary | real local MCP action-receipt writes plus missing/extra-key, old-schema, malformed/failed action, concurrency, bounds, type, per-tool, producer-validation, formatting, and approval regressions in `test_evidence_actions.py`, `test_evidence_mcp.py`, `test_review_runner.py`, `test_posting_helpers.py`, and `test_posting_approval.py` | local protocol and hostile files prove toolkit ownership and aggregate reconciliation; they do not prove model judgment, per-call identity, or an uncompromised same-user host | proven for exact attempt/completion integrity boundary | -| Explicit unprotected target remains constrained and comment-only | `providers.gitlab.acquire_review_snapshot` -> `review_runner` constrained preparation -> receipt-v7 validation -> posting/approval workflow | secure default remains protected-only; explicit unprotected mode accepts only actual unprotected state with `off` or bounded `metadata`, immutable repository evidence, and exact-target Rules; enriched context, adapters, protected policy, external MCP, accepted decisions, structured guidance, and approval mutation remain unreachable | real local HTTPS protection-state matrix, real immutable Git Rules reads, privileged-context/external-MCP rejection ordering, omitted target-policy records, hostile receipt fields, all outcome renderings, and unreachable approval-executor regressions across provider, evidence, runner, posting, and approval tests | controlled GitLab and OCR peers prove deterministic toolkit controls, receipt provenance, and call ordering; they do not establish branch governance, live GitLab merge policy, or semantic finding quality | proven for constrained toolkit path; operational protection remains external | +| Explicit unprotected target remains constrained and comment-only | `providers.gitlab.acquire_review_snapshot` -> `review_runner` constrained preparation -> receipt-v8 validation -> posting/approval workflow | secure default remains protected-only; explicit unprotected mode accepts only actual unprotected state with `off` or bounded `metadata`, immutable repository evidence, and exact-target Rules; enriched context, adapters, protected policy, external MCP, accepted decisions, structured guidance, and approval mutation remain unreachable | real local HTTPS protection-state matrix, real immutable Git Rules reads, privileged-context/external-MCP rejection ordering, omitted target-policy records, hostile receipt fields, all outcome renderings, and unreachable approval-executor regressions across provider, evidence, runner, posting, and approval tests | controlled GitLab and OCR peers prove deterministic toolkit controls, receipt provenance, and call ordering; they do not establish branch governance, live GitLab merge policy, or semantic finding quality | proven for constrained toolkit path; operational protection remains external | | Exact OCR 1.11.2 compatibility contract | compatibility gateway -> hosted Linux binary -> human-reviewed evidence promotion | prior result/manifest/budget/provider contracts remain compatible; `.mjs`/`.cjs` select JavaScript Rules, `.cxx`/`.hxx` select C++ Rules, and `.svh` remains excluded | hosted run 33508349494; checksum-pinned `compatibility/evidence/ocr-1.11.2.json`; independent checksum-verified Darwin arm64 no-LLM qualification | deterministic gateways and rule checks prove consumed structural and selection contracts, not provider-backed review quality | proven for exact qualified OCR contract | ## Complete suite module audit @@ -194,3 +194,12 @@ Every top-level test module is classified below. A module can contain more than The suite intentionally does not perform live GitLab comment, discussion, cleanup, or approval writes; live GitHub issue/release mutations; or PyPI publication. Their tests prove closed payloads, ordering, fail-closed decisions, transport serialization, and receipt parsing. Release completion requires independent live readback as defined in `docs/release.md`. Likewise, a deterministic local LLM gateway proves OCR request/result integration but cannot prove general model judgment. The one OCR 1.9.4 release run exposed an actual MCP argument-shape incompatibility and never read `review.merge_request_context`; its four code findings therefore do not qualify matching, contradictory, absent/ambiguous, or objective-defect intent calibration. Corrected real installed stdio summary/list/get paths prove transport and queryability only. Model-dependent intent calibration remains a named future qualification item; no mock-selected finding can close it. + +### OCR 1.11.3 diagnostic and publication evidence + +| Requirement or boundary | Production owner and entry point | Required observable result | Evidence | Double boundary and claim limit | State | +| --- | --- | --- | --- | --- | --- | +| Exact OCR 1.11.3 compatibility contract | compatibility gateway -> hosted Linux binary -> reviewed evidence promotion | 1.11.2 contracts and ten-extension language probe remain stable; official assets and adjacent release metadata agree | hosted run 33725971286; checksum-pinned `compatibility/evidence/ocr-1.11.3.json`; independent Darwin arm64 checksum/version readback; adjacent source audit | deterministic qualification proves consumed wire, structural, and selection contracts, not provider-backed finding quality | proven for exact qualified OCR contract | +| Raw traffic capture cannot be inherited | review owner -> preview and actual OCR child environments | `OCR_RAW_LOGGING` is absent from both children while other provider configuration remains available | subprocess-boundary regressions in `test_review_runner.py` | child doubles prove exact environment projection; no provider request is made | proven | +| Additive failed-tool diagnostics cannot erase useful review signal | OCR result -> diagnostic parser -> action receipt v3 -> DLP -> receipt v8 -> posting transaction | absent, malformed, hostile, non-zero, and contradictory diagnostics retain valid findings, summary, manifest, and posting; details are bounded redacted console-only data; completed toolkit evidence remains authoritative | hostile parser, local/provider finalization, DLP, formatter, approval, and actual posting-workflow regressions across `test_review_runner.py`, `test_posting_helpers.py`, and `test_posting_approval.py` | synthetic OCR results and mocked/local GitLab writes prove toolkit control flow and payloads, not upstream tool success or live GitLab semantics | proven for deterministic toolkit policy | +| Decision diagrams stay aligned with executable contracts | public documentation -> runtime/test owner comparison | canonical Mermaid flow distinguishes integrity errors from additive warnings and separates publication from later-action eligibility with stable terminal colors | `docs/review-decision-flow.md`, fenced/static structure contract tests, and holistic source review | static diagrams explain implemented decisions; they do not replace runtime validation or a renderer | proven when release gates pass | diff --git a/docs/engineering/toolkit_strategy.md b/docs/engineering/toolkit_strategy.md index d8c3a61..9f673a2 100644 --- a/docs/engineering/toolkit_strategy.md +++ b/docs/engineering/toolkit_strategy.md @@ -1,6 +1,6 @@ # Toolkit Strategy -This document is the durable source of truth for the product and architecture direction of Open Code Review Toolkit. It describes boundaries and intended outcomes, not execution order; see [the roadmap](../../ROADMAP.md) for sequencing and [the backlog](../codex/TASKS_BACKLOG.md) for implementation-ready work. +This document is the durable source of truth for the product and architecture direction of Open Code Review Toolkit. It describes boundaries and intended outcomes, not execution order; see [the roadmap](../../ROADMAP.md) for sequencing, [the backlog](../codex/TASKS_BACKLOG.md) for implementation-ready work, and the [review decision flow](../review-decision-flow.md) for the canonical detailed execution, diagnostic, publication, and later-action branches. The compact Mermaid diagrams below remain architecture overviews and must not duplicate that operational flow. ## Product purpose @@ -37,7 +37,7 @@ The legacy `context/*` Markdown renderer, its CLI/environment contract, and its The built-in evidence MCP is mandatory for ordinary evidence-backed reviews. External stdio and native HTTPS Streamable HTTP servers compose as independent optional entries; replacement mode may discard stale external entries but cannot remove or shadow the built-in server. The compact bootstrap is generated from the same validated capability composition that is written to OCR. -GitLab result normalization and posting are implemented behind provider-oriented modules. They bound and neutralize model-controlled text, use stable finding fingerprints, preserve human-owned discussions, and keep GitLab credentials outside OCR. Receipt v7 separates byte-equivalent private sanitization from publication filtering through one canonical publication/approval projection, reconciles the fixed summary/list/get/search/coverage action counters, and binds the immutable source, target, and actual target-protection state. OCR coverage, public-projection integrity, published findings, core advisories, reconciled count-only evidence use, privacy-safe normalized token buckets, multi-finding Recommended focus ranking, and collapsed technical details are separate implemented concepts. The current recommended and tested OCR baseline belongs in the operational compatibility contract, not this durable strategy. +GitLab result normalization and posting are implemented behind provider-oriented modules. They bound and neutralize model-controlled text, use stable finding fingerprints, preserve human-owned discussions, and keep GitLab credentials outside OCR. Receipt v8 separates byte-equivalent private sanitization from publication filtering through one canonical publication/approval projection, reconciles the fixed summary/list/get/search/coverage action counters, and binds the immutable source, target, and actual target-protection state. OCR coverage, public-projection integrity, published findings, core advisories, reconciled count-only evidence use, privacy-safe normalized token buckets, multi-finding Recommended focus ranking, and collapsed technical details are separate implemented concepts. The current recommended and tested OCR baseline belongs in the operational compatibility contract, not this durable strategy. ## Implemented Repository Evidence Engine @@ -113,7 +113,7 @@ Safe direct composition is therefore limited to reviewed narrow read-only tools, ## Bounded review-context enrichment -M5's foundation is established in v0.7.0. It extends the v0.6.3 selection/approval foundation with protected-target policy, stable GitLab discussions, deterministic references, provider-neutral adapters, a separate private context store, opaque handles, fixed `context_list`/`context_get`, isolated OCR sessions, publication DLP, receipt v4, and closed setup/CI-uncertainty outcomes. Receipt v5 in v0.7.1 superseded the result/posting contract without changing the established acquisition boundary. The v0.8.0 release tree adds policy v2 remediation selection, private context-store v2, authenticated toolkit-root ownership, fixed remediation projections, live-username mention commands, and explicit provider-neutral forge views. Toolkit 0.8.7 advanced output to receipt v6 and action receipt v2 for two additional fixed evidence search/coverage tools without reopening provider access or approval authority. Toolkit 0.9.0 advances the current result boundary to receipt v7, advances the private action receipt to v3 with separately authenticated attempted and completed counters, and adds a separately constrained path for an explicitly permitted unprotected GitLab target. That path is comment-only, rejects enriched context, adapters, protected policy acquisition, and direct or inherited external MCP before OCR, omits accepted decisions and structured project guidance, and exposes only immutable repository evidence plus bounded target Rules. It is not protected-policy equivalence and cannot authorize approval. The owner waived the separate enriched OCR+LLM qualification: no receipt proves model-time `context_list`/`context_get`, still-present/evidence-resolved scenarios, or receipt-level raw provider-data leakage inspection. The protected release workflow and independent registry/GitHub readback remain mandatory delivery proof but do not substitute for that absent qualification. +M5's foundation is established in v0.7.0. It extends the v0.6.3 selection/approval foundation with protected-target policy, stable GitLab discussions, deterministic references, provider-neutral adapters, a separate private context store, opaque handles, fixed `context_list`/`context_get`, isolated OCR sessions, publication DLP, receipt v4, and closed setup/CI-uncertainty outcomes. Receipt v5 in v0.7.1 superseded the result/posting contract without changing the established acquisition boundary. The v0.8.0 release tree adds policy v2 remediation selection, private context-store v2, authenticated toolkit-root ownership, fixed remediation projections, live-username mention commands, and explicit provider-neutral forge views. Toolkit 0.8.7 advanced output to receipt v6 and action receipt v2 for two additional fixed evidence search/coverage tools without reopening provider access or approval authority. Toolkit 0.9.0 advances the current result boundary to receipt v8, advances the private action receipt to v3 with separately authenticated attempted and completed counters, and adds a separately constrained path for an explicitly permitted unprotected GitLab target. That path is comment-only, rejects enriched context, adapters, protected policy acquisition, and direct or inherited external MCP before OCR, omits accepted decisions and structured project guidance, and exposes only immutable repository evidence plus bounded target Rules. It is not protected-policy equivalence and cannot authorize approval. The owner waived the separate enriched OCR+LLM qualification: no receipt proves model-time `context_list`/`context_get`, still-present/evidence-resolved scenarios, or receipt-level raw provider-data leakage inspection. The protected release workflow and independent registry/GitHub readback remain mandatory delivery proof but do not substitute for that absent qualification. For protected targets, the target architecture acquires forge discussion snapshots and deterministic reference candidates before OCR. An immutable `.opencodereview/review-context-policy.json` read only from the captured protected-target SHA independently controls admission, retrieval, model egress, publication, and retention. Recognizers produce candidates but never authorize them. A provider adapter must authorize the exact tenant, canonical object, fields, and operation, retrieve a bounded version-bound projection, apply normalization and DLP, and atomically commit it to a run-local context store before an opaque unguessable handle is minted. Handles bind run, adapter, tenant, canonical object, projection, version or digest, policy version, expiry, and stored record without exposing the upstream identifier. diff --git a/docs/gitlab.md b/docs/gitlab.md index 070873e..2e0f9e5 100644 --- a/docs/gitlab.md +++ b/docs/gitlab.md @@ -18,7 +18,7 @@ The complete variable inventory, owner, requirement, exact default, and behavior The public pipeline stores the OCR binary checksum as the non-secret `OCR_SHA256` pin. Store actual credentials as masked, protected CI variables; do not place their values in YAML, command arguments, repository evidence, or the generated bootstrap. GitLab job tokens are not accepted for posting. -The pipeline explicitly sets `OCR_GITLAB_TARGET_PROTECTION_MODE: "required"`. This is the secure default: an unprotected target is rejected before OCR or model execution, and an explicit empty, malformed, mixed-case, or unknown value also fails closed. Set exact `unprotected` only when the project deliberately permits unprotected targets. That setting does not constrain a target GitLab reports as protected; normal protected behavior remains available. When the actual target is unprotected, receipt v7 enforces limited, comment-only review regardless of `OCR_AUTO_APPROVE`. +The pipeline explicitly sets `OCR_GITLAB_TARGET_PROTECTION_MODE: "required"`. This is the secure default: an unprotected target is rejected before OCR or model execution, and an explicit empty, malformed, mixed-case, or unknown value also fails closed. Set exact `unprotected` only when the project deliberately permits unprotected targets. That setting does not constrain a target GitLab reports as protected; normal protected behavior remains available. When the actual target is unprotected, receipt v8 enforces limited, comment-only review regardless of `OCR_AUTO_APPROVE`. `OCR_REVIEW_LANGUAGE` defaults to `English`; `Russian` is one example of an explicit review language. The example pins its qualified OCR release and sets `OCR_REVIEW_EFFORT=medium`, allowing two review rounds; `low` and `high` explicitly select one or three. OCR may stop early when a round adds no finding. OCR 1.11.0 scales its 15-minute per-subtask base to 15/30/45 minutes for low/medium/high, so the example allows 45 minutes at the GitLab job boundary. `OCR_MAX_TOOLS=0` selects the embedded template default `100`. Values `1-49` report normalization to `50` but remain effectively `100`; explicit `50` also cannot lower the template, and only a value above `100` raises the cap. `OCR_MAX_TOKENS_BUDGET` defaults to `0`, meaning unlimited; a positive budget may stop dispatch and produce an explicitly partial, automatic-approval-ineligible review. `OCR_LLM_MAX_COMPLETION_TOKENS` defaults to unset and separately controls only the provider request's completion/output cap. Select an explicit value only from the deployment's provider/model contract; the toolkit does not recommend or hardcode a provider-specific cap. @@ -73,7 +73,7 @@ The toolkit authenticates the token owner with live `GET /user`. No configured b ## Operating model -`ocr-ci preflight` validates OCR compatibility, GitLab access, and optional model metadata. `ocr-ci configure` writes the isolated OCR configuration. `ocr-ci review` captures the exact source head and target SHA plus the actual protection state, collects immutable repository evidence, acquires only context permitted by that state, applies DLP and budgets, runs OCR once under an owner-only isolated home, validates the result, cleans private state, and attaches receipt v7. `ocr-ci post` hostile-reads that receipt rather than reconstructing configuration from a later environment. +`ocr-ci preflight` validates OCR compatibility, GitLab access, and optional model metadata. `ocr-ci configure` writes the isolated OCR configuration. `ocr-ci review` captures the exact source head and target SHA plus the actual protection state, collects immutable repository evidence, acquires only context permitted by that state, applies DLP and budgets, runs OCR once under an owner-only isolated home, validates the result, cleans private state, and attaches receipt v8. `ocr-ci post` hostile-reads that receipt rather than reconstructing configuration from a later environment. `off` still validates the source SHA, protected target, merge-request author, and live bot identity while withholding mutable MR text. `metadata` admits only bounded DLP-checked title, description, labels, and optional source branch. Treat those fields as claims to compare with the diff, never as instructions, policy, or proof. diff --git a/docs/operations.md b/docs/operations.md index e40163d..694dddd 100644 --- a/docs/operations.md +++ b/docs/operations.md @@ -34,18 +34,18 @@ suppressed. `OCR_MAX_TOKENS_BUDGET` can set an aggregate input-plus-output token ceiling for the OCR diff review. The default `0` is unlimited. A positive ceiling is approximate rather than a hard billing cutoff because already-running work may complete; when it stops further dispatch, completed findings remain publishable and unreviewed files stay explicit as budget-attributed failed coverage. Such a run is partial and cannot automatically approve. -This aggregate budget is separate from both OCR's prompt/context `max_tokens` ceiling and the provider request's completion/output cap. The toolkit does not add an environment alias for OCR's prompt/context control. `OCR_LLM_MAX_COMPLETION_TOKENS` defaults to unset and, when set, overlays only the protocol-specific output field. The inherited OpenAI value was 58,888 in OCR 1.9.10 and is 16,384 in OCR 1.10.0 through 1.11.2, including grouping requests; an explicit toolkit override applies to all requests. A gateway may reserve cost against that requested maximum before generation even when the eventual response would be short. The `/models` capability value does not reveal an account spending limit or reservation policy, so the toolkit never selects the cap from it automatically. Set an explicit cap only from the deployment's provider/model contract when that deployment must not change with the qualified OCR version; the toolkit does not recommend or hardcode a provider-specific value. +This aggregate budget is separate from both OCR's prompt/context `max_tokens` ceiling and the provider request's completion/output cap. The toolkit does not add an environment alias for OCR's prompt/context control. `OCR_LLM_MAX_COMPLETION_TOKENS` defaults to unset and, when set, overlays only the protocol-specific output field. The inherited OpenAI value was 58,888 in OCR 1.9.10 and is 16,384 in OCR 1.10.0 through 1.11.3, including grouping requests; an explicit toolkit override applies to all requests. A gateway may reserve cost against that requested maximum before generation even when the eventual response would be short. The `/models` capability value does not reveal an account spending limit or reservation policy, so the toolkit never selects the cap from it automatically. Set an explicit cap only from the deployment's provider/model contract when that deployment must not change with the qualified OCR version; the toolkit does not recommend or hardcode a provider-specific value. -`OCR_REVIEW_EFFORT=medium` is the toolkit default for the qualified OCR release and permits two review rounds; `low` permits one and `high` permits three. OCR 1.11.2 multiplies its 15-minute subtask base by those rounds, yielding 15/30/45-minute limits; the GitLab example's 45-minute job timeout accommodates the largest preset. This is a review-depth choice, not one of the three token controls. OCR first groups related changed files and may make group-filter requests; additional rounds can add requests, latency, and cost, but may stop early when they add no finding. Below four changed files, OCR 1.11.2 skips the grouping LLM: low total churn is bundled and high churn stays per-file. The published GitLab example separately passes `OCR_MAX_TOOLS=0`, selecting the embedded template default `100`. The CLI reports `1-49` as normalized to `50`, but both that target and explicit `50` remain effectively `100`; only values above `100` raise the cap. OCR 1.11.0 corrects stale help text for the already-qualified behavior; OCR 1.11.2 leaves that runtime loop contract unchanged. Exhausting the effective limit, an aggregate budget stop, or incomplete manifest coverage remains explicit and approval-ineligible; increasing either effort or tools is never a way to hide partial coverage. +`OCR_REVIEW_EFFORT=medium` is the toolkit default for the qualified OCR release and permits two review rounds; `low` permits one and `high` permits three. OCR 1.11.3 multiplies its 15-minute subtask base by those rounds, yielding 15/30/45-minute limits; the GitLab example's 45-minute job timeout accommodates the largest preset. This is a review-depth choice, not one of the three token controls. OCR first groups related changed files and may make group-filter requests; additional rounds can add requests, latency, and cost, but may stop early when they add no finding. Below four changed files, OCR 1.11.3 skips the grouping LLM: low total churn is bundled and high churn stays per-file. The published GitLab example separately passes `OCR_MAX_TOOLS=0`, selecting the embedded template default `100`. The CLI reports `1-49` as normalized to `50`, but both that target and explicit `50` remain effectively `100`; only values above `100` raise the cap. OCR 1.11.0 corrects stale help text for the already-qualified behavior; OCR 1.11.3 leaves that runtime loop contract unchanged. Exhausting the effective limit, an aggregate budget stop, or incomplete manifest coverage remains explicit and approval-ineligible; increasing either effort or tools is never a way to hide partial coverage. The outcome wording distinguishes skipped, complete, complete-with-warnings, incomplete, token-budget, and failed reviews while preserving the finding state in that same line. A complete clean review is visibly positive; a complete review with findings or only reviewer-suppressed findings is neutral; warning, partial, budget, and failed states never look clean. Findings withheld by the posting limit remain counted even when the limit allows no individual finding note. Recommended focus areas ranks only its copy of already-published findings by the closed severity, category, safe repository location, and stable-identity order before its existing display cap; inline and fallback discussion order, suppression, counts, security focus, and approval policy remain unchanged. OCR 1.8.5 and later manifest failures provide the canonical failed-file receipt; legacy warnings are a bounded fallback, and `summary.files_reviewed` is never treated as proof of successful coverage. Technical details label the aggregate as all OCR tool calls and retain the existing inline format while listing every admitted non-zero count for the closed native/context/evidence review set. An empty admitted list produces no tool-call line. The counts describe review activity, not per-tool token consumption: one read or search can return a different amount of context from another. Dynamic external tool names remain private; reconciled MCP-server attempt totals stay in their separate aggregate, and completed built-in evidence `summary`/`list`/`get` plus dedicated `search`/`coverage` counts appear only after action-receipt v3 accounts for every MCP-received or OCR-preparse attempted call. Failed and malformed attempts remain private, cannot appear as completed evidence, and cannot satisfy the mandatory summary. Zero completed action counters, unattributed attempts, queries, scopes, IDs, token counters, and configured-but-unused MCP servers are omitted. A present but incomplete or invalid receipt is rejected before prior comments are inspected or normal findings are published; absence alone selects the compatible direct-posting path. Token usage renders only validated input/output/cached/reasoning/total/derived-other buckets; malformed or contradictory counters are unavailable and unknown provider keys are not published. Status and aggregate semantic-category emoji are enabled by default and can be disabled together with `OCR_POST_EMOJI=false`; finding labels remain text unless their separate badge mode is enabled. -When a fully validated receipt v7 records an actually unprotected target, the summary adds exactly *The target branch was not protected in GitLab. This review ran in limited, comment-only mode.* immediately after the primary status line. It appears for clean, findings, warnings, partial, budget-stopped, failed-result, and publication-filtered summaries without replacing or reclassifying the canonical outcome. The limitation is a trust and approval boundary, not a coverage result. A protected target does not show it merely because the operator allowed unprotected targets; legacy, malformed, or contradictory receipt state cannot show it as trusted status. +When a fully validated receipt v8 records an actually unprotected target, the summary adds exactly *The target branch was not protected in GitLab. This review ran in limited, comment-only mode.* immediately after the primary status line. It appears for clean, findings, warnings, partial, budget-stopped, failed-result, and publication-filtered summaries without replacing or reclassifying the canonical outcome. The limitation is a trust and approval boundary, not a coverage result. A protected target does not show it merely because the operator allowed unprotected targets; legacy, malformed, or contradictory receipt state cannot show it as trusted status. ## Automatic approval lifecycle `OCR_AUTO_APPROVE=true` is the default. Approval is a separate transaction only -after every current review note publishes. A review is eligible only with exact closed receipt v7 for a protected target, a supported complete manifest, no warnings, failures, waivers, token-budget stop, or omitted findings, no configured direct external MCP, no degraded metadata, no DLP-rejected selected source, no required context degradation, no admitted remediation context, and at most three findings. Receipt v1-v6 is rejected by posting and approval. Receipt v7 binds reviewed source, policy, and target SHA, actual `protected|unprotected` target state, merge-request author ID, context mode/state, bounded configured MCP inventory and positive use, per-source completeness/degradation, admitted-mutable state, fixed context-tool use, mandatory evidence state, publication DLP, and cleanup. An actually unprotected target is structurally ineligible: orchestration does not call the approval executor, regardless of `OCR_AUTO_APPROVE`. The receipt's admitted-mutable state is the comment-only signal for an admitted remediation thread; DLP-clean metadata, generic discussions, protected same-revision CI outcomes, and adapter records do not set it. `private-sanitized` remains eligible only when its canonical publication/approval projection is byte-equivalent; `publication-filtered` is partial and ineligible. Complete `metadata` context, complete non-remediation enrichment, and the built-in evidence/context MCP do not independently block approval on a protected target. Every finding must have +after every current review note publishes. A review is eligible only with exact closed receipt v8 for a protected target, a supported complete manifest, no warnings, failures, waivers, token-budget stop, or omitted findings, no configured direct external MCP, no degraded metadata, no DLP-rejected selected source, no required context degradation, no admitted remediation context, and at most three findings. Receipt v1-v7 is rejected by posting and approval. Receipt v8 binds reviewed source, policy, and target SHA, actual `protected|unprotected` target state, merge-request author ID, context mode/state, bounded configured MCP inventory and positive use, per-source completeness/degradation, admitted-mutable state, fixed context-tool use, mandatory evidence state, publication DLP, and cleanup. An actually unprotected target is structurally ineligible: orchestration does not call the approval executor, regardless of `OCR_AUTO_APPROVE`. The receipt's admitted-mutable state is the comment-only signal for an admitted remediation thread; DLP-clean metadata, generic discussions, protected same-revision CI outcomes, and adapter records do not set it. `private-sanitized` remains eligible only when its canonical publication/approval projection is byte-equivalent; `publication-filtered` is partial and ineligible. Complete `metadata` context, complete non-remediation enrichment, and the built-in evidence/context MCP do not independently block approval on a protected target. Every finding must have severity exactly `low` and category exactly `style`, `documentation`, or `maintainability`. A complete zero-finding review is eligible. Four findings, malformed metadata, or any other severity/category are not eligible. @@ -130,11 +130,11 @@ Suppression checks both the recorded inline position and compatible fingerprints ## OCR diagnostics -Receipt v7 is emitted only for a validated GitLab merge-request profile with a real `protected|unprotected` target observation. An ordinary local review still receives the same result parsing, evidence-action reconciliation, and publication DLP, but remains receipt-less because local Git state cannot establish GitLab protection. The compatible direct-posting path accepts an absent receipt, remains approval-ineligible, and cannot render receipt-derived MCP or target-protection claims; any present malformed or incomplete receipt still fails closed before prior review state is read. +Receipt v8 is emitted only for a validated GitLab merge-request profile with a real `protected|unprotected` target observation. An ordinary local review still receives the same result parsing, evidence-action reconciliation, and publication DLP, but remains receipt-less because local Git state cannot establish GitLab protection. The compatible direct-posting path accepts an absent receipt, remains approval-ineligible, and cannot render receipt-derived MCP or target-protection claims; any present malformed or incomplete receipt still fails closed before prior review state is read. -Run OCR through `ocr-ci review --result PATH --stderr PATH -- ...`. This wrapper does not post to GitLab: it creates private artifacts, acquires enriched context when selected, asks the exact resolved and preflight-qualified OCR executable to preview the production refs/rules/selection/background without an LLM, then runs the model review only if OCR accepts that background. OCR owns the current recommendation and rejection thresholds; the toolkit has no threshold setting. A recognized soft diagnostic is reduced to a toolkit-authored `ocr.toolkit-advisory/v1` enum and two positive character counts. It is attached after publication DLP, rendered only with an exact receipt v7 in Technical details, and does not change warnings, coverage, DLP counts, telemetry, or automatic approval. A recognized hard character/file-size rejection stops before the model and lets `ocr-ci post` publish only a static numeric failure summary; the OCR path and raw diagnostic remain private. Unknown preview failures use the generic fail-closed diagnostic path. The ordinary review still validates the same background, the wrapper validates the complete output, and context/session/configuration data is removed. On an unclassified ordinary failure it prints only a bounded redacted stderr excerpt to the runner log; a classified provider failure keeps that excerpt private. Pass the paths and captured exit code to `ocr-ci post` afterward. Set `OCR_POST_ERROR_DETAILS=1` only when the generic path's safe excerpt should also appear in the merge-request failure note. Cleanup uncertainty blocks result publication. DLP atomically converts unsafe publication output into a safe `completed_with_errors` subset, but sanitizes unsafe private-only result fields without discarding an otherwise valid manifest or finding set. Safe findings are posted, unsafe finding content/warnings and unsafe optional fields are omitted, previous OCR comments remain, and matching prior findings are consumed one-for-one rather than duplicated. Receipt v7 and the `ocr.publication-dlp-signal/v2` marker distinguish `private-sanitized`, where the canonical published and approval-relevant projection is unchanged, from approval-ineligible `publication-filtered`, where the public projection is incomplete even when the receipt proves OCR coverage was complete. Posting uses only the validated original coverage kind and counts for status and Technical details; it never reconstructs or republishes rejected values. Horizontal tabs survive only in `existing_code` and `suggestion_code`; the unchanged values pass the same secret, PII, forbidden-value, laundering, and budget checks, while tabs elsewhere and all other control/format characters remain blocking. The same count-only JSON is logged as `OCR toolkit telemetry event` for optional CI collection/alerting. It is not an OTLP/network exporter and contains no rejected value or location. Never interpret a filtered subset as a full publishable review or as proof that OCR coverage was partial. +Run OCR through `ocr-ci review --result PATH --stderr PATH -- ...`. This wrapper does not post to GitLab: it creates private artifacts, acquires enriched context when selected, asks the exact resolved and preflight-qualified OCR executable to preview the production refs/rules/selection/background without an LLM, then runs the model review only if OCR accepts that background. OCR owns the current recommendation and rejection thresholds; the toolkit has no threshold setting. A recognized soft diagnostic is reduced to a toolkit-authored `ocr.toolkit-advisory/v1` enum and two positive character counts. It is attached after publication DLP, rendered only with an exact receipt v8 in Technical details, and does not change warnings, coverage, DLP counts, telemetry, or automatic approval. A recognized hard character/file-size rejection stops before the model and lets `ocr-ci post` publish only a static numeric failure summary; the OCR path and raw diagnostic remain private. Unknown preview failures use the generic fail-closed diagnostic path. The ordinary review still validates the same background, the wrapper validates the complete output, and context/session/configuration data is removed. On an unclassified ordinary failure it prints only a bounded redacted stderr excerpt to the runner log; a classified provider failure keeps that excerpt private. Pass the paths and captured exit code to `ocr-ci post` afterward. Set `OCR_POST_ERROR_DETAILS=1` only when the generic path's safe excerpt should also appear in the merge-request failure note. Cleanup uncertainty blocks result publication. DLP atomically converts unsafe publication output into a safe `completed_with_errors` subset, but sanitizes unsafe private-only result fields without discarding an otherwise valid manifest or finding set. Safe findings are posted, unsafe finding content/warnings and unsafe optional fields are omitted, previous OCR comments remain, and matching prior findings are consumed one-for-one rather than duplicated. Receipt v8 and the `ocr.publication-dlp-signal/v2` marker distinguish `private-sanitized`, where the canonical published and approval-relevant projection is unchanged, from approval-ineligible `publication-filtered`, where the public projection is incomplete even when the receipt proves OCR coverage was complete. Posting uses only the validated original coverage kind and counts for status and Technical details; it never reconstructs or republishes rejected values. Horizontal tabs survive only in `existing_code` and `suggestion_code`; the unchanged values pass the same secret, PII, forbidden-value, laundering, and budget checks, while tabs elsewhere and all other control/format characters remain blocking. The same count-only JSON is logged as `OCR toolkit telemetry event` for optional CI collection/alerting. It is not an OTLP/network exporter and contains no rejected value or location. Never interpret a filtered subset as a full publishable review or as proof that OCR coverage was partial. -OCR 1.11.2 may add group labels, file membership, grouping-task records, round diagnostics, readable reasoning, and provider-native signed or encrypted replay payloads to its private session data. Safe values remain private; the isolated OCR home is removed before publication. Unknown private-only result fields are sanitized through the existing DLP owner, while any value reaching a canonical finding or warning makes publication incomplete and approval-ineligible. Reasoning, native payloads, and `tool_choice` are deliberately absent from the canonical finding/posting projection, receipt v7, DLP counts, toolkit telemetry, tool/token summary, and approval inputs. If they appear inside receipt v7, the receipt is invalid and approval fails closed. Caller `--output`/`-o` is rejected before preview: only `ocr-ci review --result` owns the result descriptor and posting handoff. +OCR 1.11.3 may add group labels, file membership, grouping-task records, round diagnostics, readable reasoning, and provider-native signed or encrypted replay payloads to its private session data. Safe values remain private; the isolated OCR home is removed before publication. Unknown private-only result fields are sanitized through the existing DLP owner, while any value reaching a canonical finding or warning makes publication incomplete and approval-ineligible. Reasoning, native payloads, and `tool_choice` are deliberately absent from the canonical finding/posting projection, receipt v8, DLP counts, toolkit telemetry, tool/token summary, and approval inputs. If they appear inside receipt v8, the receipt is invalid and approval fails closed. Caller `--output`/`-o` is rejected before preview: only `ocr-ci review --result` owns the result descriptor and posting handoff. When OCR exits nonzero with a valid bounded `ocr.llm-retry-report/v1`, the toolkit reads only its closed error class, failure phase, terminal outcome, HTTP status, and schema counters. It maps those facts to `authentication`, `authorization`, `rate-or-spending-limit`, `overloaded`, `timeout`, `network`, `endpoint-or-model-not-found`, `request-rejected`, `provider-unavailable`, `invalid-response`, `cancelled`, `mixed`, or `unknown`, then writes a completely toolkit-authored note. A runtime `404` remains `endpoint-or-model-not-found` because safely distinguishing the endpoint from the model would require trusting the raw response body. @@ -144,7 +144,7 @@ The `rate-or-spending-limit` note keeps that intentionally broad public reason a Raw provider/model identities, response bodies, error codes and messages, request IDs, paths, warnings, and stderr remain in owner-only private artifacts for a classified provider failure. `OCR_POST_ERROR_DETAILS=1` cannot add them to that note or the numeric line. Normal findings from the failed result are ignored, the previous successful review is preserved, and automatic approval is not attempted. Missing, oversized, malformed, or internally contradictory retry reports keep the existing generic failure path instead of guessing a classification. -For a local diagnosis, add `--preserve-private-artifacts` before the `--` separator. The command retains the isolated OCR home plus repository-local private evidence/context artifacts, prints only their paths, and deliberately leaves the raw OCR result without receipt v7; do not pass that result to `ocr-ci post`. It writes `.review-context/private-dlp-decisions.json` with value-free bounded JSON paths, closed reason and detector subtype, size units, and SHA-256 for up to 1,000 rejected keys/values, plus explicit truncation and omitted-decision counts. Use matching digests to identify one repeated technical value and inspect the retained raw result locally before deciding whether a conservative PII match is a false positive; the sidecar itself is not proof that content is safe. These owner-only files can contain source/provider context, prompts, model responses, tool arguments/results, and generated runtime configuration. Inspect them locally, keep them out of commits and shared artifacts, then delete them after extracting the needed evidence. Ordinary runs do not retain this attribution. The authoritative GitLab merge-request profile rejects the flag before OCR execution and performs normal cleanup; an arbitrary `CI=true` value neither grants nor blocks the local mode. +For a local diagnosis, add `--preserve-private-artifacts` before the `--` separator. The command retains the isolated OCR home plus repository-local private evidence/context artifacts, prints only their paths, and deliberately leaves the raw OCR result without receipt v8; do not pass that result to `ocr-ci post`. It writes `.review-context/private-dlp-decisions.json` with value-free bounded JSON paths, closed reason and detector subtype, size units, and SHA-256 for up to 1,000 rejected keys/values, plus explicit truncation and omitted-decision counts. Use matching digests to identify one repeated technical value and inspect the retained raw result locally before deciding whether a conservative PII match is a false positive; the sidecar itself is not proof that content is safe. These owner-only files can contain source/provider context, prompts, model responses, tool arguments/results, and generated runtime configuration. Inspect them locally, keep them out of commits and shared artifacts, then delete them after extracting the needed evidence. Ordinary runs do not retain this attribution. The authoritative GitLab merge-request profile rejects the flag before OCR execution and performs normal cleanup; an arbitrary `CI=true` value neither grants nor blocks the local mode. Two pre-execution outcomes have narrower static reporting. When the merge request introduces the configured repository-owned OCR rules path and that exact path is absent from both immutable policy-side baselines, `review` verifies only that the source object is a bounded regular blob, writes a closed private status, and stops before OCR. When installed OCR rejects the generated background during preview, the status instead carries the closed character/file-size reason plus actual, limit, and unit. `post` hostile-validates either v2 status against the current source and diff-base identities and renders only toolkit-authored text. A successful retry replaces only an earlier toolkit-owned setup-pending note; background rejection and generic failures never replace previous review findings or summaries. Neither outcome includes the rules/background path or raw stderr and neither trusts repository/provider display text. Malformed, stale, unsafe, unknown, or identity-mismatched state falls back to the generic failure note. `OCR_POST_EMOJI=false` removes the heading emoji; `OCR_POST_ERROR_DETAILS` does not add detail to recognized static outcomes. diff --git a/docs/review-context.md b/docs/review-context.md index 39bbb0a..2923db6 100644 --- a/docs/review-context.md +++ b/docs/review-context.md @@ -12,7 +12,7 @@ Open Code Review Toolkit can enrich one validated forge review with bounded merg `OCR_GITLAB_TARGET_PROTECTION_MODE` is a separate closed selector. Unset `required` rejects an unprotected target before OCR. Exact `unprotected` is an explicit opt-in, but constrains only a target GitLab actually reports unprotected. Such a run allows context `off` or bounded untrusted `metadata`; rejects `enriched`, any adapter setting, discussion/remediation/CI policy acquisition, and direct or inherited external MCP; preserves only the built-in immutable evidence MCP; and omits accepted decisions plus structured project guidance. Required repository Rules come from the exact target SHA as untrusted model guidance and cannot enable a tool, acquisition, suppression, posting permission, or approval. A protected target keeps the normal lifecycle under either valid operator setting. -The protected lifecycle is fixed: capture the protected-target SHA; load policy from that immutable object; acquire and authorize records; normalize, DLP-check, and atomically commit the private context store; run one OCR review in an isolated home; serve only local handles; remove session, adapter, and context artifacts; then validate/project the complete OCR result and attach receipt v7 through one inode-checked atomic replacement. A constrained unprotected run skips protected-policy acquisition and the context store entirely. Receipt v7 binds the exact source SHA, target/policy SHA, and actual protection state; unprotected is structurally approval-ineligible. A cleanup or publication-validation failure blocks ordinary result publication. +The protected lifecycle is fixed: capture the protected-target SHA; load policy from that immutable object; acquire and authorize records; normalize, DLP-check, and atomically commit the private context store; run one OCR review in an isolated home; serve only local handles; remove session, adapter, and context artifacts; then validate/project the complete OCR result and attach receipt v8 through one inode-checked atomic replacement. A constrained unprotected run skips protected-policy acquisition and the context store entirely. Receipt v8 binds the exact source SHA, target/policy SHA, and actual protection state; unprotected is structurally approval-ineligible. A cleanup or publication-validation failure blocks ordinary result publication. ## Protected-target policy @@ -129,7 +129,7 @@ Candidates are extracted only from admitted merge-request metadata and admitted Projection fields are sorted unique lists. `model`, `publish`, and `retain` must each be subsets of `retrieve`. Retention is limited to `state`, `count`, `digest`, `version`, and `expiry`; it cannot retain text, upstream identifiers, URLs, commands, transport data, or personal display data. Retrieval, model egress, publication, and retention are deliberately separate decisions. -Policy `ocr.review-context-policy/v1` remains accepted for existing protected configurations and supports aggregate budgets, `forge_discussions`, and references. Policy `ocr.review-context-policy/v2` adds optional `remediation_threads`. Policy `ocr.review-context-policy/v3` adds optional protected `ci_outcomes`; v1/v2 reject that selector instead of interpreting it with weaker semantics. New CI examples use v3 while existing discussion/remediation examples remain valid v2. This compatibility is for reviewed policy documents, not persisted runtime state: reviews and stores are ephemeral, and the private store accepts only `ocr.context-store/v2`. Adapter frames and receipt v7 likewise require their exact schema. Discriminators prevent an old or different field set from inheriting current authorization or approval meaning; there is no store or receipt migration path. +Policy `ocr.review-context-policy/v1` remains accepted for existing protected configurations and supports aggregate budgets, `forge_discussions`, and references. Policy `ocr.review-context-policy/v2` adds optional `remediation_threads`. Policy `ocr.review-context-policy/v3` adds optional protected `ci_outcomes`; v1/v2 reject that selector instead of interpreting it with weaker semantics. New CI examples use v3 while existing discussion/remediation examples remain valid v2. This compatibility is for reviewed policy documents, not persisted runtime state: reviews and stores are ephemeral, and the private store accepts only `ocr.context-store/v2`. Adapter frames and receipt v8 likewise require their exact schema. Discriminators prevent an old or different field set from inheriting current authorization or approval meaning; there is no store or receipt migration path. ## Operator adapter allowlist @@ -199,11 +199,11 @@ Both tools read the already committed local store. They have no network, subproc After OCR exits, the toolkit applies separate publication-sink and private-retention projections against forbidden/non-publishable context, configured secrets, closed PII patterns, controls, Markdown destinations, and Unicode/HTML/Markdown laundering forms. Publication sinks are exactly the result values the posting owner can render: finding fields, warnings, outcome message, displayed tool names, and manifest failure path/reason fields. Other OCR metadata remains private but is independently scanned and sanitized before persistence; it cannot make safe publication sinks partial merely because an opaque SHA, UUID, item identity, or bare build number resembles a phone number. The conservative detector can still classify a separator-bearing technical identifier as phone-like. Ordinary receipts intentionally expose only closed aggregate counts; an explicit local preservation run adds a private value-free path/subtype/size/hash decision sidecar so the operator can diagnose that false-positive class against the separately retained raw result without weakening the filter or disclosing the value. The checks compare whole values and normalized contiguous excerpts of at least 24 characters; a work-bound uncertainty filters or sanitizes the affected unit. This does not claim detection of shorter arbitrary excerpts or semantic paraphrases, and it cannot reverse data already sent to the model. -An unsafe result is neither retained raw nor discarded wholesale. In the same inode-checked atomic transformation used to attach the receipt, unsafe publication content produces a safe `completed_with_errors` projection with independently passed finding/warning fields, fixed tool-use counters, original closed coverage counts, and closed DLP reason/retained/omitted counts. Unsafe `content` removes its finding; an unsafe optional finding field is removed without discarding safe finding content. Horizontal tab is permitted only in `existing_code` and `suggestion_code`, where the unchanged value still passes every secret, PII, forbidden-value, laundering, and budget detector before it is retained. When every publication sink is safe and only private result metadata fails retention DLP, the unsafe private fields are replaced with static non-sensitive values while the original valid status, manifest, warnings, and findings remain. Unsafe values and their locations are never retained. Receipt v7 calls this `private-sanitized` only when a pure canonical publication/approval projection is byte-equivalent before and after sanitization; normal approval evaluation then applies. Any changed, malformed, or incomparable projection is `publication-filtered`, preserves the previous review, emits only closed counts, and cannot automatically approve. Its public projection is incomplete, but posting uses the validated original coverage kind/counts so complete OCR coverage is not relabelled as partial and filtered warnings cannot synthesize failed items. +An unsafe result is neither retained raw nor discarded wholesale. In the same inode-checked atomic transformation used to attach the receipt, unsafe publication content produces a safe `completed_with_errors` projection with independently passed finding/warning fields, fixed tool-use counters, original closed coverage counts, and closed DLP reason/retained/omitted counts. Unsafe `content` removes its finding; an unsafe optional finding field is removed without discarding safe finding content. Horizontal tab is permitted only in `existing_code` and `suggestion_code`, where the unchanged value still passes every secret, PII, forbidden-value, laundering, and budget detector before it is retained. When every publication sink is safe and only private result metadata fails retention DLP, the unsafe private fields are replaced with static non-sensitive values while the original valid status, manifest, warnings, and findings remain. Unsafe values and their locations are never retained. Receipt v8 calls this `private-sanitized` only when a pure canonical publication/approval projection is byte-equivalent before and after sanitization; normal approval evaluation then applies. Any changed, malformed, or incomparable projection is `publication-filtered`, preserves the previous review, emits only closed counts, and cannot automatically approve. Its public projection is incomplete, but posting uses the validated original coverage kind/counts so complete OCR coverage is not relabelled as partial and filtered warnings cannot synthesize failed items. The summary contains distinct private-sanitization and publication-filtering details with an exact `ocr.publication-dlp-signal/v2` HTML marker containing only low-cardinality counts. The posting command emits the same JSON as an `OCR toolkit telemetry event` log line so CI log collectors can alert without a new runtime network/exporter path. OCR remains authoritative for provider/token/request telemetry; the toolkit does not add an OTLP client, arbitrary telemetry endpoint, raw value/path, or mandatory external export. The explicit local `ocr-ci review --preserve-private-artifacts` diagnostic retains owner-only raw session/context state, adds a value-free `ocr.private-dlp-decisions/v1` path/reason/detector/size/hash attribution sidecar, and deliberately emits no posting receipt. It is local evidence for investigating conservative false positives, not a CI or publication artifact; the validated GitLab MR profile rejects it. -Receipt v7 stores only closed source/target/policy identities, actual `protected|unprotected` target state, context mode, per-source completeness and degradation counts, admitted-mutable state, fixed evidence/context tool-use counts, publication-DLP result, and cleanup result. It does not store context text, provider IDs, URLs, commands, arguments, headers, adapter results, personal display data, or transport diagnostics. Receipt v1-v6 is rejected; ephemeral results have no migration path. Only a fully validated unprotected receipt adds the static italic comment-only limitation; that line does not change result completeness or status. A protected target allowed by the permissive operator setting has no limitation line. +Receipt v8 stores only closed source/target/policy identities, actual `protected|unprotected` target state, context mode, per-source completeness and degradation counts, admitted-mutable state, fixed evidence/context tool-use counts, a closed failed-tool diagnostic state and optional aggregate, publication-DLP result, and cleanup result. It does not store context text, failed-tool detail or paths, provider IDs, URLs, commands, arguments, headers, adapter results, personal display data, or transport diagnostics. Receipt v1-v7 is rejected; ephemeral results have no migration path. Only a fully validated unprotected receipt adds the static italic comment-only limitation; that line does not change result completeness or status. A protected target allowed by the permissive operator setting has no limitation line. Every existing manifest, coverage, warning, omission, finding, source-SHA, author, provider, and self-approval gate remains. Degraded selected metadata, a DLP-rejected selected source, required-source degradation, and any admitted remediation record make the run ineligible. DLP-clean generic discussions and adapter records do not independently block approval; optional non-DLP degradation remains visible and cannot prove absence. A complete enriched run without admitted remediation is not blocked solely by the selected mode. Direct operator MCP is a separate privileged boundary and remains comment-only. diff --git a/docs/review-decision-flow.md b/docs/review-decision-flow.md new file mode 100644 index 0000000..2f61a48 --- /dev/null +++ b/docs/review-decision-flow.md @@ -0,0 +1,185 @@ +# Review decision flow + +This document is the canonical visual map of the toolkit's end-to-end review decisions. It +connects configuration and immutable identity, OCR execution, result and action-receipt +validation, additive diagnostics, publication DLP, GitLab posting, and optional later actions. +The shorter diagrams in [the toolkit strategy](engineering/toolkit_strategy.md) remain +architecture overviews; this document owns the detailed operational branches. + +The diagrams describe toolkit decisions, not OCR finding quality or GitLab merge policy. The +executable authorities remain the runtime validators and their contract tests. If this map and +an executable contract disagree, the change is incomplete: update the implementation, tests, +and every affected public contract together before release. + +## Color legend + +| Color | Meaning | +| --- | --- | +| Green | Successful terminal state: the intended review signal was retained or published. | +| Orange | Warning terminal state: useful signal survived, but completeness or a later action is limited. | +| Red | Error terminal state: the current stage stopped and must not claim successful publication. | +| Gray | Auxiliary or neutral state: input, processing step, or an intentional non-publication path. | +| Blue | Decision or validation boundary. | + +The same palette is repeated in every diagram so a terminal state never changes meaning between +views. + +## End-to-end control flow + +```mermaid +flowchart TD + start[CI or local review request] --> input{Configuration, immutable refs,
target state, and paths valid?} + input -- No --> preflight_error[Review blocked before OCR] + input -- Yes --> acquire[Collect bounded immutable evidence
and optional authorized context] + acquire --> acquire_ok{Evidence, context, and MCP
composition valid?} + acquire_ok -- No --> preflight_error + acquire_ok -- Yes --> preview[Run exact OCR preview with
toolkit-owned child environment] + preview --> preview_ok{Preview accepted?} + preview_ok -- No --> preview_error[OCR not started; bounded failure path] + preview_ok -- Yes --> review[Run OCR against exact from/to refs] + review --> exit{OCR exit code} + exit -- Non-zero --> runtime_error[No normal result publication;
closed failure note may be posted] + exit -- Zero --> finalize[Enter result finalization] + finalize --> core{Result, manifest, identity, cleanup,
and toolkit action receipt valid?} + core -- No --> integrity_error[Delete unsafe handoff;
normal publication blocked] + core -- Yes --> diag[Classify additive failed-tool diagnostics] + diag --> dlp[Apply publication sinks and private-field DLP] + dlp --> dlp_state{DLP state} + dlp_state -- passed --> publishable[Complete publishable projection] + dlp_state -- private-sanitized --> publishable + dlp_state -- publication-filtered --> partial[Safe partial projection;
original coverage remains explicit] + publishable --> mode{Execution profile} + partial --> mode + mode -- Local diagnostic retention --> local[Owner-only artifacts retained;
no provider receipt or posting authority] + mode -- Local ordinary --> local_result[Validated receipt-less local result] + mode -- GitLab MR --> receipt[Attach exact receipt v8] + receipt --> post{Posting input valid at readback?} + post -- No --> posting_error[Publication-policy error;
findings transaction not started] + post -- Yes --> transaction[Publish current findings and summary atomically] + transaction --> transaction_ok{All required writes succeed?} + transaction_ok -- No --> posting_error + transaction_ok -- Yes --> published{Projection complete?} + published -- Yes --> success[Review signal published] + published -- Safe partial --> warning[Safe review subset published;
not a complete public review] + + classDef success fill:#d1fae5,stroke:#15803d,color:#14532d,stroke-width:2px; + classDef warning fill:#ffedd5,stroke:#ea580c,color:#7c2d12,stroke-width:2px; + classDef error fill:#fee2e2,stroke:#dc2626,color:#7f1d1d,stroke-width:2px; + classDef auxiliary fill:#f3f4f6,stroke:#6b7280,color:#1f2937; + classDef decision fill:#dbeafe,stroke:#2563eb,color:#1e3a8a; + + class success,local_result success; + class warning warning; + class local auxiliary; + class preflight_error,preview_error,runtime_error,integrity_error,posting_error error; + class start,acquire,preview,review,finalize,diag,dlp,publishable,partial,receipt,transaction auxiliary; + class input,acquire_ok,preview_ok,exit,core,dlp_state,mode,post,transaction_ok,published decision; +``` + +The core integrity boundary deliberately precedes additive diagnostics. A malformed result, +contradictory manifest, stale identity, failed cleanup, or missing/mismatched toolkit action +receipt blocks finalization. By contrast, OCR's additive failed-tool envelope is diagnostic: it +cannot replace an otherwise valid manifest, findings, summary, or posting transaction. + +## Additive failed-tool diagnostic states + +```mermaid +flowchart TD + raw[OCR tool_calls] --> present{All diagnostic fields absent?} + present -- Yes --> absent[State: absent
no diagnostic log] + present -- No --> shape{failure, failure_by_tool, and
failure_details form one bounded envelope?} + shape -- No --> invalid[State: invalid
static malformed notice in console] + shape -- Yes --> failed{Failed count is zero?} + failed -- Yes --> verified_zero[State: verified, failed: 0
no detail lines] + failed -- No --> reconcile{Contradicts toolkit-owned
completed evidence actions?} + reconcile -- Yes --> conflicting[State: conflicting
toolkit completion remains authoritative] + reconcile -- No --> verified[State: verified, failed: N
bounded redacted detail in console] + + absent --> retain[Retain authoritative review signal] + invalid --> retain + verified_zero --> retain + conflicting --> retain + verified --> retain + retain --> later{Separate later-action policy} + later -- Eligible --> later_ok[Later action may proceed] + later -- Diagnostic blocker --> later_warn[Review stays published;
later action does not proceed] + + classDef success fill:#d1fae5,stroke:#15803d,color:#14532d,stroke-width:2px; + classDef warning fill:#ffedd5,stroke:#ea580c,color:#7c2d12,stroke-width:2px; + classDef error fill:#fee2e2,stroke:#dc2626,color:#7f1d1d,stroke-width:2px; + classDef auxiliary fill:#f3f4f6,stroke:#6b7280,color:#1f2937; + classDef decision fill:#dbeafe,stroke:#2563eb,color:#1e3a8a; + + class retain,later_ok success; + class invalid,conflicting,verified,later_warn warning; + class raw,absent,verified_zero auxiliary; + class present,shape,failed,reconcile,later decision; +``` + +Valid detail records are bounded, credential-redacted, and control-safe before they reach the +local stderr or CI job log. Dynamic detail text, paths, and per-tool failure maps never enter the +finalized result, receipt, merge-request comments, publication-DLP telemetry, or later-action +inputs. Receipt v8 stores only `absent|verified|invalid|conflicting` and a bounded aggregate +`failed` integer for `verified`; the toolkit action receipt v3 remains authoritative for evidence +attempts and completions. + +This separation applies in every execution profile. It is not a local-mode exception and it is +not an automatic-approval feature: publication owns review-signal delivery, while any later action +evaluates the already-published result independently. + +## Posting and later-action states + +```mermaid +flowchart TD + handoff[Finalized result handoff] --> receipt{Toolkit receipt present?} + receipt -- No --> direct[Compatible receipt-less posting path] + receipt -- Yes --> exact{Exact current receipt v8 valid?} + exact -- No --> schema_error[Publication-policy error] + exact -- Yes --> projection{Publication projection} + direct --> publish[Publish findings and summary] + projection -- passed or private-sanitized --> publish + projection -- publication-filtered --> publish_partial[Publish safe subset and explicit limitation] + publish --> writes{Posting transaction complete?} + publish_partial --> writes + writes -- No --> post_error[Posting failed;
previous review state preserved] + writes -- Yes --> public_ok[Review signal published] + public_ok --> action{Optional later action eligible?} + action -- Yes --> action_ok[Review published; later action completed] + action -- No --> action_warn[Review published; later action skipped,
disabled, ineligible, or failed] + + classDef success fill:#d1fae5,stroke:#15803d,color:#14532d,stroke-width:2px; + classDef warning fill:#ffedd5,stroke:#ea580c,color:#7c2d12,stroke-width:2px; + classDef error fill:#fee2e2,stroke:#dc2626,color:#7f1d1d,stroke-width:2px; + classDef auxiliary fill:#f3f4f6,stroke:#6b7280,color:#1f2937; + classDef decision fill:#dbeafe,stroke:#2563eb,color:#1e3a8a; + + class public_ok,action_ok success; + class publish_partial,action_warn warning; + class schema_error,post_error error; + class handoff,direct,publish auxiliary; + class receipt,exact,projection,writes,action decision; +``` + +The current GitLab later action is an optional receipt-bound approval write. It is only one +consumer of the finalized state. Unprotected targets, incomplete coverage, publication filtering, +non-zero or uncertain tool diagnostics, context blockers, external MCP, identity movement, and +other documented gates can make that action unavailable without turning a successfully published +review into a failure. GitLab approval rules, Code Owners, protected-branch policy, and mergeability +remain external merge-policy authorities. + +## Maintenance contract + +Update this document in the same change whenever any of these boundaries changes: + +- review preflight, immutable identity, target-protection, OCR preview, or child environment; +- result/manifest parsing, action receipt, receipt schema, cleanup, or DLP projection; +- diagnostic parsing or any console, result, receipt, summary, comment, or telemetry sink; +- posting transaction ordering, partial-publication behavior, or a later-action gate. + +Before merging such a change, compare every diagram branch with the relevant runtime owner and +hostile regression, render or parse the Mermaid blocks, and reconcile +[configuration](configuration.md), [operations](operations.md), +[security](security.md), [review context](review-context.md), the +[test evidence matrix](engineering/test_evidence_matrix.md), and the architecture summary in +[toolkit strategy](engineering/toolkit_strategy.md). Do not add a second detailed flow elsewhere; +link to this document instead. diff --git a/docs/security.md b/docs/security.md index 2dd20bf..b7cd765 100644 --- a/docs/security.md +++ b/docs/security.md @@ -23,7 +23,7 @@ An operator controls CI configuration, direct MCP servers, adapter commands/endp 5. Typed repository evidence crosses into the compact bootstrap and mandatory read-only evidence MCP. During enriched OCR, only the same built-in process's fixed `context_list`/`context_get` can read committed local handles; adapter/provider network paths are absent from the model loop. 6. Persisted evidence, context, results, OCR configuration, private pre-execution status, and receipts re-enter hostile parsing. OCR runs in a fresh isolated home; session, configuration, adapter, and context data crosses deterministic cleanup before a result becomes publishable. 7. In the direct-MCP GitLab profile, operator-configured external MCP is remote HTTPS only; developer-local execution may pass explicit stdio command/setup configuration. Server-authored descriptions and schemas cross into plan and main model context; model-generated arguments cross to allowed tools; textual responses cross back to the model and OCR session. This path is separate from M5 adapters. -8. The complete successful OCR result crosses independent publication DLP, deterministic GitLab publication, suppression, and receipt-v7 approval policy. Receipt v7 binds exact source/target identity and actual protection state. An unprotected receipt can add only the static limitation and is structurally barred from the approval executor. On non-zero OCR exit with a valid bounded `ocr.llm-retry-report/v1`, only a closed provider-neutral reason may cross into a toolkit-authored failure note; one separate local line may contain closed protocol detail, a shared HTTP status, and bounded aggregate counts from the same strict parser. Raw result fields and stderr remain private, normal findings are ignored, and approval is unreachable. Missing or invalid retry diagnostics use the pre-existing generic failure boundary. +8. The complete successful OCR result crosses independent publication DLP, deterministic GitLab publication, suppression, and receipt-v8 later-action policy. Receipt v8 binds exact source/target identity and actual protection state. Additive failed-tool diagnostics can limit a later action but cannot suppress a valid review publication; dynamic detail is console-only. An unprotected receipt can add only the static limitation and is structurally barred from the approval executor. On non-zero OCR exit with a valid bounded `ocr.llm-retry-report/v1`, only a closed provider-neutral reason may cross into a toolkit-authored failure note; one separate local line may contain closed protocol detail, a shared HTTP status, and bounded aggregate counts from the same strict parser. Raw result fields and stderr remain private, normal findings are ignored, and approval is unreachable. Missing or invalid retry diagnostics use the pre-existing generic failure boundary. See the [review decision flow](review-decision-flow.md) for the complete branch ordering. 9. A release candidate crosses protected-base authorization, publication, provenance, and live readback. ### Security objectives @@ -38,7 +38,7 @@ An operator controls CI configuration, direct MCP servers, adapter commands/endp - Context policy cannot come from the source branch, context budgets cannot evict repository evidence, and model-facing context cannot add a network, arbitrary ID/URL, search, traversal, or write path. - Publication and retention are independent from retrieval/model egress. Cleanup uncertainty blocks publication. DLP selects exact posting sinks separately from private OCR metadata. Unsafe sinks produce an explicit safe partial result; unsafe private-only keys/values are removed or replaced before retention without discarding a still-valid manifest or safe findings. Both paths atomically destroy the rejected value/location and expose only closed counts. Publication filtering preserves prior review state, matches repeated fingerprints one-for-one, and blocks approval; private-only sanitization may continue through all existing approval gates only after exact canonical equivalence. - Classified provider failure reporting consumes only validated retry class, phase, terminal outcome, HTTP status, and schema counters. Provider/model identity, URL, response body, error code or message, request ID, path, warning text, token, and stderr cannot enter the static note or closed numeric line or become a receipt, DLP, telemetry, severity, finding, or approval signal. A status-derived label describes protocol state only; it cannot assert the provider's billing or admission cause. -- OCR semantic-group labels, path-derived group keys, file membership, and round diagnostics are untrusted private data. They may be DLP-sanitized in the owner-only result but cannot enter findings, GitLab text, receipt v7, fingerprints, severity, lifecycle commands, toolkit telemetry, or approval. Receipt extensions fail closed. +- OCR semantic-group labels, path-derived group keys, file membership, and round diagnostics are untrusted private data. They may be DLP-sanitized in the owner-only result but cannot enter findings, GitLab text, receipt v8, fingerprints, severity, lifecycle commands, toolkit telemetry, or approval. Receipt extensions fail closed. - Provider mutations bind reviewed identity where supported; ambiguous inline creates use one author-bound readback without retry, and unresolved ambiguity preserves prior state. - Secrets remain outside repository-controlled context, public notes, fixtures, and release artifacts. @@ -59,7 +59,7 @@ These observations establish the current safe-use envelope, not the safety of ar Server command, endpoint, setup, schemas, descriptions, arguments, and responses cross separate executable or untrusted boundaries. GitLab-MR external entries are remote HTTPS only and reject stdio/setup fields; local-profile `setup` remains operator-owned shell configuration executed in the analyzed repository. Existing OCR MCP configuration is hostile persisted input and is revalidated against the active profile. Raw endpoint, setup, and credential values are not safe diagnostic material. Toolkit diagnostics avoid them, but OCR may emit operator-owned transport details; keep those values non-sensitive and retain OCR stderr privately. Managed OAuth authenticates a client but does not authorize an object and remains conditional. -Receipt v7 records the bounded configured capability inventory and positive calls attributed from known tool names. For the fixed evidence MCP only, owner-only atomic action receipt v3 records authenticated attempts separately from completed `summary`, `list`, `get`, `search`, and `coverage` actions. MCP-received attempt totals cannot exceed OCR's three authoritative built-in evidence-tool totals; requests rejected during OCR argument parsing never reach MCP and their count-only residual is added to `unattributed`. Only completed actions establish successful evidence use, and malformed MCP-dispatched primary actions are likewise unattributed. Absence, malformed content, races, or mismatch fails normal review finalization before publication, and failed attempts cannot satisfy the mandatory summary or authorize approval. The receipt contains no arguments, IDs, paths, queries, scopes, results, or provider content. Its publication state is exactly `passed`, `private-sanitized`, or `publication-filtered`; only a byte-equivalent canonical publication/approval projection permits private sanitization to continue through the existing approval gates. Every direct external MCP makes a protected-target review comment-only, independent of use, and is rejected for an actually unprotected target; server-authored tool annotations are not used to upgrade that policy. The receipt is operational attribution, not security evidence for resource authorization, completeness, response safety, actual semantic use, or model output. Security severity still depends on demonstrated reachability and impact: prompt-like or Markdown text is not code execution without a privileged action path, and same-owner artifact tampering is not an ordinary-contributor escalation without a lower-privilege writer. +Receipt v8 records the bounded configured capability inventory and positive calls attributed from known tool names. For the fixed evidence MCP only, owner-only atomic action receipt v3 records authenticated attempts separately from completed `summary`, `list`, `get`, `search`, and `coverage` actions. MCP-received attempt totals cannot exceed OCR's three authoritative built-in evidence-tool totals; requests rejected during OCR argument parsing never reach MCP and their count-only residual is added to `unattributed`. Only completed actions establish successful evidence use, and malformed MCP-dispatched primary actions are likewise unattributed. Absence, malformed content, races, or mismatch fails normal review finalization before publication, and failed attempts cannot satisfy the mandatory summary or authorize approval. The receipt contains no arguments, IDs, paths, queries, scopes, results, or provider content. Its publication state is exactly `passed`, `private-sanitized`, or `publication-filtered`; only a byte-equivalent canonical publication/approval projection permits private sanitization to continue through the existing approval gates. Every direct external MCP makes a protected-target review comment-only, independent of use, and is rejected for an actually unprotected target; server-authored tool annotations are not used to upgrade that policy. The receipt is operational attribution, not security evidence for resource authorization, completeness, response safety, actual semantic use, or model output. Security severity still depends on demonstrated reachability and impact: prompt-like or Markdown text is not code execution without a privileged action path, and same-owner artifact tampering is not an ordinary-contributor escalation without a lower-privilege writer. ## Preserved current safety properties @@ -71,8 +71,8 @@ Receipt v7 records the bounded configured capability inventory and positive call - Result and provider reads have byte limits; notes enforce character and UTF-8 byte limits. Position-bearing inline creates reserve independent unguessable markers, classify closed outcomes, and perform at most one complete author-bound reconciliation read with no retry. - Publication DLP admits ordinary horizontal tab only in `existing_code` and `suggestion_code`. The unchanged code value is retained only after it passes the existing size, secret, PII, forbidden-value, and laundering checks; tabs elsewhere and vertical tab, form feed, NUL, bidi/format controls, and other unsupported controls remain blocking. - LLM provider URLs are normalized by one forge-neutral owner shared by configuration and preflight; only credential-free absolute HTTPS roots or protocol-compatible terminal endpoints are accepted. Explicit protocol remains authoritative, and ambiguous auxiliary URL derivation fails closed. -- Automatic approval binds the exact synchronized reviewed head, target SHA/protection state, and MR author from receipt v7, skips self-approval, and never removes an existing approval. An unprotected target cannot reach the approval executor. Partial, warning, non-v7, publication-filtered, omitted, degraded metadata, DLP-rejected selected-source, required context degradation, admitted remediation-context, or direct external-MCP runs are ineligible. -- Built-in evidence search operates only on the already DLP-admitted store. Queries are bounded literal NFKC/case-folded tokens; regex, wildcards, operators, controls, bidi/format characters, broad empty search, and query/result echo are rejected. Results expose only existing stable IDs and closed record metadata. Coverage can authorize a negative claim only for one exact complete base/head scope with zero records and no truncation. Private action receipt v3 retains only closed attempted/completed counters; receipt v7 requires complete OCR/MCP attempt accounting and a completed mandatory summary before automatic approval. +- Automatic approval binds the exact synchronized reviewed head, target SHA/protection state, and MR author from receipt v8, skips self-approval, and never removes an existing approval. An unprotected target cannot reach the approval executor. Partial, warning, non-v8, publication-filtered, omitted, degraded metadata, DLP-rejected selected-source, required context degradation, admitted remediation-context, or direct external-MCP runs are ineligible. +- Built-in evidence search operates only on the already DLP-admitted store. Queries are bounded literal NFKC/case-folded tokens; regex, wildcards, operators, controls, bidi/format characters, broad empty search, and query/result echo are rejected. Results expose only existing stable IDs and closed record metadata. Coverage can authorize a negative claim only for one exact complete base/head scope with zero records and no truncation. Private action receipt v3 retains only closed attempted/completed counters; receipt v8 requires complete OCR/MCP attempt accounting and a completed mandatory summary before automatic approval. - Human replies are ownership boundaries. Merge-request source SHA, protected-target policy SHA, and merge-result SHA remain distinct. - The evidence engine stores recursively redacted typed facts/deltas in owner-only files and serves a closed bounded network-independent MCP. Absence supports a negative claim only for applicable complete scope. - OCR rules, decisions, and guidance come only from captured target blobs; source changes cannot create policy. On a protected target they retain their existing bounded contracts. On an unprotected target only Rules are admitted, solely as untrusted guidance; decisions and structured guidance are omitted. None can authorize a privileged capability. @@ -87,7 +87,7 @@ Ansible Galaxy requirements use the same immutable-object boundary. Relative inc ## M5 bounded enrichment boundary -The v0.7.0 implementation extended the v0.6.3 selector/approval foundation with protected policy, stable GitLab discussions, deterministic recognizers, provider-neutral adapters, a separate context store, opaque handles, fixed context tools, publication DLP, isolated OCR sessions, and receipt v4. Receipt v7 now supersedes that result/posting contract without changing the established M5 acquisition boundary. The public [bounded review-context contract](review-context.md), engineering [M5 checkpoint](engineering/m5_context_contracts.md), and [test-evidence matrix](engineering/test_evidence_matrix.md) distinguish deterministic production-owner evidence from the final real-OCR and stable-release gates. +The v0.7.0 implementation extended the v0.6.3 selector/approval foundation with protected policy, stable GitLab discussions, deterministic recognizers, provider-neutral adapters, a separate context store, opaque handles, fixed context tools, publication DLP, isolated OCR sessions, and receipt v4. Receipt v8 now supersedes that result/posting contract without changing the established M5 acquisition boundary. The public [bounded review-context contract](review-context.md), engineering [M5 checkpoint](engineering/m5_context_contracts.md), and [test-evidence matrix](engineering/test_evidence_matrix.md) distinguish deterministic production-owner evidence from the final real-OCR and stable-release gates. ### Data flow and trust transitions @@ -119,12 +119,12 @@ Policy v2 adds optional toolkit-owned remediation threads without changing these - **TOCTOU/replay/cache poisoning:** bind snapshots and handles to version/ETag or digest, policy version, run, and expiry; use atomic store commits and uniform unavailable outcomes. - **PII/secret bypass and output laundering:** make retrieval, model egress, publication sinks, and private retention separate decisions. Minimize before model egress; publication DLP cannot undo prior disclosure. Scan decoded source and rendered forms so HTML comments/entities/tags, Markdown inline/reference/autolink destinations, controls, and Unicode formatting cannot hide exact protected values. Sink uncertainty yields a safe partial result; private-only uncertainty yields an atomically sanitized valid result or falls back to that partial form. Expose only low-cardinality receipt/summary/log signals in ordinary runs. The explicit local-only diagnostic switch retains owner-only raw private state, emits no receipt, and is rejected by the validated GitLab MR profile. - **Upstream session and telemetry privacy:** OCR 1.11.0 can retain readable reasoning, signed/encrypted provider-native replay payloads, tool arguments/results, sorted changed paths, model-produced group labels, and round counters in its owner-only session. Telemetry may also carry repository-derived high-cardinality grouping values. OCR telemetry is disabled by default, and the toolkit removes the isolated session home before publication. Enabling an exporter authorizes it to receive the fields OCR emits; content-logging disablement is not a promise that every identifier disappears. The toolkit neither ingests nor duplicates these values. -- **OCR advisory spoofing:** raw OCR output cannot supply `_ocr_toolkit_advisory`. The toolkit adds only the exact numeric `ocr.toolkit-advisory/v1` shape after publication DLP; posting requires a valid receipt v7 and rejects duplicate, extended, unknown, or malformed forms. The advisory cannot change warnings, coverage, DLP, telemetry, findings, or approval. +- **OCR advisory spoofing:** raw OCR output cannot supply `_ocr_toolkit_advisory`. The toolkit adds only the exact numeric `ocr.toolkit-advisory/v1` shape after publication DLP; posting requires a valid receipt v8 and rejects duplicate, extended, unknown, or malformed forms. The advisory cannot change warnings, coverage, DLP, telemetry, findings, or approval. - **Local executable and subprocess confused deputy:** accept the OCR executable only through absolute search-path entries and reject resolution inside the reviewed repository. Stdio adapter deadlines cover request delivery as well as response/exit, use a clean environment and isolated directory, and terminate the process group on uncertainty. - **Marker and retry spoofing:** recognize ownership, run-summary, and setup identities only in the exact toolkit preamble. Repository/provider text containing marker lookalikes has no authority. Duplicate findings consume visible prior occurrences one-for-one; successful setup retries replace only previous toolkit setup-pending notes. - **Approval/suppression manipulation:** every admitted remediation thread blocks automatic approval; partial, required-degraded, or DLP-rejected context cannot prove absence or restore eligibility. Safe MR metadata, generic discussions, and adapter records do not themselves block an otherwise eligible receipt. Remediation text cannot change severity, prove a fix, suppress or resolve a finding, issue a lifecycle command, or authorize approval. Existing suppression, exact slash/mention commands, fingerprints, and discussion ownership remain separate deterministic consumers. - **Persistent-session leakage:** run OCR under an isolated owner-only home and clean session artifacts deterministically. Containment/cleanup failure blocks publication. The explicit local diagnostic exception preserves owner-only paths and produces no posting-eligible receipt; the authoritative GitLab MR profile rejects it before OCR execution. -- **Local receipt confusion:** local Git state cannot establish an actual GitLab target-protection fact. Local review finalization therefore enforces result, action, and DLP contracts without emitting receipt v7; the compatible receipt-less posting path cannot authorize approval or receipt-derived status. Any present invalid receipt still fails closed. +- **Local receipt confusion:** local Git state cannot establish an actual GitLab target-protection fact. Local review finalization therefore enforces result, action, and DLP contracts without emitting receipt v8; the compatible receipt-less posting path cannot authorize approval or receipt-derived status. Any present invalid receipt still fails closed. - **Second-review-engine drift:** OCR remains the sole review engine. If contextual adjudication needs a separate model phase, depend on a native structured OCR API rather than merge two toolkit-driven reviews. ### Deployment assumptions and residual risks diff --git a/examples/gitlab/README.md b/examples/gitlab/README.md index 7d0530e..84553d2 100644 --- a/examples/gitlab/README.md +++ b/examples/gitlab/README.md @@ -2,7 +2,7 @@ [`ocr-review.gitlab-ci.yml`](ocr-review.gitlab-ci.yml) is the complete checksum-pinned pipeline. It defaults to identity-only review, explicitly keeps `OCR_GITLAB_TARGET_PROTECTION_MODE: "required"`, and contains the install, preflight, configure, review, and post lifecycle. Copy one mode file below into the pipeline's top-level `variables` mapping, or include exactly one file after reviewing its trust boundary. -The pipeline pins OCR 1.11.2 and explicitly sets `OCR_REVIEW_EFFORT=medium`, which permits two review rounds. Use `low` for one round when latency/cost is the priority, or `high` for three only after accepting the additional provider work. OCR uses a 15-minute subtask base and scales it to 15/30/45 minutes for low/medium/high; the example's 45-minute job timeout accommodates every preset without adding another environment control. Semantic grouping and filtering are OCR behavior shared by every context mode; they do not change which merge-request text a mode admits. `OCR_MAX_TOOLS=0` selects the embedded template default `100`. Values `1-49` report normalization to `50` but remain effectively `100`; only values above `100` raise the per-subtask cap. `OCR_MAX_TOKENS_BUDGET` and the optional completion cap remain independent controls. +The pipeline pins OCR 1.11.3 and explicitly sets `OCR_REVIEW_EFFORT=medium`, which permits two review rounds. Use `low` for one round when latency/cost is the priority, or `high` for three only after accepting the additional provider work. OCR uses a 15-minute subtask base and scales it to 15/30/45 minutes for low/medium/high; the example's 45-minute job timeout accommodates every preset without adding another environment control. Semantic grouping and filtering are OCR behavior shared by every context mode; they do not change which merge-request text a mode admits. `OCR_MAX_TOOLS=0` selects the embedded template default `100`. Values `1-49` report normalization to `50` but remain effectively `100`; only values above `100` raise the per-subtask cap. `OCR_MAX_TOKENS_BUDGET` and the optional completion cap remain independent controls. | Mode | Recipe | MR text admitted | External access | Automatic approval | | --- | --- | --- | --- | --- | diff --git a/examples/gitlab/modes/unprotected-target.gitlab-ci.yml b/examples/gitlab/modes/unprotected-target.gitlab-ci.yml index 2b22711..d928e5d 100644 --- a/examples/gitlab/modes/unprotected-target.gitlab-ci.yml +++ b/examples/gitlab/modes/unprotected-target.gitlab-ci.yml @@ -3,5 +3,5 @@ variables: OCR_GITLAB_TARGET_PROTECTION_MODE: "unprotected" # Only "off" and bounded untrusted "metadata" are allowed for an actual unprotected target. OCR_REVIEW_CONTEXT_MODE: "metadata" - # Defense in depth; receipt v7 also makes an actual unprotected target ineligible. + # Defense in depth; receipt v8 also makes an actual unprotected target ineligible. OCR_AUTO_APPROVE: "false" diff --git a/examples/gitlab/ocr-review.gitlab-ci.yml b/examples/gitlab/ocr-review.gitlab-ci.yml index 3e0b9f6..d61f3ca 100644 --- a/examples/gitlab/ocr-review.gitlab-ci.yml +++ b/examples/gitlab/ocr-review.gitlab-ci.yml @@ -6,10 +6,10 @@ default: image: python:3.12-slim variables: - OCR_VERSION: "v1.11.2" + OCR_VERSION: "v1.11.3" OCR_TOOLKIT_VERSION: "0.8.7" OCR_TOOLKIT_CHECKSUMS_URL: "https://github.com/xeonvs/open-code-review-toolkit/releases/download/v${OCR_TOOLKIT_VERSION}/SHA256SUMS" - OCR_SHA256: "fad3c62314478822c1e90f858f3e06b3f8dc5ff0034933927c3ecd7faa5d3731" + OCR_SHA256: "9726204ac81baee153fd65b1ff357c380f73e9d8091c4a73c3c9fb541b5164cb" OCR_POST_MODE: "draft" OCR_STRICT_POSTING: "true" # Secure default: reject an unprotected GitLab target before OCR. @@ -46,7 +46,7 @@ open_code_review: - if: '$CI_PIPELINE_SOURCE == "merge_request_event"' when: on_success - when: never - # OCR 1.11.2 scales the 15-minute subtask base by low/medium/high rounds (15/30/45). + # OCR 1.11.3 scales the 15-minute subtask base by low/medium/high rounds (15/30/45). timeout: 45m script: - apt-get update && apt-get install -y --no-install-recommends ca-certificates curl git diff --git a/src/ocr_toolkit/ocr_result.py b/src/ocr_toolkit/ocr_result.py index 67f08ba..f6ac84a 100644 --- a/src/ocr_toolkit/ocr_result.py +++ b/src/ocr_toolkit/ocr_result.py @@ -19,7 +19,7 @@ DEFAULT_MAX_RESULT_BYTES = 2_000_000 MAX_RESULT_BYTES_HARD_LIMIT = 20_000_000 TOOLKIT_RESULT_KEY = "_ocr_toolkit" -TOOLKIT_RESULT_SCHEMA_VERSION = 7 +TOOLKIT_RESULT_SCHEMA_VERSION = 8 SUPPORTED_TOOLKIT_RESULT_SCHEMA_VERSIONS = frozenset({TOOLKIT_RESULT_SCHEMA_VERSION}) TOOLKIT_ADVISORY_KEY = "_ocr_toolkit_advisory" TOOLKIT_ADVISORY_SCHEMA_VERSION = "ocr.toolkit-advisory/v1" diff --git a/src/ocr_toolkit/posting/formatting.py b/src/ocr_toolkit/posting/formatting.py index 2374651..16226e7 100644 --- a/src/ocr_toolkit/posting/formatting.py +++ b/src/ocr_toolkit/posting/formatting.py @@ -558,7 +558,7 @@ def format_mcp_usage_summary(toolkit_metadata: Any) -> str: def publication_dlp_signal( publication: Any, *, carried_forward_comments: int = 0 ) -> dict[str, Any] | None: - """Return one low-cardinality signal from an exact v7 DLP receipt.""" + """Return one low-cardinality signal from an exact v8 DLP receipt.""" state = publication_dlp_state(publication) if ( diff --git a/src/ocr_toolkit/posting/workflow.py b/src/ocr_toolkit/posting/workflow.py index 858e858..a87ddb4 100644 --- a/src/ocr_toolkit/posting/workflow.py +++ b/src/ocr_toolkit/posting/workflow.py @@ -165,7 +165,7 @@ def approval_receipt_identity(toolkit_metadata: Any) -> tuple[str, int | None]: def unprotected_target_limitation(toolkit_metadata: Any) -> bool: - """Select the static limitation only from one fully validated receipt v7.""" + """Select the static limitation only from one fully validated receipt v8.""" identity = validated_review_identity(toolkit_metadata) return identity is not None and identity.target_protection == "unprotected" @@ -614,7 +614,7 @@ def post_results(config: GitLabConfig, result: dict[str, Any]) -> int: if toolkit_metadata is not None and not toolkit_receipt_is_valid(toolkit_metadata): return invalid_ocr_schema_exit( config, - "receipt v7 is invalid", + "receipt v8 is invalid", intro="OCR result publication policy state could not be validated.", title="**Open Code Review publication policy error**", ) @@ -624,7 +624,7 @@ def post_results(config: GitLabConfig, result: dict[str, Any]) -> int: if publication_state is None: return invalid_ocr_schema_exit( config, - "receipt v7 publication state is invalid", + "receipt v8 publication state is invalid", intro="OCR result publication policy state could not be validated.", title="**Open Code Review publication policy error**", ) @@ -638,7 +638,7 @@ def post_results(config: GitLabConfig, result: dict[str, Any]) -> int: if not toolkit_receipt_is_valid(toolkit_metadata): return invalid_ocr_schema_exit( config, - "OCR toolkit advisory is not bound to a valid receipt v7", + "OCR toolkit advisory is not bound to a valid receipt v8", ) ocr_core_advisory_summary = format_ocr_core_advisory(advisory) diff --git a/src/ocr_toolkit/preflight.py b/src/ocr_toolkit/preflight.py index 6dbed1e..f255576 100644 --- a/src/ocr_toolkit/preflight.py +++ b/src/ocr_toolkit/preflight.py @@ -28,7 +28,7 @@ "Accept": "application/json", "User-Agent": "open-code-review-ci-preflight/1.0", } -EXPECTED_OCR_VERSION = "1.11.2" +EXPECTED_OCR_VERSION = "1.11.3" class PreflightError(Exception): diff --git a/src/ocr_toolkit/review_receipt.py b/src/ocr_toolkit/review_receipt.py index 46593c5..066587e 100644 --- a/src/ocr_toolkit/review_receipt.py +++ b/src/ocr_toolkit/review_receipt.py @@ -114,7 +114,7 @@ def _sha256(value: Any) -> bool: def publication_dlp_state(value: Any) -> str | None: - """Validate the exact v7 publication-policy receipt.""" + """Validate the exact current publication-policy receipt.""" if value == {"state": "passed"}: return "passed" @@ -212,13 +212,14 @@ def automatic_approval_metadata_reason(toolkit_metadata: Any) -> str: invalid = INVALID_APPROVAL_RECEIPT_REASON if not isinstance(toolkit_metadata, dict): return invalid - if toolkit_metadata.get("schema_version") != 7 or set(toolkit_metadata) != { + if toolkit_metadata.get("schema_version") != 8 or set(toolkit_metadata) != { "schema_version", "review", "context", "mcp", "evidence", "publication", + "tool_execution", "cleanup", }: return invalid @@ -404,9 +405,27 @@ def automatic_approval_metadata_reason(toolkit_metadata: Any) -> str: ): return invalid publication = toolkit_metadata.get("publication") + tool_execution = toolkit_metadata.get("tool_execution") cleanup = toolkit_metadata.get("cleanup") publication_state = publication_dlp_state(publication) - if publication_state is None or cleanup != {"result": "passed"}: + failure_state = tool_execution.get("state") if isinstance(tool_execution, dict) else None + failed_tools = tool_execution.get("failed") if isinstance(tool_execution, dict) else None + if ( + publication_state is None + or not isinstance(tool_execution, dict) + or set(tool_execution) != {"state", "failed"} + or failure_state not in {"absent", "invalid", "conflicting", "verified"} + or ( + failure_state == "verified" + and ( + not isinstance(failed_tools, int) + or isinstance(failed_tools, bool) + or not 0 <= failed_tools <= MAX_TOOLKIT_MCP_USAGE_COUNT + ) + ) + or (failure_state != "verified" and failed_tools is not None) + or cleanup != {"result": "passed"} + ): return invalid if review.get("target_protection") == "unprotected": if mode not in {"off", "metadata"} or external: @@ -414,6 +433,10 @@ def automatic_approval_metadata_reason(toolkit_metadata: Any) -> str: return UNPROTECTED_APPROVAL_REASON if publication_state == "publication-filtered": return "publication DLP filtered the complete review result" + if failure_state in {"invalid", "conflicting"}: + return "OCR tool failure diagnostics were malformed or contradictory" + if failure_state == "verified" and failed_tools: + return "one or more OCR tool calls failed" if context.get("state") == "degraded" or required_degraded: return "the selected review context was degraded" if mutable_admitted: @@ -424,7 +447,7 @@ def automatic_approval_metadata_reason(toolkit_metadata: Any) -> str: def toolkit_receipt_is_valid(toolkit_metadata: Any) -> bool: - """Return whether metadata is an exact receipt v7, including valid blockers.""" + """Return whether metadata is an exact current receipt, including valid blockers.""" return automatic_approval_metadata_reason(toolkit_metadata) != INVALID_APPROVAL_RECEIPT_REASON @@ -432,7 +455,7 @@ def toolkit_receipt_is_valid(toolkit_metadata: Any) -> bool: def receipt_review_identity(toolkit_metadata: Any) -> ReceiptReviewIdentity | None: """Parse the exact immutable identity section without granting approval authority.""" - if not isinstance(toolkit_metadata, dict) or toolkit_metadata.get("schema_version") != 7: + if not isinstance(toolkit_metadata, dict) or toolkit_metadata.get("schema_version") != 8: return None review = toolkit_metadata.get("review") if ( diff --git a/src/ocr_toolkit/review_runner.py b/src/ocr_toolkit/review_runner.py index d2f7930..a7a107f 100644 --- a/src/ocr_toolkit/review_runner.py +++ b/src/ocr_toolkit/review_runner.py @@ -101,6 +101,7 @@ toolkit_advisory_payload, transform_ocr_result, ) +from ocr_toolkit.posting.comments import compact_text from ocr_toolkit.posting.result import ocr_warning_text from ocr_toolkit.pre_execution import ( BACKGROUND_CHARACTER_LIMIT_REASON, @@ -178,12 +179,47 @@ "tool_choice", } ) +OCR_RAW_LOGGING_ENV = "OCR_RAW_LOGGING" +MAX_TOOL_FAILURE_DETAILS_LOGGED = 20 +MAX_TOOL_FAILURE_NAME_CHARS = 256 +MAX_TOOL_FAILURE_PATH_CHARS = 4_096 +MAX_TOOL_FAILURE_ERROR_CHARS = 32_768 +MAX_TOOL_FAILURE_LOG_FIELD_CHARS = 500 class ReviewRunnerError(Exception): """The local OCR review process could not be started safely.""" +@dataclass(frozen=True, slots=True) +class ToolFailureDetail: + """Carry one validated OCR diagnostic before bounded CI rendering.""" + + number: int + tool: str + path: str | None + error: str + + +@dataclass(frozen=True, slots=True) +class ToolFailureTelemetry: + """Separate additive OCR diagnostics from the authoritative review result.""" + + present: bool + valid: bool + failed: int | None + by_tool: dict[str, int] + details: tuple[ToolFailureDetail, ...] + + +def _ocr_child_environment() -> dict[str, str]: + """Keep configured OCR inputs while disabling unowned raw provider capture.""" + + environment = dict(os.environ) + environment.pop(OCR_RAW_LOGGING_ENV, None) + return environment + + @dataclass(frozen=True, slots=True) class BackgroundQualification: """Carry closed toolkit-authored projections of installed OCR diagnostics.""" @@ -357,6 +393,7 @@ def _review_receipt( total_calls = 0 elif not isinstance(tool_calls, dict) or not isinstance(by_tool, dict): raise ReviewRunnerError("OCR result has inconsistent aggregate MCP usage") + failure_telemetry = _tool_failure_telemetry(tool_calls) if outcome.kind == "skipped": legacy_message_invalid = ( not outcome.manifest_present and payload.get("message") != "No supported files changed." @@ -416,6 +453,30 @@ def _review_receipt( ) except ValueError as exc: raise ReviewRunnerError(str(exc)) from exc + completed = action_attribution.get("completed") + failure_state = ( + "absent" + if not failure_telemetry.present + else "invalid" + if not failure_telemetry.valid + else "verified" + ) + if failure_telemetry.valid and failure_telemetry.present and isinstance(completed, dict): + completed_by_tool = { + TOOL_NAME: sum(completed.get(name, 0) for name in ("summary", "list", "get")), + SEARCH_TOOL_NAME: completed.get("search", 0), + COVERAGE_TOOL_NAME: completed.get("coverage", 0), + } + if any( + completed_by_tool[name] + > evidence_by_tool[name] - failure_telemetry.by_tool.get(name, 0) + for name in completed_by_tool + ): + # The private action receipt is recorded by the toolkit MCP owner and + # remains the authority for completed evidence. OCR's additive + # diagnostic counters must not discard a valid review when the two + # observations disagree. + failure_state = "conflicting" completed_actions = action_attribution.get("completed") evidence_used = ( isinstance(completed_actions, dict) @@ -485,6 +546,10 @@ def _review_receipt( "actions": action_attribution, }, "publication": {"state": "passed"}, + "tool_execution": { + "state": failure_state, + "failed": failure_telemetry.failed if failure_state == "verified" else None, + }, "cleanup": {"result": "passed"}, } @@ -835,7 +900,138 @@ def _safe_publication_warnings( return retained, len(value) - len(retained) -def _closed_tool_calls(value: object, *, allowed_tools: frozenset[str]) -> dict[str, object]: +def _tool_failure_telemetry(value: object) -> ToolFailureTelemetry: + """Parse additive OCR diagnostics without granting them result authority.""" + + if not isinstance(value, dict): + return ToolFailureTelemetry(False, True, None, {}, ()) + present = {name for name in ("failure", "failure_by_tool", "failure_details") if name in value} + if not present: + return ToolFailureTelemetry(False, True, None, {}, ()) + if present != {"failure", "failure_by_tool", "failure_details"}: + return ToolFailureTelemetry(True, False, None, {}, ()) + total = value.get("total") + by_tool = value.get("by_tool") + failed = value.get("failure") + failed_by_tool = value.get("failure_by_tool") + details = value.get("failure_details") + if ( + not isinstance(total, int) + or isinstance(total, bool) + or not 0 <= total <= MAX_TOOLKIT_MCP_USAGE_COUNT + or not isinstance(by_tool, dict) + or not isinstance(failed, int) + or isinstance(failed, bool) + or not 0 <= failed <= total + or not isinstance(failed_by_tool, dict) + or len(failed_by_tool) > MAX_TOOLKIT_MCP_USAGE_COUNT + or not isinstance(details, list) + or len(details) != failed + ): + return ToolFailureTelemetry(True, False, None, {}, ()) + normalized: dict[str, int] = {} + for name, count in failed_by_tool.items(): + attempted = by_tool.get(name) + if ( + not isinstance(name, str) + or not name + or len(name) > MAX_TOOL_FAILURE_NAME_CHARS + or not isinstance(count, int) + or isinstance(count, bool) + or not 0 < count <= MAX_TOOLKIT_MCP_USAGE_COUNT + or not isinstance(attempted, int) + or isinstance(attempted, bool) + or count > attempted + ): + return ToolFailureTelemetry(True, False, None, {}, ()) + normalized[name] = count + if sum(normalized.values()) != failed: + return ToolFailureTelemetry(True, False, None, {}, ()) + detail_counts: Counter[str] = Counter() + call_numbers: set[int] = set() + normalized_details: list[ToolFailureDetail] = [] + for detail in details: + if not isinstance(detail, dict) or set(detail) not in ( + {"tool_call_number", "tool_name", "error"}, + {"tool_call_number", "tool_name", "file_path", "error"}, + ): + return ToolFailureTelemetry(True, False, None, {}, ()) + number = detail.get("tool_call_number") + name = detail.get("tool_name") + error = detail.get("error") + file_path = detail.get("file_path") + if ( + not isinstance(number, int) + or isinstance(number, bool) + or not 1 <= number <= total + or number in call_numbers + or not isinstance(name, str) + or name not in normalized + or not isinstance(error, str) + or not error + or len(error) > MAX_TOOL_FAILURE_ERROR_CHARS + or ( + file_path is not None + and (not isinstance(file_path, str) or len(file_path) > MAX_TOOL_FAILURE_PATH_CHARS) + ) + ): + return ToolFailureTelemetry(True, False, None, {}, ()) + call_numbers.add(number) + detail_counts[name] += 1 + normalized_details.append(ToolFailureDetail(number, name, file_path, error)) + if dict(detail_counts) != normalized: + return ToolFailureTelemetry(True, False, None, {}, ()) + normalized_details.sort(key=lambda detail: detail.number) + return ToolFailureTelemetry(True, True, failed, normalized, tuple(normalized_details)) + + +def _safe_tool_failure_log_field(value: str) -> str: + """Redact and compact one OCR-controlled field before CI console output.""" + + return compact_text(redact_sensitive(value), MAX_TOOL_FAILURE_LOG_FIELD_CHARS) + + +def _render_tool_failure_diagnostics(telemetry: ToolFailureTelemetry) -> tuple[str, ...]: + """Render bounded diagnostics for the local/CI console, never MR publication.""" + + if not telemetry.present: + return () + if not telemetry.valid: + return ( + "OCR tool failure diagnostics: malformed additive telemetry ignored; " + "authoritative review result retained.", + ) + if not telemetry.failed: + return () + lines = [f"OCR tool failures: {telemetry.failed} failed call(s)."] + for detail in telemetry.details[:MAX_TOOL_FAILURE_DETAILS_LOGGED]: + tool = _safe_tool_failure_log_field(detail.tool) + path = ( + f" path={_safe_tool_failure_log_field(detail.path)}" if detail.path is not None else "" + ) + error = _safe_tool_failure_log_field(detail.error) + lines.append(f"- call={detail.number} tool={tool}{path} error={error}") + omitted = telemetry.failed - min(telemetry.failed, MAX_TOOL_FAILURE_DETAILS_LOGGED) + if omitted: + lines.append(f"- ... {omitted} additional failed call(s) omitted") + return tuple(lines) + + +def _print_tool_failure_diagnostics(telemetry: ToolFailureTelemetry | None) -> None: + """Write safe failed-tool detail to the current process stderr/CI job log.""" + + if telemetry is None: + return + for line in _render_tool_failure_diagnostics(telemetry): + print(line, file=sys.stderr) + + +def _closed_tool_calls( + value: object, + *, + allowed_tools: frozenset[str], + failures: ToolFailureTelemetry | None = None, +) -> dict[str, object]: """Project only bounded counters needed to prove model-side toolkit use.""" if not isinstance(value, dict): @@ -858,10 +1054,18 @@ def _closed_tool_calls(value: object, *, allowed_tools: frozenset[str]) -> dict[ and not isinstance(count, bool) and 0 < count <= MAX_TOOLKIT_MCP_USAGE_COUNT } - return {"total": total, "by_tool": dict(sorted(projected.items()))} + closed: dict[str, object] = { + "total": total, + "by_tool": dict(sorted(projected.items())), + } + if failures is not None and failures.present and failures.valid: + closed["failure"] = failures.failed + return closed -def _canonical_result_projection(payload: dict[str, object]) -> bytes: +def _canonical_result_projection( + payload: dict[str, object], *, failures: ToolFailureTelemetry | None = None +) -> bytes: """Serialize every publication and approval input into one closed comparison.""" outcome = parse_result_outcome(payload) @@ -881,7 +1085,9 @@ def _canonical_result_projection(payload: dict[str, object]) -> bytes: for comment in comments ] projected_tool_calls = _closed_tool_calls( - payload.get("tool_calls"), allowed_tools=PUBLIC_REVIEW_TOOL_CALL_NAMES + payload.get("tool_calls"), + allowed_tools=PUBLIC_REVIEW_TOOL_CALL_NAMES, + failures=failures, ) projection = { "outcome": { @@ -924,7 +1130,7 @@ def _publication_projection( forbidden: tuple[str, ...], allowed_tools: frozenset[str], ) -> tuple[dict[str, object], dict[str, object], bool]: - """Return a DLP-safe result plus one exact v7 publication state.""" + """Return a DLP-safe result plus one exact v8 publication state.""" budgets = TextBudgets(max_chars=2_000_000, max_bytes=8_000_000, max_lines=100_000) matcher = ForbiddenMatcher.compile(forbidden) @@ -938,21 +1144,40 @@ def _publication_projection( allow_horizontal_tabs=allow_horizontal_tabs, ) ) + failures = _tool_failure_telemetry(payload.get("tool_calls")) + sanitization_payload = payload + raw_tool_calls = payload.get("tool_calls") + if failures.present and isinstance(raw_tool_calls, dict): + # The details are a validated local/CI diagnostic channel, not an MR + # publication sink or approval input. Remove them before generic DLP; + # their dedicated console renderer applies redaction and tighter bounds. + sanitization_payload = dict(payload) + sanitization_payload["tool_calls"] = { + key: value + for key, value in raw_tool_calls.items() + if key not in {"failure", "failure_by_tool", "failure_details"} + } sanitized, private_reasons, redacted_fields = _sanitize_nonpublication_fields( - payload, budgets=budgets, matcher=matcher + sanitization_payload, budgets=budgets, matcher=matcher ) + if failures.present: + sanitized["tool_calls"] = _closed_tool_calls( + payload.get("tool_calls"), allowed_tools=allowed_tools, failures=failures + ) if not sink_reasons and not private_reasons and redacted_fields == 0: - return payload, {"state": "passed"}, False + return sanitized if failures.present else payload, {"state": "passed"}, False try: - original_projection = _canonical_result_projection(payload) + original_projection = _canonical_result_projection(payload, failures=failures) except OcrResultContractError: original_projection = None outcome = parse_result_outcome(payload) publication_changed = bool(sink_reasons) if not publication_changed: try: - publication_changed = original_projection != _canonical_result_projection(sanitized) + publication_changed = original_projection != _canonical_result_projection( + sanitized, failures=failures + ) except OcrResultContractError: publication_changed = True if publication_changed: @@ -972,7 +1197,7 @@ def _publication_projection( "comments": comments, "warnings": warnings, "tool_calls": _closed_tool_calls( - payload.get("tool_calls"), allowed_tools=allowed_tools + payload.get("tool_calls"), allowed_tools=allowed_tools, failures=failures ), } if outcome.budget_exceeded: @@ -1027,16 +1252,18 @@ def _finalize_ocr_result( filtered = False publication: dict[str, object] = {"state": "passed"} usage: dict[str, int] = {} + failure_telemetry = ToolFailureTelemetry(False, True, None, {}, ()) allowed_tools = PUBLIC_REVIEW_TOOL_CALL_NAMES def finalize(payload: dict[str, object]) -> dict[str, object]: - nonlocal filtered, publication, usage + nonlocal failure_telemetry, filtered, publication, usage for reserved in (TOOLKIT_RESULT_KEY, TOOLKIT_ADVISORY_KEY): if reserved in payload: raise OcrResultMalformed(f"OCR result contains reserved field {reserved!r}") warnings = payload.get("warnings", []) if not isinstance(warnings, list): raise OcrResultMalformed("OCR result warnings must be a list") + failure_telemetry = _tool_failure_telemetry(payload.get("tool_calls")) metadata = _review_receipt( payload, composition, @@ -1073,6 +1300,7 @@ def finalize(payload: dict[str, object]) -> dict[str, object]: except OSError: pass raise + _print_tool_failure_diagnostics(failure_telemetry) return usage, filtered, publication @@ -1328,6 +1556,7 @@ def _qualify_review_background( stdin=subprocess.DEVNULL, stdout=stdout_file, stderr=stderr_file, + env=_ocr_child_environment(), ) except OSError as exc: raise ReviewRunnerError(f"could not execute OCR background preview: {exc}") from exc @@ -1670,7 +1899,7 @@ def _bounded_combined_records( def _remediation_mutable_admitted(records: Sequence[ContextRecord]) -> bool: - """Report only admitted remediation as the receipt-v7 comment-only condition.""" + """Report only admitted remediation as the receipt-v8 comment-only condition.""" return any( record.mutable and record.resource_class == "remediation_thread" for record in records @@ -2325,6 +2554,7 @@ def run_review( stdin=subprocess.DEVNULL, stdout=result_file, stderr=stderr_file, + env=_ocr_child_environment(), ) except OSError as exc: raise ReviewRunnerError(f"could not execute OCR: {exc}") from exc diff --git a/tests/support.py b/tests/support.py index 9ac2310..53dce38 100644 --- a/tests/support.py +++ b/tests/support.py @@ -75,7 +75,7 @@ def gitlab_config( ) -def review_receipt_v7( +def review_receipt_v8( *, usage: dict[str, int] | None = None, attempted: dict[str, int] | None = None, @@ -83,7 +83,7 @@ def review_receipt_v7( context_tool_usage: dict[str, int] | None = None, mandatory: bool = True, ) -> dict[str, Any]: - """Return one exact synthetic receipt v7 for posting-boundary tests.""" + """Return one exact synthetic receipt v8 for posting-boundary tests.""" attempted = ( attempted @@ -141,7 +141,7 @@ def review_receipt_v7( if server != "ocr_toolkit_evidence" ) return { - "schema_version": 7, + "schema_version": 8, "review": { "source_sha": "a" * 40, "policy_sha": "b" * 40, @@ -176,5 +176,6 @@ def review_receipt_v7( }, }, "publication": {"state": "passed"}, + "tool_execution": {"state": "absent", "failed": None}, "cleanup": {"result": "passed"}, } diff --git a/tests/test_environment_contract.py b/tests/test_environment_contract.py index 45e669e..8fef1a3 100644 --- a/tests/test_environment_contract.py +++ b/tests/test_environment_contract.py @@ -70,8 +70,8 @@ } EXAMPLE_DEFAULTS = { - "OCR_VERSION": "v1.11.2", - "OCR_SHA256": "fad3c62314478822c1e90f858f3e06b3f8dc5ff0034933927c3ecd7faa5d3731", + "OCR_VERSION": "v1.11.3", + "OCR_SHA256": "9726204ac81baee153fd65b1ff357c380f73e9d8091c4a73c3c9fb541b5164cb", "OCR_TOOLKIT_VERSION": STABLE_TOOLKIT_VERSION, "OCR_TOOLKIT_CHECKSUMS_URL": "Release URL derived from `OCR_TOOLKIT_VERSION`", "OCR_TOOLKIT_WHEEL": "open_code_review_toolkit-${OCR_TOOLKIT_VERSION}-py3-none-any.whl", @@ -112,6 +112,10 @@ "ANTHROPIC_API_KEY", } +CHILD_ENVIRONMENT_DENYLIST = { + "OCR_RAW_LOGGING", +} + REMOVED_PUBLIC_INPUTS = { "OCR_GITLAB_BOT_USER_ID", "OCR_RUN_HELPER_TESTS", @@ -204,6 +208,7 @@ def test_documented_environment_tables_are_complete_and_exact() -> None: documented_names = set(RUNTIME_DEFAULTS) | set(GITLAB_DEFAULTS) | set(EXAMPLE_DEFAULTS) assert documented_names.isdisjoint(REDACTION_ONLY) + assert documented_names.isdisjoint(CHILD_ENVIRONMENT_DENYLIST) assert REDACTION_ONLY.issubset(SENSITIVE_ENV_NAMES) assert documented_names.isdisjoint(REMOVED_PUBLIC_INPUTS | {"OCR_USE_ANTHROPIC"}) @@ -230,6 +235,7 @@ def test_source_environment_inventory_matches_the_documented_contract() -> None: set(RUNTIME_DEFAULTS) | (set(GITLAB_DEFAULTS) - {"CI_PIPELINE_SOURCE"}) | REDACTION_ONLY + | CHILD_ENVIRONMENT_DENYLIST | {"OCR_USE_ANTHROPIC"} ) assert source_names == expected @@ -300,7 +306,7 @@ def test_example_local_defaults_match_the_pipeline() -> None: encoding="utf-8" ) for name, value in { - "OCR_VERSION": "v1.11.2", + "OCR_VERSION": "v1.11.3", "OCR_SHA256": EXAMPLE_DEFAULTS["OCR_SHA256"], "OCR_TOOLKIT_VERSION": STABLE_TOOLKIT_VERSION, "OCR_MAX_TOOLS": "0", diff --git a/tests/test_gitlab_provider.py b/tests/test_gitlab_provider.py index 0928ce4..86ec7c2 100644 --- a/tests/test_gitlab_provider.py +++ b/tests/test_gitlab_provider.py @@ -981,7 +981,7 @@ def test_evidence_review_crosses_provider_git_store_mcp_and_subprocess_boundarie assert _git(checkout, "status", "--short") == "" payload = json.loads(result.read_text(encoding="utf-8")) assert payload["_ocr_toolkit"] == { - "schema_version": 7, + "schema_version": 8, "review": { "source_sha": head, "policy_sha": policy, @@ -1038,6 +1038,7 @@ def test_evidence_review_crosses_provider_git_store_mcp_and_subprocess_boundarie }, }, "publication": {"state": "passed"}, + "tool_execution": {"state": "absent", "failed": None}, "cleanup": {"result": "passed"}, } for path in ( diff --git a/tests/test_integration_contracts.py b/tests/test_integration_contracts.py index 6bae16a..fde04b8 100644 --- a/tests/test_integration_contracts.py +++ b/tests/test_integration_contracts.py @@ -303,20 +303,20 @@ def test_public_docs_describe_the_established_m5_boundary() -> None: "ocr.context-adapter-response/v1", "context_list", "context_get", - "receipt v7", + "receipt v8", "schema_version", "no store or receipt migration path", "semantic paraphrase", ): assert contract in bounded for document in (configuration, gitlab, operations, security): - assert "receipt v7" in document + assert "receipt v8" in document assert "review-context.md" in document assert "M5's foundation is established in v0.7.0" in strategy - assert "Toolkit 0.9.0 advances the current result boundary to receipt v7" in strategy + assert "Toolkit 0.9.0 advances the current result boundary to receipt v8" in strategy assert "It is not protected-policy equivalence" in strategy assert "M5 Bounded review-context enrichment
established" in roadmap - assert "Toolkit 0.9.0 adds receipt-v7 source/target/protection binding" in roadmap + assert "Toolkit 0.9.0 adds receipt-v8 source/target/protection binding" in roadmap assert "DLP-clean metadata, generic discussions, and adapter records" in strategy assert "v0.8.0 release tree completes its remediation/provider-neutral extension" in roadmap assert "explicit owner waiver for the separate enriched OCR+LLM receipt" in roadmap diff --git a/tests/test_ocr_compat.py b/tests/test_ocr_compat.py index 284d6fa..671a156 100644 --- a/tests/test_ocr_compat.py +++ b/tests/test_ocr_compat.py @@ -37,14 +37,24 @@ def release(version: str, *, body: str = "fix: correct parser bug") -> dict[str, } +def manifest_before_1_11_3(module: ModuleType) -> dict[str, Any]: + """Return the committed support chain as it stood before 1.11.3 promotion.""" + + manifest = module.load_json(MANIFEST) + manifest["recommended_version"] = "1.11.2" + manifest["monitoring_floor"] = "1.11.2" + manifest["releases"] = [item for item in manifest["releases"] if item["version"] != "1.11.3"] + return manifest + + def test_committed_manifest_is_valid_and_has_recommended_tested_baseline() -> None: module = load_script() manifest = module.load_json(MANIFEST) module.validate_manifest(manifest, PROJECT_ROOT) - assert manifest["recommended_version"] == "1.11.2" - assert manifest["monitoring_floor"] == "1.11.2" + assert manifest["recommended_version"] == "1.11.3" + assert manifest["monitoring_floor"] == "1.11.3" assert [(item["version"], item["status"]) for item in manifest["releases"]] == [ ("1.7.17", "tested"), ("1.8.0", "tested"), @@ -75,6 +85,7 @@ def test_committed_manifest_is_valid_and_has_recommended_tested_baseline() -> No ("1.11.0", "tested"), ("1.11.1", "tested"), ("1.11.2", "tested"), + ("1.11.3", "tested"), ] @@ -82,7 +93,7 @@ def test_language_probe_generation_and_validation_share_canonical_order() -> Non """Keep regenerated evidence byte-compatible with the manifest validator.""" module = load_script() - for version in ("1.11.1", "1.11.2"): + for version in ("1.11.1", "1.11.2", "1.11.3"): evidence = module.load_json( PROJECT_ROOT / "compatibility" / "evidence" / f"ocr-{version}.json" ) @@ -172,7 +183,7 @@ def test_discovery_filters_known_prerelease_and_old_versions() -> None: def test_discovery_pages_until_the_monitoring_floor() -> None: module = load_script() - manifest = module.load_json(MANIFEST) + manifest = manifest_before_1_11_3(module) first_page = [release("1.11.3")] first_page.extend({"draft": True} for _ in range(module.MAX_RELEASES_PER_PAGE - 1)) second_page = [release("1.11.2")] @@ -191,7 +202,7 @@ def fake_request(url: str) -> list[dict[str, Any]]: def test_discovery_fails_when_bounded_pages_do_not_reach_floor() -> None: module = load_script() - manifest = module.load_json(MANIFEST) + manifest = manifest_before_1_11_3(module) page = [release("1.11.3")] page.extend({"draft": True} for _ in range(module.MAX_RELEASES_PER_PAGE - 1)) @@ -235,7 +246,7 @@ def test_qualification_matrix_preserves_a_release_gap_for_human_review() -> None def test_qualification_matrix_accepts_the_next_manual_patch() -> None: module = load_script() - manifest = module.load_json(MANIFEST) + manifest = manifest_before_1_11_3(module) matrix = module.qualification_matrix(manifest, [release("1.11.3")]) @@ -1189,7 +1200,7 @@ def test_schema_three_candidate_remains_chain_aware() -> None: """Current behavioral evidence participates in the adjacent release chain.""" module = load_script() - manifest = module.load_json(MANIFEST) + manifest = manifest_before_1_11_3(module) evidence = { "schema_version": 3, "version": "1.11.3", @@ -1672,11 +1683,11 @@ def test_prepare_update_rejects_human_review_candidate(tmp_path: Path) -> None: module = load_script() evidence = { "schema_version": 2, - "version": "1.11.3", + "version": "1.11.4", "result": "compatible", "classification": "human-review-required", - "comparison_version": "1.11.2", - "tested_baseline_version": "1.11.2", + "comparison_version": "1.11.3", + "tested_baseline_version": "1.11.3", } with pytest.raises(module.CompatibilityError, match="bounded conclusion"): @@ -1695,8 +1706,8 @@ def test_prepare_update_requires_human_review_for_minor_transition() -> None: "version": "1.12.0", "result": "compatible", "classification": "automatic-safe", - "comparison_version": "1.11.2", - "tested_baseline_version": "1.11.2", + "comparison_version": "1.11.3", + "tested_baseline_version": "1.11.3", } with pytest.raises(module.CompatibilityError, match="explicit human review"): @@ -1755,11 +1766,11 @@ def test_prepare_update_rejects_conclusion_outside_evidence_chain() -> None: module = load_script() evidence = { "schema_version": 2, - "version": "1.11.3", + "version": "1.11.4", "result": "compatible", "classification": "automatic-safe", - "comparison_version": "1.11.2", - "tested_baseline_version": "1.11.2", + "comparison_version": "1.11.3", + "tested_baseline_version": "1.11.3", } with pytest.raises(module.CompatibilityError, match="only evidence versions"): @@ -1767,7 +1778,7 @@ def test_prepare_update_rejects_conclusion_outside_evidence_chain() -> None: manifest_path=MANIFEST, evidence=evidence, fragment_number=72, - human_conclusions={"1.11.4": "Synthetic unrelated conclusion."}, + human_conclusions={"1.11.5": "Synthetic unrelated conclusion."}, root=PROJECT_ROOT, ) @@ -1779,11 +1790,11 @@ def test_prepare_update_rejects_invalid_optional_reviewed_conclusion( module = load_script() evidence = { "schema_version": 2, - "version": "1.11.3", + "version": "1.11.4", "result": "compatible", "classification": "automatic-safe", - "comparison_version": "1.11.2", - "tested_baseline_version": "1.11.2", + "comparison_version": "1.11.3", + "tested_baseline_version": "1.11.3", } with pytest.raises(module.CompatibilityError, match="bounded plain text"): @@ -1791,7 +1802,7 @@ def test_prepare_update_rejects_invalid_optional_reviewed_conclusion( manifest_path=MANIFEST, evidence=evidence, fragment_number=72, - human_conclusions={"1.11.3": conclusion}, + human_conclusions={"1.11.4": conclusion}, root=PROJECT_ROOT, ) diff --git a/tests/test_operations_docs.py b/tests/test_operations_docs.py index 200ca26..fa3c3c7 100644 --- a/tests/test_operations_docs.py +++ b/tests/test_operations_docs.py @@ -12,6 +12,7 @@ GITLAB_EXAMPLES = PROJECT_ROOT / "examples" / "gitlab" CODE_OF_CONDUCT = PROJECT_ROOT / "CODE_OF_CONDUCT.md" SIGNAL_OWNERSHIP = PROJECT_ROOT / "docs" / "engineering" / "review_signal_ownership.md" +DECISION_FLOW = PROJECT_ROOT / "docs" / "review-decision-flow.md" def test_readme_product_and_security_badges_link_to_authoritative_results() -> None: @@ -98,6 +99,7 @@ def test_documentation_indexes_route_to_canonical_owners() -> None: "configuration.md", "gitlab.md", "operations.md", + "review-decision-flow.md", "review-context.md", "security.md", "development.md", @@ -133,7 +135,7 @@ def test_review_signal_audit_keeps_group_data_outside_toolkit_authority() -> Non for phrase in ( "Source-to-signal matrix", - "receipt v7", + "receipt v8", "action-receipt-v3 attempted/completed accounting", "Group labels are model-produced", "sorted changed paths", @@ -147,7 +149,7 @@ def test_review_signal_audit_keeps_group_data_outside_toolkit_authority() -> Non assert phrase in audit assert "Review measurement gaps (BL-017) | Completed and removed" in backlog assert "M6 Profiles and quality measurement | Established / conditional" in roadmap - assert "Current receipt v7 adds immutable target/protection identity" in roadmap + assert "Current receipt v8 adds immutable target/protection identity" in roadmap def test_community_conduct_policy_has_a_private_enforcement_route() -> None: @@ -193,6 +195,35 @@ def test_operations_guide_documents_lifecycle_contract() -> None: assert "previous review" in operations +def test_canonical_decision_flow_has_stable_palette_and_runtime_boundaries() -> None: + """Keep one linked detailed flow aligned with signal-preserving publication.""" + + flow = DECISION_FLOW.read_text(encoding="utf-8") + strategy = (PROJECT_ROOT / "docs" / "engineering" / "toolkit_strategy.md").read_text( + encoding="utf-8" + ) + root_instructions = (PROJECT_ROOT / "AGENTS.md").read_text(encoding="utf-8") + + assert flow.count("```mermaid") == 3 + for color in ("#d1fae5", "#ffedd5", "#fee2e2", "#f3f4f6", "#dbeafe"): + assert flow.count(color) == 3 + for phrase in ( + "core integrity boundary deliberately precedes additive diagnostics", + "cannot replace an otherwise valid manifest, findings, summary, or posting transaction", + "toolkit action receipt v3 remains authoritative", + "not a local-mode exception", + "not an automatic-approval feature", + "publication owns review-signal delivery", + "receipt v8", + ): + assert phrase in flow + assert "review-decision-flow.md" in strategy + assert "docs/review-decision-flow.md" in root_instructions + assert ( + "the review decision flow for execution or publication branch changes" in root_instructions + ) + + def test_legacy_commands_are_only_documented_as_removed() -> None: operations = OPERATIONS.read_text(encoding="utf-8") readme = README.read_text(encoding="utf-8") @@ -252,7 +283,7 @@ def test_completion_cap_and_provider_failure_boundaries_are_public() -> None: assert "provider-specific" in document for document in (configuration, operations, gitlab): assert "OCR_LLM_MAX_COMPLETION_TOKENS=4096" not in document - current_compatibility = compatibility.split("### OCR 1.11.2 — toolkit 0.9.0 target", 1)[1] + current_compatibility = compatibility.split("### OCR 1.11.3 — toolkit 0.9.0 target", 1)[1] assert "explicit positive completion-cap transport" in current_compatibility assert "default remains unset" in current_compatibility assert "provider-specific cap" in current_compatibility @@ -363,9 +394,9 @@ def test_context_receipt_and_mcp_profile_contracts_are_public() -> None: assert "`metadata`" in document assert "`enriched`" in document assert 'OCR_REVIEW_CONTEXT_MODE: "off"' in example - assert "receipt v7" in configuration - assert "Receipt v1-v6" in configuration - assert "Receipt v1-v6" in operations + assert "receipt v8" in configuration + assert "Receipt v1-v7" in configuration + assert "Receipt v1-v7" in operations assert "complete `metadata` context" in operations.lower() assert "Every configured direct external MCP" in configuration assert "required context degradation" in operations @@ -374,7 +405,7 @@ def test_context_receipt_and_mcp_profile_contracts_are_public() -> None: assert "sole stdio exception" in configuration -def test_builtin_search_coverage_and_receipt_v7_boundaries_are_public() -> None: +def test_builtin_search_coverage_and_receipt_v8_boundaries_are_public() -> None: """Document efficient routing without exposing search or coverage arguments.""" configuration = CONFIGURATION.read_text(encoding="utf-8") @@ -395,7 +426,7 @@ def test_builtin_search_coverage_and_receipt_v7_boundaries_are_public() -> None: "absence_authoritative=true", "Stop once the required evidence is sufficient", "action receipt v3", - "Receipt v7", + "Receipt v8", ): assert phrase in configuration assert "DLP-admitted store" in security @@ -474,7 +505,7 @@ def test_unprotected_target_contract_is_complete_and_fail_closed() -> None: assert "OCR_GITLAB_TARGET_PROTECTION_MODE" in document assert "required" in document assert "unprotected" in document - assert "receipt v7" in document + assert "receipt v8" in document for phrase in ( "Explicit empty strings", "Context `off` and bounded untrusted `metadata`", @@ -648,7 +679,8 @@ def test_ocr_compatibility_workflow_is_bounded_and_protected() -> None: "OCR 1.10.2 — toolkit 0.8.5 target", "OCR 1.11.0 — toolkit 0.8.6 target", "OCR 1.11.1 — toolkit 0.8.7 target", - "OCR 1.11.2 — toolkit 0.9.0 target", + "OCR 1.11.2 — toolkit 0.9.0 qualification predecessor", + "OCR 1.11.3 — toolkit 0.9.0 target", "ocr.toolkit-advisory/v1", "ocr.llm-retry-report/v1", "not toolkit telemetry", @@ -657,7 +689,7 @@ def test_ocr_compatibility_workflow_is_bounded_and_protected() -> None: "Deploy toolkit 0.8.5 directly with OCR 1.10.2", "Deploy toolkit 0.8.6 directly with OCR 1.11.0", "Deploy toolkit 0.8.7 directly with OCR 1.11.1", - "Deploy toolkit 0.9.0 directly with OCR 1.11.2", + "Deploy toolkit 0.9.0 directly with OCR 1.11.3", "max-tools runtime behavior is unchanged", "max_completion_tokens=16384", "do not install OCR 1.9.10 as an intermediate step", diff --git a/tests/test_posting_approval.py b/tests/test_posting_approval.py index efda340..1891004 100644 --- a/tests/test_posting_approval.py +++ b/tests/test_posting_approval.py @@ -44,12 +44,13 @@ def finding(category: Any = "style", severity: Any = "low") -> dict[str, Any]: return {"category": category, "severity": severity} -def receipt_v7( +def receipt_v8( *, context_state: str = "disabled", external: bool = False, author_id: int | None = 41, target_protection: str = "protected", + failed_tools: int = 0, ) -> dict[str, Any]: """Return one closed synthetic review-time receipt.""" @@ -70,7 +71,7 @@ def receipt_v7( {"server": "documentation", "transport": "remote", "tools": ["docs_read"]} ) return { - "schema_version": 7, + "schema_version": 8, "review": { "source_sha": "a" * 40, "policy_sha": "b" * 40, @@ -117,12 +118,13 @@ def receipt_v7( }, }, "publication": {"state": "passed"}, + "tool_execution": {"state": "verified", "failed": failed_tools}, "cleanup": {"result": "passed"}, } def test_unprotected_receipt_is_valid_but_structurally_comment_only() -> None: - receipt = receipt_v7(target_protection="unprotected") + receipt = receipt_v8(target_protection="unprotected") assert approval.toolkit_receipt_is_valid(receipt) decision = approval.evaluate_approval_policy( @@ -135,7 +137,7 @@ def test_unprotected_receipt_is_valid_but_structurally_comment_only() -> None: def test_unprotected_receipt_never_reaches_approval_mutation_path() -> None: - receipt = receipt_v7(target_protection="unprotected") + receipt = receipt_v8(target_protection="unprotected") eligibility = approval.evaluate_approval_policy( settings.BooleanSetting(True), complete_outcome(), [], [], 0, receipt ) @@ -161,12 +163,29 @@ def test_unprotected_receipt_never_reaches_approval_mutation_path() -> None: assert result.result == eligibility.result +@pytest.mark.parametrize("state", ["invalid", "conflicting"]) +def test_additive_failure_diagnostic_uncertainty_is_a_valid_nonapproval_receipt( + state: str, +) -> None: + """Do not turn diagnostic uncertainty into an invalid publication receipt.""" + + receipt = receipt_v8() + receipt["tool_execution"] = {"state": state, "failed": None} + + assert approval.toolkit_receipt_is_valid(receipt) + decision = approval.evaluate_approval_policy( + settings.BooleanSetting(True), complete_outcome(), [], [], 0, receipt + ) + assert decision.eligible is False + assert decision.result.reason == "OCR tool failure diagnostics were malformed or contradictory" + + @pytest.mark.parametrize( "value", (None, "", "required", "UNPROTECTED", True, False, 0, {}, [], "unprotected "), ) def test_hostile_target_protection_states_invalidate_receipt(value: Any) -> None: - receipt = receipt_v7() + receipt = receipt_v8() receipt["review"]["target_protection"] = value assert not approval.toolkit_receipt_is_valid(receipt) assert workflow.unprotected_target_limitation(receipt) is False @@ -178,7 +197,7 @@ def test_target_sha_is_exactly_bound_to_policy_sha() -> None: lambda review: review.pop("target_sha"), lambda review: review.update({"extra": "c" * 40}), ): - receipt = receipt_v7() + receipt = receipt_v8() mutate(receipt["review"]) assert not approval.toolkit_receipt_is_valid(receipt) @@ -199,14 +218,14 @@ def eligibility( comments or [], warnings or [], omitted, - receipt_v7(), + receipt_v8(), ) def enriched_receipt(*, mutable: bool = False, required_degraded: bool = False) -> dict[str, Any]: - """Return one v7 local-store-only enrichment receipt.""" + """Return one v8 local-store-only enrichment receipt.""" - receipt = receipt_v7() + receipt = receipt_v8() receipt["context"] = { "mode": "enriched", "state": "degraded" if required_degraded else "complete", @@ -307,7 +326,7 @@ def test_degraded_context_receipt_blocks_without_exposing_provider_text(self) -> [], [], 0, - receipt_v7(context_state="degraded", author_id=41), + receipt_v8(context_state="degraded", author_id=41), ) self.assertFalse(decision.eligible) @@ -315,7 +334,7 @@ def test_degraded_context_receipt_blocks_without_exposing_provider_text(self) -> self.assertEqual(decision.result.reason, "the selected review context was degraded") def test_publication_dlp_filtered_receipt_is_valid_but_never_eligible(self) -> None: - receipt = receipt_v7() + receipt = receipt_v8() receipt["publication"] = { "state": "publication-filtered", "reason_counts": { @@ -358,7 +377,7 @@ def test_provider_private_fields_cannot_enter_approval_receipt(self) -> None: """Reject replay or request-control fields at the closed receipt boundary.""" for field in ("reasoning_content", "native_payload", "tool_choice"): - receipt = receipt_v7() + receipt = receipt_v8() receipt[field] = "private" with self.subTest(field=field): @@ -413,7 +432,7 @@ def test_filtered_receipt_rejects_outcomes_that_contradict_coverage(self) -> Non self.assertIsNone(approval.publication_dlp_state(candidate)) def test_private_only_sanitization_keeps_existing_approval_gates(self) -> None: - receipt = receipt_v7() + receipt = receipt_v8() receipt["publication"] = { "state": "private-sanitized", "reason_counts": { @@ -434,7 +453,7 @@ def test_private_only_sanitization_keeps_existing_approval_gates(self) -> None: self.assertTrue(decision.eligible) def test_evidence_action_attribution_does_not_change_approval_eligibility(self) -> None: - receipt = receipt_v7() + receipt = receipt_v8() decision = approval.evaluate_approval_policy( settings.BooleanSetting(True), complete_outcome(), [], [], 0, receipt ) @@ -448,7 +467,7 @@ def test_evidence_action_attribution_does_not_change_approval_eligibility(self) self.assertIn("receipt is missing or invalid", decision.result.reason) def test_failed_attempts_do_not_authorize_successful_evidence_or_approval(self) -> None: - receipt = receipt_v7() + receipt = receipt_v8() actions = receipt["evidence"]["actions"] actions["attempted"]["get"] = 1 receipt["evidence"]["calls"] = 2 @@ -474,10 +493,10 @@ def test_complete_metadata_and_external_mcp_have_independent_approval_effects(se [], [], 0, - receipt_v7(context_state="complete", author_id=41), + receipt_v8(context_state="complete", author_id=41), ) external = approval.evaluate_approval_policy( - settings.BooleanSetting(True), complete_outcome(), [], [], 0, receipt_v7(external=True) + settings.BooleanSetting(True), complete_outcome(), [], [], 0, receipt_v8(external=True) ) self.assertTrue(complete_metadata.eligible) @@ -527,7 +546,7 @@ def test_optional_context_mutation_remains_visible_without_becoming_required_fai self.assertTrue(decision.eligible) def test_receipt_accepts_ocr_compatible_non_identifier_tool_names(self) -> None: - metadata = receipt_v7(external=True) + metadata = receipt_v8(external=True) metadata["mcp"]["capabilities"][1]["tools"] = ["repo.search", "records/read"] decision = approval.evaluate_approval_policy( @@ -540,8 +559,8 @@ def test_receipt_accepts_ocr_compatible_non_identifier_tool_names(self) -> None: "external MCP was configured for a comment-only review", ) - def test_every_pre_v7_receipt_is_rejected(self) -> None: - for version in range(1, 7): + def test_every_pre_v8_receipt_is_rejected(self) -> None: + for version in range(1, 8): with self.subTest(version=version): decision = approval.evaluate_approval_policy( settings.BooleanSetting(True), @@ -558,15 +577,15 @@ def test_every_pre_v7_receipt_is_rejected(self) -> None: "the review-time approval receipt is missing or invalid", ) - def test_missing_or_malformed_v7_receipt_fails_closed(self) -> None: - cases: list[Any] = [None, {"schema_version": 7}] + def test_missing_or_malformed_v8_receipt_fails_closed(self) -> None: + cases: list[Any] = [None, {"schema_version": 8}] for mutate in ( lambda value: value["context"].update({"state": "complete"}), lambda value: value["review"].update({"source_sha": "invalid"}), lambda value: value["mcp"].update({"usage": {"unknown": 1}}), lambda value: value["evidence"].update({"used": False}), ): - candidate = receipt_v7() + candidate = receipt_v8() mutate(candidate) cases.append(candidate) for metadata in cases: @@ -587,7 +606,7 @@ def test_group_diagnostics_cannot_extend_the_closed_receipt(self) -> None: ("groups", [{"label": "core", "files": ["src/core.py"]}]), ("review_rounds", 2), ): - receipt = receipt_v7() + receipt = receipt_v8() receipt[field] = value decision = approval.evaluate_approval_policy( @@ -603,40 +622,40 @@ def test_group_diagnostics_cannot_extend_the_closed_receipt(self) -> None: def test_impossible_v6_capability_and_evidence_states_fail_closed(self) -> None: cases: list[dict[str, Any]] = [] - builtin_remote = receipt_v7() + builtin_remote = receipt_v8() builtin_remote["mcp"]["capabilities"][0]["transport"] = "remote" cases.append(builtin_remote) - external_builtin = receipt_v7(external=True) + external_builtin = receipt_v8(external=True) external_builtin["mcp"]["capabilities"][1]["transport"] = "builtin" cases.append(external_builtin) - wrong_builtin_tool = receipt_v7() + wrong_builtin_tool = receipt_v8() wrong_builtin_tool["mcp"]["capabilities"][0]["tools"] = ["other_read"] cases.append(wrong_builtin_tool) - duplicate_tool = receipt_v7(external=True) + duplicate_tool = receipt_v8(external=True) duplicate_tool["mcp"]["capabilities"][1]["tools"] = ["ocr_toolkit_evidence"] cases.append(duplicate_tool) - too_many_tools = receipt_v7(external=True) + too_many_tools = receipt_v8(external=True) too_many_tools["mcp"]["capabilities"][1]["tools"] = [ f"tool_{index}" for index in range(129) ] cases.append(too_many_tools) - usage_mismatch = receipt_v7() + usage_mismatch = receipt_v8() usage_mismatch["mcp"]["usage"] = {} cases.append(usage_mismatch) - usage_overflow = receipt_v7() + usage_overflow = receipt_v8() usage_overflow["mcp"]["usage"] = {"ocr_toolkit_evidence": 1_000_000_001} cases.append(usage_overflow) - missing_author = receipt_v7(author_id=None) + missing_author = receipt_v8(author_id=None) cases.append(missing_author) - mandatory_mismatch = receipt_v7() + mandatory_mismatch = receipt_v8() mandatory_mismatch["evidence"] = {"mandatory": False, "used": True} cases.append(mandatory_mismatch) @@ -1016,7 +1035,7 @@ def tearDown(self) -> None: settings.post_mode.cache_clear() def test_receipt_identity_is_atomic_for_summary_and_approval(self) -> None: - valid = receipt_v7(author_id=41) + valid = receipt_v8(author_id=41) self.assertEqual(workflow.approval_receipt_identity(valid), ("a" * 40, 41)) for mutate in ( @@ -1026,7 +1045,7 @@ def test_receipt_identity_is_atomic_for_summary_and_approval(self) -> None: lambda value: value.update({"cleanup": {"result": "unknown"}}), lambda value: value.update({"extra": True}), ): - candidate = receipt_v7(author_id=41) + candidate = receipt_v8(author_id=41) mutate(candidate) with self.subTest(candidate=candidate): self.assertEqual(workflow.approval_receipt_identity(candidate), ("", None)) @@ -1034,7 +1053,7 @@ def test_receipt_identity_is_atomic_for_summary_and_approval(self) -> None: def test_valid_receipt_binds_advisory_without_changing_summary_or_approval_inputs(self) -> None: """Publish one closed advisory in Technical details with ordinary clean status.""" - receipt = receipt_v7(author_id=41) + receipt = receipt_v8(author_id=41) self.assertTrue(approval.toolkit_receipt_is_valid(receipt)) notes: list[str] = [] @@ -1127,7 +1146,7 @@ def _assert_unprotected_limitation_is_adjacent_once( summary: dict[str, bool], comments: list[dict[str, Any]], ) -> None: - receipt = receipt_v7(target_protection="unprotected") + receipt = receipt_v8(target_protection="unprotected") if status in {"skipped", "failed"}: receipt["evidence"] = { "mandatory": False, @@ -1207,14 +1226,14 @@ def capture(_config: Any, _title: str, body: str, *_args: Any) -> dict[str, int] status_index = next(index for index, line in enumerate(visible) if "**Review" in line) assert visible[status_index + 1] == limitation - def test_unprotected_limitation_requires_fully_validated_v7_receipt(self) -> None: - valid = receipt_v7(target_protection="unprotected") + def test_unprotected_limitation_requires_fully_validated_v8_receipt(self) -> None: + valid = receipt_v8(target_protection="unprotected") assert workflow.unprotected_target_limitation(valid) for candidate in ( {**valid, "schema_version": 6}, {**valid, "cleanup": {"result": "unknown"}}, {**valid, "extra": True}, - receipt_v7(target_protection="unprotected", external=True), + receipt_v8(target_protection="unprotected", external=True), ): assert workflow.unprotected_target_limitation(candidate) is False @@ -1223,7 +1242,7 @@ def test_complete_filtered_review_keeps_coverage_and_activity_dimensions_separat ) -> None: """Render scenario B without inventing partial coverage or a failed item.""" - receipt = receipt_v7(author_id=41, target_protection="unprotected") + receipt = receipt_v8(author_id=41, target_protection="unprotected") receipt["publication"] = { "state": "publication-filtered", "reason_counts": { @@ -1311,7 +1330,7 @@ def capture_note( def test_private_sanitized_review_keeps_tool_and_token_activity_visible(self) -> None: """Keep independent numeric activity lines after private-only sanitization.""" - receipt = receipt_v7(author_id=41) + receipt = receipt_v8(author_id=41) receipt["publication"] = { "state": "private-sanitized", "reason_counts": { @@ -1468,7 +1487,7 @@ def test_unprotected_review_never_calls_approval_or_rewrites_summary(self) -> No [], [], 0, - receipt_v7(target_protection="unprotected"), + receipt_v8(target_protection="unprotected"), ) calls: list[str] = [] diff --git a/tests/test_posting_helpers.py b/tests/test_posting_helpers.py index 672d27b..f0fe649 100644 --- a/tests/test_posting_helpers.py +++ b/tests/test_posting_helpers.py @@ -49,7 +49,7 @@ gitlab_config, patched_attr, patched_env, - review_receipt_v7, + review_receipt_v8, ) @@ -97,7 +97,7 @@ def fake_api_request(*args: Any, **kwargs: Any) -> dict[str, int]: self.assertEqual(exit_code, 1) self.assertEqual(calls, []) - def test_invalid_v7_publication_state_never_reaches_normal_result_flow(self) -> None: + def test_invalid_legacy_publication_state_never_reaches_normal_result_flow(self) -> None: notes: list[str] = [] result_data = { "status": "failed", @@ -122,7 +122,7 @@ def test_invalid_v7_publication_state_never_reaches_normal_result_flow(self) -> self.assertEqual(exit_code, 0) self.assertEqual(notes, ["**Open Code Review publication policy error**"]) - def test_advisory_without_exact_receipt_v7_never_reaches_normal_result_flow(self) -> None: + def test_advisory_without_exact_receipt_v8_never_reaches_normal_result_flow(self) -> None: """Treat a correctly shaped but unbound advisory as an invalid result.""" notes: list[str] = [] @@ -908,6 +908,89 @@ def capture_note( summary = next(note for note in notes if "## Open Code Review" in note) self.assertNotIn("img.shields.io", summary) + def test_diagnostic_uncertainty_does_not_replace_a_valid_review_with_schema_error(self) -> None: + """Carry the review through GitLab writes when only additive diagnostics are uncertain.""" + + inline_bodies: list[str] = [] + notes: list[tuple[str, str]] = [] + receipt = review_receipt_v8() + receipt["tool_execution"] = {"state": "invalid", "failed": None} + result_data = { + "status": "complete", + "message": "Review completed with one actionable finding.", + "comments": [ + { + "path": "src/example.py", + "line": 7, + "content": "Guard the empty collection before indexing it.", + "category": "bug", + "severity": "medium", + } + ], + "warnings": [], + "tool_calls": { + "total": 2, + "by_tool": {"ocr_toolkit_evidence": 1, "file_read": 1}, + }, + "manifest": { + "schema_version": "ocr.run-manifest/v1", + "operation": "review", + "terminal_state": "complete", + "coverage": { + "selected": [{"item_id": "synthetic-item"}], + "completed": [{"item_id": "synthetic-item"}], + "reused": [], + "failed": [], + "waived": [], + }, + }, + ocr_result.TOOLKIT_RESULT_KEY: receipt, + } + + def capture_discussion(*_args: Any, **kwargs: Any) -> gitlab.GitLabWriteResult: + inline_bodies.append(kwargs["body"]) + return gitlab.GitLabWriteResult("posted") + + def capture_note( + _config: gitlab.GitLabConfig, + title: str, + body: str, + _drafts: list[int], + ) -> dict[str, int]: + notes.append((title, body)) + return {"id": len(notes)} + + with ( + patched_attr( + workflow, + "get_diff_refs", + lambda _config: {"base_sha": "a", "start_sha": "b", "head_sha": "c"}, + ), + patched_attr( + workflow, + "collect_previous_bot_comment_refs", + lambda _config: snapshot.BotCommentRefs(), + ), + patched_attr(workflow, "post_review_discussion", capture_discussion), + patched_attr(workflow, "post_review_note_bounded", capture_note), + patched_attr(workflow, "finalize_posting", lambda *_args: True), + patched_attr( + workflow, + "delete_previous_bot_comments_if_collected", + lambda *_args: None, + ), + redirect_stdout(io.StringIO()), + ): + exit_code = workflow.post_results(gitlab_config(), result_data) + + self.assertEqual(exit_code, 0) + self.assertEqual(len(inline_bodies), 1) + self.assertIn("Guard the empty collection", inline_bodies[0]) + published = "\n".join(f"{title}\n{body}" for title, body in notes) + self.assertIn("Review complete — 1 finding published", published) + self.assertNotIn("result schema error", published) + self.assertNotIn("publication policy error", published) + def test_retry_report_remains_private_from_gitlab_notes(self) -> None: notes: list[str] = [] @@ -1342,7 +1425,7 @@ def capture_note(_config: Any, *args: Any) -> dict[str, int]: "comments": [old, new], "warnings": [], "_ocr_toolkit": { - **review_receipt_v7(), + **review_receipt_v8(), "publication": publication, }, }, @@ -1359,7 +1442,7 @@ def capture_note(_config: Any, *args: Any) -> dict[str, int]: def test_present_invalid_receipt_never_reaches_normal_publication(self) -> None: notes: list[str] = [] collect_calls: list[str] = [] - receipt = review_receipt_v7() + receipt = review_receipt_v8() receipt.pop("review") with ( @@ -1421,7 +1504,7 @@ def capture_note( }, }, "_ocr_toolkit": { - "schema_version": 7, + "schema_version": 8, "review": { "source_sha": "a" * 40, "policy_sha": "b" * 40, @@ -1461,6 +1544,7 @@ def capture_note( "actions": {"state": "unavailable"}, }, "publication": {"state": "passed"}, + "tool_execution": {"state": "absent", "failed": None}, "cleanup": {"result": "passed"}, }, ocr_result.TOOLKIT_ADVISORY_KEY: ocr_result.toolkit_advisory_payload( @@ -2854,6 +2938,35 @@ def test_tool_calls_reports_every_useful_nonzero_counter_inline(self) -> None: ) self.assertNotIn("more", summary) + def test_tool_calls_never_renders_failure_detail_payloads(self) -> None: + """Keep additive OCR diagnostics in the CI console, not the MR summary.""" + + summary = posting_formatting.format_tool_calls_summary( + { + "total": 2, + "by_tool": {"file_read": 2}, + "failure": 1, + "failure_by_tool": {"dynamic-private-tool": 1}, + "failure_details": [ + { + "tool_call_number": 2, + "tool_name": "dynamic-private-tool", + "file_path": "private/path.py", + "error": "Authorization: Bearer private-token", + } + ], + } + ) + + self.assertEqual(summary, "- all OCR tool calls: 2 total (`file_read`: 2)") + for private_value in ( + "failure", + "dynamic-private-tool", + "private/path.py", + "private-token", + ): + self.assertNotIn(private_value, summary) + def test_tool_calls_omits_empty_zero_or_unknown_breakdowns(self) -> None: """Do not emit a technical line without a positive admitted counter list.""" @@ -3326,7 +3439,7 @@ def test_budget_summary_and_guide_mark_findings_as_partial(self) -> None: def test_mcp_usage_summary_reports_only_servers_actually_called(self) -> None: summary = posting_formatting.format_mcp_usage_summary( - review_receipt_v7( + review_receipt_v8( usage={"ocr_toolkit_evidence": 2, "documentation": 1}, attempted={ "summary": 1, @@ -3353,9 +3466,9 @@ def test_mcp_usage_summary_reports_only_servers_actually_called(self) -> None: ) self.assertNotIn("file_read", summary) - def test_mcp_usage_summary_reads_receipt_v7_inventory(self) -> None: + def test_mcp_usage_summary_reads_receipt_v8_inventory(self) -> None: summary = posting_formatting.format_mcp_usage_summary( - review_receipt_v7( + review_receipt_v8( attempted={ "summary": 1, "list": 2, @@ -3382,7 +3495,7 @@ def test_mcp_usage_summary_reads_receipt_v7_inventory(self) -> None: def test_mcp_usage_summary_renders_verified_action_breakdown(self) -> None: summary = posting_formatting.format_mcp_usage_summary( - review_receipt_v7( + review_receipt_v8( attempted={ "summary": 1, "list": 2, @@ -3434,7 +3547,7 @@ def test_mcp_usage_summary_omits_zero_or_unavailable_action_breakdown(self) -> N with self.subTest(attempted=attempted): self.assertEqual( posting_formatting.format_mcp_usage_summary( - review_receipt_v7( + review_receipt_v8( attempted=attempted, completed=completed, mandatory=False, @@ -3445,7 +3558,7 @@ def test_mcp_usage_summary_omits_zero_or_unavailable_action_breakdown(self) -> N def test_mcp_usage_summary_never_renders_unattributed_attempts(self) -> None: summary = posting_formatting.format_mcp_usage_summary( - review_receipt_v7( + review_receipt_v8( attempted={ "summary": 1, "list": 0, @@ -3472,7 +3585,7 @@ def test_mcp_usage_summary_omits_hostile_or_unreconciled_action_breakdown(self) {"state": "verified", "summary": 1, "list": 2, "get": 1, "extra": 0}, ): with self.subTest(actions=actions): - receipt = review_receipt_v7() + receipt = review_receipt_v8() receipt["evidence"]["actions"] = actions summary = posting_formatting.format_mcp_usage_summary(receipt) @@ -3481,7 +3594,7 @@ def test_mcp_usage_summary_omits_hostile_or_unreconciled_action_breakdown(self) def test_mcp_usage_summary_reconciles_actions_after_context_calls(self) -> None: summary = posting_formatting.format_mcp_usage_summary( - review_receipt_v7( + review_receipt_v8( attempted={ "summary": 1, "list": 2, @@ -3515,7 +3628,7 @@ def test_mcp_usage_summary_rejects_malformed_usage_before_rendering(self) -> Non {"ocr_toolkit_evidence": 0}, ): with self.subTest(usage=usage): - receipt = review_receipt_v7() + receipt = review_receipt_v8() receipt["mcp"]["usage"] = usage self.assertEqual( posting_formatting.format_mcp_usage_summary(receipt), @@ -3523,7 +3636,7 @@ def test_mcp_usage_summary_rejects_malformed_usage_before_rendering(self) -> Non ) def test_mcp_usage_summary_requires_complete_valid_receipt(self) -> None: - receipt = review_receipt_v7() + receipt = review_receipt_v8() for mutate in ( lambda value: value.pop("review"), lambda value: value["review"].update({"source_sha": "invalid"}), @@ -5653,7 +5766,7 @@ def test_toolkit_metadata_is_reserved_and_schema_owned(self) -> None: lambda _payload: {"schema_version": 999, "publication": {"state": "passed"}}, ) - self.assertEqual(metadata["schema_version"], 7) + self.assertEqual(metadata["schema_version"], 8) self.assertEqual(transformed["_ocr_toolkit"], metadata) with self.assertRaisesRegex(ocr_result.OcrResultMalformed, "reserved field"): diff --git a/tests/test_review_runner.py b/tests/test_review_runner.py index d541ad6..fad1f57 100644 --- a/tests/test_review_runner.py +++ b/tests/test_review_runner.py @@ -90,6 +90,38 @@ def action_counts( SUMMARY_ACTION_COUNTS = action_counts() +def complete_review_payload(*, tool_calls: dict[str, object]) -> dict[str, object]: + """Return one complete review whose public signal must survive diagnostics.""" + + return { + "status": "complete", + "message": "Review completed with one actionable finding.", + "comments": [ + { + "path": "src/safe.py", + "line": 7, + "content": "Guard the empty collection before indexing it.", + "severity": "medium", + "category": "bug", + } + ], + "warnings": [], + "tool_calls": tool_calls, + "manifest": { + "schema_version": "ocr.run-manifest/v1", + "operation": "review", + "terminal_state": "complete", + "coverage": { + "selected": [{"item_id": "synthetic-item"}], + "completed": [{"item_id": "synthetic-item"}], + "reused": [], + "failed": [], + "waived": [], + }, + }, + } + + def enriched_identity() -> review_runner.ReviewIdentity: """Return one provider-normalized enriched-review identity.""" @@ -742,7 +774,7 @@ def test_ocr_result_requires_builtin_mcp_usage_for_completed_review(tmp_path: Pa result, composition, DEFAULT_IDENTITY, evidence_action_counts=counts ) == {"ocr_toolkit_evidence": 2} assert json.loads(result.read_text(encoding="utf-8"))["_ocr_toolkit"] == { - "schema_version": 7, + "schema_version": 8, "review": { "source_sha": "a" * 40, "policy_sha": "b" * 40, @@ -778,6 +810,7 @@ def test_ocr_result_requires_builtin_mcp_usage_for_completed_review(tmp_path: Pa "actions": {"state": "verified", **counts}, }, "publication": {"state": "passed"}, + "tool_execution": {"state": "absent", "failed": None}, "cleanup": {"result": "passed"}, } @@ -899,6 +932,239 @@ def test_generated_mr_receipt_is_validated_before_atomic_publication( assert not result.exists() +@pytest.mark.parametrize( + "diagnostics", + [ + {"failure": 1}, + {"failure": True, "failure_by_tool": {}, "failure_details": []}, + {"failure": -1, "failure_by_tool": {}, "failure_details": []}, + { + "failure": 1, + "failure_by_tool": {"file_read": 1}, + "failure_details": [], + }, + { + "failure": 1, + "failure_by_tool": {"unknown_tool": 1}, + "failure_details": [ + {"tool_call_number": 2, "tool_name": "unknown_tool", "error": "failure"} + ], + }, + { + "failure": 1, + "failure_by_tool": {"file_read": 1}, + "failure_details": [ + { + "tool_call_number": 99, + "tool_name": "file_read", + "error": "failure", + } + ], + }, + ], +) +def test_additive_failure_diagnostics_cannot_discard_a_valid_review_signal( + tmp_path: Path, + diagnostics: dict[str, object], +) -> None: + """Keep findings postable when only OCR's additive diagnostics are malformed.""" + + result = tmp_path / "result.json" + payload = complete_review_payload( + tool_calls={ + "total": 2, + "by_tool": {"ocr_toolkit_evidence": 1, "file_read": 1}, + **diagnostics, + } + ) + result.write_text(json.dumps(payload), encoding="utf-8") + composition = MCPComposition( + payload={}, + capabilities=(MCPCapability("ocr_toolkit_evidence", BUILTIN_EVIDENCE_TOOLS, True),), + external_servers=(), + secret_values=(), + ) + operator_log = io.StringIO() + + with redirect_stderr(operator_log): + _usage, filtered, publication = review_runner._finalize_ocr_result( + result, + composition, + DEFAULT_IDENTITY, + None, + SUMMARY_ACTION_COUNTS, + forbidden=(), + ) + + persisted = json.loads(result.read_text(encoding="utf-8")) + assert filtered is False + assert publication == {"state": "passed"} + assert persisted["status"] == "complete" + assert persisted["comments"] == payload["comments"] + assert persisted["manifest"] == payload["manifest"] + assert persisted["tool_calls"] == { + "total": 2, + "by_tool": {"file_read": 1, "ocr_toolkit_evidence": 1}, + } + receipt = persisted[ocr_result.TOOLKIT_RESULT_KEY] + assert approval.toolkit_receipt_is_valid(receipt) + assert receipt["tool_execution"] == {"state": "invalid", "failed": None} + assert "authoritative review result retained" in operator_log.getvalue() + + +def test_valid_failure_details_are_console_only_and_keep_the_review_postable( + tmp_path: Path, +) -> None: + """Publish findings while retaining only a safe aggregate failed-call status.""" + + result = tmp_path / "result.json" + secret = "synthetic-provider-token-123456" + payload = complete_review_payload( + tool_calls={ + "total": 2, + "by_tool": {"ocr_toolkit_evidence": 1, "file_read": 1}, + "failure": 1, + "failure_by_tool": {"file_read": 1}, + "failure_details": [ + { + "tool_call_number": 2, + "tool_name": "file_read", + "file_path": "src/private\npath.py\x1b[31m", + "error": f"Authorization: Bearer {secret}\n/merge\x1b[2J timeout", + } + ], + } + ) + result.write_text(json.dumps(payload), encoding="utf-8") + composition = MCPComposition( + payload={}, + capabilities=(MCPCapability("ocr_toolkit_evidence", BUILTIN_EVIDENCE_TOOLS, True),), + external_servers=(), + secret_values=(), + ) + operator_log = io.StringIO() + + with patched_env(OCR_LLM_TOKEN=secret), redirect_stderr(operator_log): + _usage, filtered, publication = review_runner._finalize_ocr_result( + result, + composition, + DEFAULT_IDENTITY, + None, + SUMMARY_ACTION_COUNTS, + forbidden=(secret,), + ) + + persisted = json.loads(result.read_text(encoding="utf-8")) + serialized = result.read_text(encoding="utf-8") + assert filtered is False + assert publication == {"state": "passed"} + assert persisted["comments"] == payload["comments"] + assert persisted["tool_calls"] == { + "total": 2, + "by_tool": {"file_read": 1, "ocr_toolkit_evidence": 1}, + "failure": 1, + } + receipt = persisted[ocr_result.TOOLKIT_RESULT_KEY] + assert approval.toolkit_receipt_is_valid(receipt) + assert receipt["tool_execution"] == {"state": "verified", "failed": 1} + for private_value in (secret, "failure_details", "failure_by_tool", "/merge", "\x1b"): + assert private_value not in serialized + diagnostics = operator_log.getvalue() + assert diagnostics.count("\n") == 2 + assert "call=2 tool=file_read path=src/private path.py" in diagnostics + assert "Authorization: ***" in diagnostics + assert secret not in diagnostics + assert "\x1b" not in diagnostics + + +def test_local_review_also_keeps_signal_when_failure_diagnostics_are_malformed( + tmp_path: Path, +) -> None: + """Apply the signal-preservation invariant independently of provider mode.""" + + result = tmp_path / "result.json" + payload = complete_review_payload( + tool_calls={ + "total": 1, + "by_tool": {"ocr_toolkit_evidence": 1}, + "failure": "unknown", + "failure_by_tool": {"ocr_toolkit_evidence": 1}, + "failure_details": {"error": "not a list"}, + } + ) + result.write_text(json.dumps(payload), encoding="utf-8") + composition = MCPComposition( + payload={}, + capabilities=(MCPCapability("ocr_toolkit_evidence", BUILTIN_EVIDENCE_TOOLS, True),), + external_servers=(), + secret_values=(), + ) + + review_runner._finalize_ocr_result( + result, + composition, + LOCAL_IDENTITY, + None, + SUMMARY_ACTION_COUNTS, + forbidden=(), + ) + + persisted = json.loads(result.read_text(encoding="utf-8")) + assert persisted["comments"] == payload["comments"] + assert persisted["manifest"] == payload["manifest"] + assert persisted["tool_calls"] == { + "total": 1, + "by_tool": {"ocr_toolkit_evidence": 1}, + } + assert ocr_result.TOOLKIT_RESULT_KEY not in persisted + + +def test_ocr_failure_diagnostic_conflict_does_not_override_completed_evidence( + tmp_path: Path, +) -> None: + """Prefer the toolkit-owned completion receipt over contradictory OCR diagnostics.""" + + result = tmp_path / "result.json" + payload = complete_review_payload( + tool_calls={ + "total": 1, + "by_tool": {"ocr_toolkit_evidence": 1}, + "failure": 1, + "failure_by_tool": {"ocr_toolkit_evidence": 1}, + "failure_details": [ + { + "tool_call_number": 1, + "tool_name": "ocr_toolkit_evidence", + "error": "upstream classified the completed call as failed", + } + ], + } + ) + result.write_text(json.dumps(payload), encoding="utf-8") + composition = MCPComposition( + payload={}, + capabilities=(MCPCapability("ocr_toolkit_evidence", BUILTIN_EVIDENCE_TOOLS, True),), + external_servers=(), + secret_values=(), + ) + + review_runner._finalize_ocr_result( + result, + composition, + DEFAULT_IDENTITY, + None, + SUMMARY_ACTION_COUNTS, + forbidden=(), + ) + + persisted = json.loads(result.read_text(encoding="utf-8")) + assert persisted["comments"] == payload["comments"] + receipt = persisted[ocr_result.TOOLKIT_RESULT_KEY] + assert receipt["evidence"]["used"] is True + assert receipt["tool_execution"] == {"state": "conflicting", "failed": None} + assert approval.toolkit_receipt_is_valid(receipt) + + def test_local_finalization_uses_direct_posting_without_a_provider_receipt( tmp_path: Path, monkeypatch: pytest.MonkeyPatch ) -> None: @@ -1346,7 +1612,7 @@ def test_ocr_result_receipt_blocks_approval_when_mr_context_was_admitted( ) assert json.loads(result.read_text(encoding="utf-8"))["_ocr_toolkit"] == { - "schema_version": 7, + "schema_version": 8, "review": { "source_sha": "a" * 40, "policy_sha": "b" * 40, @@ -1382,6 +1648,7 @@ def test_ocr_result_receipt_blocks_approval_when_mr_context_was_admitted( "actions": {"state": "verified", **SUMMARY_ACTION_COUNTS}, }, "publication": {"state": "passed"}, + "tool_execution": {"state": "absent", "failed": None}, "cleanup": {"result": "passed"}, } @@ -3112,11 +3379,19 @@ def test_ocr_result_receipt_rejects_hard_link_without_rewriting(tmp_path: Path) def test_run_review_unit_wires_argv_and_artifact_streams_to_subprocess() -> None: def fake_run(argv: list[str], **kwargs: object) -> subprocess.CompletedProcess[bytes]: assert argv == ["ocr", "review", "--from", "base", "--to", "head"] + environment = kwargs["env"] + assert isinstance(environment, dict) + assert "OCR_RAW_LOGGING" not in environment + assert environment["OCR_LLM_TOKEN"] == "synthetic-provider-token" kwargs["stdout"].write(b'{"comments": []}\n') # type: ignore[union-attr] kwargs["stderr"].write(b"review complete\n") # type: ignore[union-attr] return subprocess.CompletedProcess(argv, 0) - with TemporaryDirectory() as tmp, patched_attr(review_runner.subprocess, "run", fake_run): + with ( + TemporaryDirectory() as tmp, + patched_env(OCR_RAW_LOGGING="1", OCR_LLM_TOKEN="synthetic-provider-token"), + patched_attr(review_runner.subprocess, "run", fake_run), + ): result_path = Path(tmp) / "artifacts" / "result.json" stderr_path = Path(tmp) / "artifacts" / "stderr.log" exit_code = review_runner.run_review( @@ -3551,6 +3826,10 @@ def test_background_preview_uses_exact_production_argv_and_cleans_artifacts( def fake_run(argv: list[str], **kwargs: object) -> subprocess.CompletedProcess[bytes]: argv_seen.extend(argv) + environment = kwargs["env"] + assert isinstance(environment, dict) + assert "OCR_RAW_LOGGING" not in environment + assert environment["OCR_LLM_TOKEN"] == "synthetic-provider-token" kwargs["stdout"].write(b'{"files":[]}\n') # type: ignore[union-attr] kwargs["stderr"].write( # type: ignore[union-attr] b"[ocr] --background-file content is 2100 characters, exceeding the recommended " @@ -3572,11 +3851,12 @@ def fake_run(argv: list[str], **kwargs: object) -> subprocess.CompletedProcess[b "/private/bootstrap.md", ] - result = review_runner._qualify_review_background( - production_args, - ocr_binary="/private/ocr", - session_home=session_home, - ) + with patched_env(OCR_RAW_LOGGING="1", OCR_LLM_TOKEN="synthetic-provider-token"): + result = review_runner._qualify_review_background( + production_args, + ocr_binary="/private/ocr", + session_home=session_home, + ) assert argv_seen == ["/private/ocr", "review", *production_args, "--preview"] assert result.advisory == ocr_result.OcrToolkitAdvisory( diff --git a/tests/test_runtime_helpers.py b/tests/test_runtime_helpers.py index aa5e9f5..af2e237 100644 --- a/tests/test_runtime_helpers.py +++ b/tests/test_runtime_helpers.py @@ -1569,7 +1569,7 @@ def test_invalid_json_error_does_not_echo_secret_payload(self) -> None: class PreflightTests(unittest.TestCase): def test_validate_ocr_binary_accepts_supported_version(self) -> None: completed = subprocess.CompletedProcess( - args=["ocr", "--version"], returncode=0, stdout="ocr 1.11.2\n", stderr="" + args=["ocr", "--version"], returncode=0, stdout="ocr 1.11.3\n", stderr="" ) with ( patched_attr(preflight.shutil, "which", lambda _name: "/usr/bin/ocr"),