diff --git a/apps/api/src/services/gitea_private_inventory_p0_scorecard.py b/apps/api/src/services/gitea_private_inventory_p0_scorecard.py index 74728ce35..2e46a535d 100644 --- a/apps/api/src/services/gitea_private_inventory_p0_scorecard.py +++ b/apps/api/src/services/gitea_private_inventory_p0_scorecard.py @@ -41,6 +41,7 @@ def _build_payload(scorecard: dict[str, Any], path: Path) -> dict[str, Any]: gitea_inventory = _dict(scorecard.get("gitea_inventory")) import_acceptance = _dict(scorecard.get("authenticated_import_acceptance")) coverage_attestation = _dict(scorecard.get("coverage_attestation")) + owner_response_validation = _dict(scorecard.get("owner_response_validation")) product_coverage = _dict(scorecard.get("product_row_coverage")) active_blockers = _strings(scorecard.get("active_blockers")) exit_criteria = _strings(scorecard.get("exit_criteria")) @@ -70,6 +71,7 @@ def _build_payload(scorecard: dict[str, Any], path: Path) -> dict[str, Any]: "gitea_inventory": gitea_inventory, "authenticated_import_acceptance": import_acceptance, "coverage_attestation": coverage_attestation, + "owner_response_validation": owner_response_validation, "product_row_coverage": product_coverage, "active_blockers": active_blockers, "exit_criteria": exit_criteria, @@ -93,6 +95,25 @@ def _build_payload(scorecard: dict[str, Any], path: Path) -> dict[str, Any]: "owner_coverage_attestation_accepted_count": _int( coverage_attestation.get("accepted_attestation_count") ), + "owner_response_validation_received_count": _int( + owner_response_validation.get("received_response_count") + ), + "owner_response_validation_accepted_count": _int( + owner_response_validation.get("accepted_response_count") + ), + "owner_response_validation_rejected_count": _int( + owner_response_validation.get("rejected_response_count") + ), + "owner_response_validation_template_count": _int( + owner_response_validation.get("response_template_count") + ), + "owner_response_validation_guard_result": str( + owner_response_validation.get("latest_local_validation_result") or "" + ), + "next_owner_response_validation_lane_id": str( + owner_response_validation.get("next_collection_candidate_lane_id") + or "" + ), "expected_product_count": _int( product_coverage.get("expected_product_count") ), @@ -186,6 +207,36 @@ def _require_source_scorecard(payload: dict[str, Any], label: str) -> None: coverage_attestation = _dict(payload.get("coverage_attestation")) if coverage_attestation.get("execution_authorized") is not False: raise ValueError(f"{label}: attestation write must remain false") + owner_response_validation = _dict(payload.get("owner_response_validation")) + if ( + owner_response_validation.get("lane_id") + != "s4_9_gitea_inventory_owner_attestation_response" + ): + raise ValueError(f"{label}: owner response validation lane must be S4.9") + if ( + owner_response_validation.get("validator_source") + != "scripts/security/source-control-owner-response-guard.py" + ): + raise ValueError(f"{label}: owner response validator source mismatch") + owner_response_blocked_flags = { + "runtime_execution_authorized", + "token_value_collection_allowed", + "secret_value_collection_allowed", + "gitea_repo_write_authorized", + "refs_sync_authorized", + "github_primary_switch_authorized", + "action_buttons_allowed", + } + owner_response_drift = sorted( + flag + for flag in owner_response_blocked_flags + if owner_response_validation.get(flag) is not False + ) + if owner_response_drift: + raise ValueError( + f"{label}: owner response validation boundaries must remain false: " + f"{owner_response_drift}" + ) def _require_operation_boundaries(payload: dict[str, Any], label: str) -> None: @@ -236,6 +287,11 @@ def _require_rollup_consistency(payload: dict[str, Any], label: str) -> None: is not True ): raise ValueError(f"{label}: active product owner readiness rows incomplete") + if ( + rollups.get("next_owner_response_validation_lane_id") + != "s4_9_gitea_inventory_owner_attestation_response" + ): + raise ValueError(f"{label}: next owner response validation lane mismatch") def _met_exit_criteria( diff --git a/apps/api/tests/test_gitea_private_inventory_p0_scorecard_api.py b/apps/api/tests/test_gitea_private_inventory_p0_scorecard_api.py index 613c00f70..32a32b905 100644 --- a/apps/api/tests/test_gitea_private_inventory_p0_scorecard_api.py +++ b/apps/api/tests/test_gitea_private_inventory_p0_scorecard_api.py @@ -26,6 +26,17 @@ def test_gitea_private_inventory_p0_scorecard_reports_active_gitea_blocker(): assert payload["rollups"]["gitea_visibility_scope"] == "public_only" assert payload["rollups"]["accepted_inventory_payload_count"] == 0 assert payload["rollups"]["owner_coverage_attestation_received_count"] == 0 + assert payload["rollups"]["owner_response_validation_received_count"] == 0 + assert payload["rollups"]["owner_response_validation_accepted_count"] == 0 + assert payload["rollups"]["owner_response_validation_template_count"] == 5 + assert ( + payload["rollups"]["owner_response_validation_guard_result"] + == "SOURCE_CONTROL_OWNER_RESPONSE_GUARD_OK" + ) + assert ( + payload["rollups"]["next_owner_response_validation_lane_id"] + == "s4_9_gitea_inventory_owner_attestation_response" + ) assert payload["rollups"]["expected_product_count"] == 11 assert payload["rollups"]["present_product_row_count"] == 11 assert payload["rollups"]["missing_product_row_count"] == 0 @@ -47,6 +58,12 @@ def test_gitea_private_inventory_p0_scorecard_endpoint_returns_readback(): data = response.json() assert data["priority"] == "P0-003" assert data["rollups"]["active_blocker_count"] == 4 + assert data["owner_response_validation"]["received_response_count"] == 0 + assert data["owner_response_validation"]["accepted_response_count"] == 0 + assert ( + data["owner_response_validation"]["validator_source"] + == "scripts/security/source-control-owner-response-guard.py" + ) assert data["operation_boundaries"]["read_only_api_allowed"] is True assert data["operation_boundaries"]["gitea_api_write_allowed"] is False assert data["operation_boundaries"]["gitea_repo_creation_allowed"] is False @@ -87,6 +104,22 @@ def test_gitea_private_inventory_p0_scorecard_rejects_token_collection(tmp_path) load_latest_gitea_private_inventory_p0_scorecard(operations_dir) +def test_gitea_private_inventory_p0_scorecard_rejects_owner_response_runtime_unlock( + tmp_path, +): + operations_dir = tmp_path / "operations" + operations_dir.mkdir() + payload = _scorecard_payload() + payload["owner_response_validation"]["runtime_execution_authorized"] = True + (operations_dir / "awoooi-gitea-private-inventory-p0-scorecard.snapshot.json").write_text( + json.dumps(payload), + encoding="utf-8", + ) + + with pytest.raises(ValueError, match="owner response validation boundaries"): + load_latest_gitea_private_inventory_p0_scorecard(operations_dir) + + def _scorecard_payload() -> dict: return { "schema_version": "awoooi_gitea_private_inventory_p0_scorecard_v1", @@ -120,6 +153,34 @@ def _scorecard_payload() -> dict: "accepted_attestation_count": 0, "execution_authorized": False, }, + "owner_response_validation": { + "schema_version": "source_control_owner_response_validation_rollup_v1", + "status": "draft_waiting_owner_responses", + "rollup_status": "waiting_owner_responses", + "lane_id": "s4_9_gitea_inventory_owner_attestation_response", + "source_contract": "gitea_inventory_owner_attestation_response_v1", + "response_packet": ( + "docs/security/gitea-inventory-owner-attestation-response.snapshot.json" + ), + "response_template_count": 5, + "received_response_count": 0, + "accepted_response_count": 0, + "rejected_response_count": 0, + "next_collection_candidate_lane_id": ( + "s4_9_gitea_inventory_owner_attestation_response" + ), + "next_collection_candidate_status": "next_owner_response_required", + "latest_local_validation_status": "repo_snapshot_guard_pass", + "latest_local_validation_result": "SOURCE_CONTROL_OWNER_RESPONSE_GUARD_OK", + "validator_source": "scripts/security/source-control-owner-response-guard.py", + "runtime_execution_authorized": False, + "token_value_collection_allowed": False, + "secret_value_collection_allowed": False, + "gitea_repo_write_authorized": False, + "refs_sync_authorized": False, + "github_primary_switch_authorized": False, + "action_buttons_allowed": False, + }, "product_row_coverage": { "expected_product_count": 11, "present_product_row_count": 11, diff --git a/docs/operations/awoooi-gitea-private-inventory-p0-scorecard.snapshot.json b/docs/operations/awoooi-gitea-private-inventory-p0-scorecard.snapshot.json index b30ba04b0..a7158a903 100644 --- a/docs/operations/awoooi-gitea-private-inventory-p0-scorecard.snapshot.json +++ b/docs/operations/awoooi-gitea-private-inventory-p0-scorecard.snapshot.json @@ -1,6 +1,6 @@ { "schema_version": "awoooi_gitea_private_inventory_p0_scorecard_v1", - "generated_at": "2026-06-29T15:55:00+08:00", + "generated_at": "2026-06-29T19:07:18+08:00", "workplan_id": "P0-003", "status": "blocked_waiting_gitea_authenticated_or_owner_export_inventory", "source_control_authority": "gitea", @@ -46,6 +46,30 @@ "accepted_attestation_count": 0, "execution_authorized": false }, + "owner_response_validation": { + "schema_version": "source_control_owner_response_validation_rollup_v1", + "status": "draft_waiting_owner_responses", + "rollup_status": "waiting_owner_responses", + "lane_id": "s4_9_gitea_inventory_owner_attestation_response", + "source_contract": "gitea_inventory_owner_attestation_response_v1", + "response_packet": "docs/security/gitea-inventory-owner-attestation-response.snapshot.json", + "response_template_count": 5, + "received_response_count": 0, + "accepted_response_count": 0, + "rejected_response_count": 0, + "next_collection_candidate_lane_id": "s4_9_gitea_inventory_owner_attestation_response", + "next_collection_candidate_status": "next_owner_response_required", + "latest_local_validation_status": "repo_snapshot_guard_pass", + "latest_local_validation_result": "SOURCE_CONTROL_OWNER_RESPONSE_GUARD_OK", + "validator_source": "scripts/security/source-control-owner-response-guard.py", + "runtime_execution_authorized": false, + "token_value_collection_allowed": false, + "secret_value_collection_allowed": false, + "gitea_repo_write_authorized": false, + "refs_sync_authorized": false, + "github_primary_switch_authorized": false, + "action_buttons_allowed": false + }, "product_row_coverage": { "expected_product_count": 11, "present_product_row_count": 11, diff --git a/scripts/security/gitea-private-inventory-p0-scorecard.py b/scripts/security/gitea-private-inventory-p0-scorecard.py index 48976bae3..ad71183b2 100644 --- a/scripts/security/gitea-private-inventory-p0-scorecard.py +++ b/scripts/security/gitea-private-inventory-p0-scorecard.py @@ -33,6 +33,14 @@ def parse_args() -> argparse.Namespace: type=Path, default=ROOT / "docs/security/gitea-inventory-coverage-attestation.snapshot.json", ) + parser.add_argument( + "--owner-response-validation-rollup", + type=Path, + default=( + ROOT + / "docs/security/source-control-owner-response-validation-rollup.snapshot.json" + ), + ) parser.add_argument( "--payload-validation", type=Path, @@ -102,12 +110,69 @@ def build_product_rows(remaining: dict[str, Any]) -> list[dict[str, Any]]: return rows +def find_validation_lane(rollup: dict[str, Any], lane_id: str) -> dict[str, Any]: + for lane in as_list(rollup.get("validation_lanes")): + if isinstance(lane, dict) and lane.get("lane_id") == lane_id: + return lane + return {} + + +def build_owner_response_validation(rollup: dict[str, Any]) -> dict[str, Any]: + summary = as_dict(rollup.get("summary")) + lane = find_validation_lane( + rollup, + "s4_9_gitea_inventory_owner_attestation_response", + ) + next_candidate = as_dict(rollup.get("next_collection_candidate")) + latest_validation = as_dict(rollup.get("latest_local_validation")) + return { + "schema_version": rollup.get("schema_version", ""), + "status": str(rollup.get("status", "not_run")), + "rollup_status": str(summary.get("rollup_status", "unknown")), + "lane_id": str(lane.get("lane_id", "")), + "source_contract": str(lane.get("source_contract", "")), + "response_packet": str(lane.get("response_packet", "")), + "response_template_count": as_int(lane.get("response_template_count")), + "received_response_count": as_int(lane.get("received_response_count")), + "accepted_response_count": as_int(lane.get("accepted_response_count")), + "rejected_response_count": as_int(lane.get("rejected_response_count")), + "next_collection_candidate_lane_id": str(next_candidate.get("lane_id", "")), + "next_collection_candidate_status": str( + next_candidate.get("display_status", "") + ), + "latest_local_validation_status": str(latest_validation.get("status", "")), + "latest_local_validation_result": str(latest_validation.get("result", "")), + "validator_source": "scripts/security/source-control-owner-response-guard.py", + "runtime_execution_authorized": bool( + summary.get("runtime_execution_authorized", False) + ), + "token_value_collection_allowed": bool( + summary.get("token_value_collection_allowed", False) + ), + "secret_value_collection_allowed": bool( + summary.get("secret_value_collection_allowed", False) + ), + "gitea_repo_write_authorized": bool( + summary.get("gitea_repo_write_authorized", False) + ), + "refs_sync_authorized": bool(summary.get("refs_sync_authorized", False)), + "github_primary_switch_authorized": bool( + summary.get("github_primary_switch_authorized", False) + ), + "action_buttons_allowed": bool(summary.get("action_buttons_allowed", False)), + } + + def build_scorecard(args: argparse.Namespace) -> dict[str, Any]: gitea_inventory = load_json(args.gitea_inventory) import_acceptance = load_json(args.import_acceptance) coverage_attestation = load_json(args.coverage_attestation) + owner_response_validation_rollup = load_json(args.owner_response_validation_rollup) payload_validation = load_optional_json(args.payload_validation) remaining_products = load_json(args.remaining_products) + owner_response_validation = build_owner_response_validation( + owner_response_validation_rollup + ) rows = build_product_rows(remaining_products) summary = remaining_products.get("summary", {}) @@ -192,6 +257,7 @@ def build_scorecard(args: argparse.Namespace) -> dict[str, Any]: ), "execution_authorized": bool(coverage_attestation.get("execution_authorized", False)), }, + "owner_response_validation": owner_response_validation, "product_row_coverage": { "expected_product_count": expected_product_count, "present_product_row_count": len(rows), diff --git a/scripts/security/tests/test_gitea_private_inventory_p0_scorecard.py b/scripts/security/tests/test_gitea_private_inventory_p0_scorecard.py index 58753fb9c..d22d41126 100644 --- a/scripts/security/tests/test_gitea_private_inventory_p0_scorecard.py +++ b/scripts/security/tests/test_gitea_private_inventory_p0_scorecard.py @@ -56,6 +56,28 @@ def test_scorecard_preserves_current_gitea_inventory_blocker() -> None: == "scripts/security/gitea-authenticated-inventory-payload-validator.py" ) assert scorecard["coverage_attestation"]["received_attestation_count"] == 0 + assert ( + scorecard["owner_response_validation"]["lane_id"] + == "s4_9_gitea_inventory_owner_attestation_response" + ) + assert scorecard["owner_response_validation"]["response_template_count"] == 5 + assert scorecard["owner_response_validation"]["received_response_count"] == 0 + assert scorecard["owner_response_validation"]["accepted_response_count"] == 0 + assert ( + scorecard["owner_response_validation"]["next_collection_candidate_lane_id"] + == "s4_9_gitea_inventory_owner_attestation_response" + ) + assert ( + scorecard["owner_response_validation"]["latest_local_validation_result"] + == "SOURCE_CONTROL_OWNER_RESPONSE_GUARD_OK" + ) + assert ( + scorecard["owner_response_validation"]["validator_source"] + == "scripts/security/source-control-owner-response-guard.py" + ) + assert scorecard["owner_response_validation"]["runtime_execution_authorized"] is False + assert scorecard["owner_response_validation"]["gitea_repo_write_authorized"] is False + assert scorecard["owner_response_validation"]["refs_sync_authorized"] is False assert "gitea_repo_inventory_status_not_ok" in scorecard["active_blockers"] assert "gitea_authenticated_inventory_payload_not_accepted" in scorecard["active_blockers"]