diff --git a/apps/api/src/services/github_target_private_backup_evidence_gate.py b/apps/api/src/services/github_target_private_backup_evidence_gate.py index 51935638d..9c75a78e7 100644 --- a/apps/api/src/services/github_target_private_backup_evidence_gate.py +++ b/apps/api/src/services/github_target_private_backup_evidence_gate.py @@ -21,6 +21,7 @@ _OWNER_RESPONSE_FILE = "github-target-owner-decision-response.snapshot.json" _APPROVAL_PACKAGE_FILE = "github-target-repo-approval-package.snapshot.json" _PROBE_FILE = "github-target-probe.snapshot.json" _CONNECTOR_READBACK_FILE = "github-target-connector-readback.snapshot.json" +_MISSING_SOURCE_READINESS_FILE = "github-target-missing-source-readiness.snapshot.json" def load_latest_github_target_private_backup_evidence_gate( @@ -33,6 +34,7 @@ def load_latest_github_target_private_backup_evidence_gate( approval_package = _load_snapshot(directory / _APPROVAL_PACKAGE_FILE) probe = _load_snapshot(directory / _PROBE_FILE) connector_readback = _load_optional_snapshot(directory / _CONNECTOR_READBACK_FILE) + missing_source_readiness = _load_optional_snapshot(directory / _MISSING_SOURCE_READINESS_FILE) _require_source_contracts( decision=decision, @@ -40,6 +42,7 @@ def load_latest_github_target_private_backup_evidence_gate( approval_package=approval_package, probe=probe, connector_readback=connector_readback, + missing_source_readiness=missing_source_readiness, ) return build_github_target_private_backup_evidence_gate( decision=decision, @@ -47,6 +50,7 @@ def load_latest_github_target_private_backup_evidence_gate( approval_package=approval_package, probe=probe, connector_readback=connector_readback, + missing_source_readiness=missing_source_readiness, ) @@ -57,10 +61,13 @@ def build_github_target_private_backup_evidence_gate( approval_package: dict[str, Any], probe: dict[str, Any], connector_readback: dict[str, Any] | None = None, + missing_source_readiness: dict[str, Any] | None = None, ) -> dict[str, Any]: """Build the read-only gate response from source-control snapshots.""" connector_payload = _dict(connector_readback) owner_readback = _dict(connector_payload.get("owner_readback")) + missing_payload = _dict(missing_source_readiness) + missing_summary = _dict(missing_payload.get("summary")) decisions = [_dict(row) for row in _list(decision.get("decisions"))] probe_by_repo = { str(row.get("github_repo")): _dict(row) @@ -77,6 +84,11 @@ def build_github_target_private_backup_evidence_gate( for row in _list(connector_payload.get("targets")) if row.get("github_repo") } + missing_source_by_repo = { + str(row.get("github_repo")): _dict(row) + for row in _list(missing_payload.get("targets")) + if row.get("github_repo") + } owner_summary = _dict(owner_response.get("summary")) targets = [ @@ -85,6 +97,7 @@ def build_github_target_private_backup_evidence_gate( probe=probe_by_repo.get(str(row.get("github_repo")), {}), approval_item=package_by_repo.get(str(row.get("github_repo")), {}), connector_readback=connector_by_repo.get(str(row.get("github_repo")), {}), + missing_source_readiness=missing_source_by_repo.get(str(row.get("github_repo")), {}), ) for row in decisions ] @@ -125,6 +138,7 @@ def build_github_target_private_backup_evidence_gate( "approval_package": f"docs/security/{_APPROVAL_PACKAGE_FILE}", "github_target_probe": f"docs/security/{_PROBE_FILE}", "github_connector_readback": f"docs/security/{_CONNECTOR_READBACK_FILE}", + "github_missing_source_readiness": f"docs/security/{_MISSING_SOURCE_READINESS_FILE}", }, "summary": { "target_decision_count": len(targets), @@ -147,6 +161,23 @@ def build_github_target_private_backup_evidence_gate( for row in _list(connector_payload.get("targets")) if _dict(row).get("readback_status") == "not_found_or_inaccessible" ), + "github_missing_target_resolution_count": len(_list(missing_payload.get("targets"))), + "github_missing_target_gitea_source_candidate_count": _int( + missing_summary.get("gitea_source_candidate_count") + ), + "github_missing_target_internal_remote_source_candidate_count": _int( + missing_summary.get("internal_remote_source_candidate_count") + ), + "github_missing_target_local_worktree_candidate_count": _int( + missing_summary.get("local_worktree_candidate_count") + ), + "github_missing_target_canonical_source_ambiguous_count": _int( + missing_summary.get("canonical_source_ambiguous_count") + ), + "github_missing_target_create_private_repo_ready_count": _int( + missing_summary.get("create_private_repo_ready_count") + ), + "github_missing_target_refs_sync_ready_count": _int(missing_summary.get("refs_sync_ready_count")), "public_probe_visible_target_count": len(public_probe_visible_targets), "not_found_or_private_target_count": len(not_found_or_private_targets), "private_backup_verified_count": private_backup_verified_count, @@ -206,6 +237,7 @@ def _build_target( probe: dict[str, Any], approval_item: dict[str, Any], connector_readback: dict[str, Any], + missing_source_readiness: dict[str, Any], ) -> dict[str, Any]: github_repo = str(decision.get("github_repo") or "") probe_status = str(probe.get("status") or decision.get("probe_status") or "unknown") @@ -245,6 +277,15 @@ def _build_target( "visibility_evidence_status": visibility_status, "github_connector_readback_status": str(connector_readback.get("readback_status") or "not_checked"), "github_connector_visibility": connector_readback.get("visibility"), + "missing_target_source_resolution_status": missing_source_readiness.get("source_resolution_status"), + "missing_target_source_candidate_type": missing_source_readiness.get("source_candidate_type"), + "missing_target_gitea_repo": missing_source_readiness.get("gitea_repo"), + "missing_target_local_worktree_status": missing_source_readiness.get("local_worktree_status"), + "missing_target_create_private_repo_ready": missing_source_readiness.get("create_private_repo_ready"), + "missing_target_refs_sync_ready": missing_source_readiness.get("refs_sync_ready"), + "missing_target_required_owner_decisions": _strings( + missing_source_readiness.get("required_owner_decisions") + ), "private_backup_verified": private_visibility_verified, "private_visibility_owner_evidence_ref": connector_readback.get("evidence_ref"), "safe_credential_evidence_status": "not_collected", @@ -323,6 +364,7 @@ def _require_source_contracts( approval_package: dict[str, Any], probe: dict[str, Any], connector_readback: dict[str, Any], + missing_source_readiness: dict[str, Any], ) -> None: _require_schema(decision, "github_target_decision_v1", _DECISION_FILE) _require_schema(owner_response, "github_target_owner_decision_response_v1", _OWNER_RESPONSE_FILE) @@ -330,11 +372,19 @@ def _require_source_contracts( _require_schema(probe, "github_target_probe_v1", _PROBE_FILE) if connector_readback: _require_schema(connector_readback, "github_target_connector_readback_v1", _CONNECTOR_READBACK_FILE) + if missing_source_readiness: + _require_schema( + missing_source_readiness, + "github_target_missing_source_readiness_v1", + _MISSING_SOURCE_READINESS_FILE, + ) _require_decision_consistency(decision, _DECISION_FILE) _require_probe_consistency(probe, _PROBE_FILE) _require_approval_package_consistency(approval_package, _APPROVAL_PACKAGE_FILE) if connector_readback: _require_connector_readback_consistency(connector_readback, _CONNECTOR_READBACK_FILE) + if missing_source_readiness: + _require_missing_source_readiness_consistency(missing_source_readiness, _MISSING_SOURCE_READINESS_FILE) _require_owner_response_boundaries(owner_response, _OWNER_RESPONSE_FILE) @@ -402,6 +452,42 @@ def _require_connector_readback_consistency(payload: dict[str, Any], label: str) raise ValueError(f"{label}: connector readback must remain read-only: {enabled}") +def _require_missing_source_readiness_consistency(payload: dict[str, Any], label: str) -> None: + summary = _dict(payload.get("summary")) + targets = [_dict(row) for row in _list(payload.get("targets"))] + if _int(summary.get("target_count")) != len(targets): + raise ValueError(f"{label}: summary.target_count must equal targets length") + create_ready = sum(1 for row in targets if row.get("create_private_repo_ready") is True) + refs_ready = sum(1 for row in targets if row.get("refs_sync_ready") is True) + owner_required = sum(1 for row in targets if row.get("owner_response_required") is True) + if _int(summary.get("create_private_repo_ready_count")) != create_ready: + raise ValueError(f"{label}: create ready count must match targets") + if _int(summary.get("refs_sync_ready_count")) != refs_ready: + raise ValueError(f"{label}: refs sync ready count must match targets") + if _int(summary.get("owner_response_required_count")) != owner_required: + raise ValueError(f"{label}: owner response required count must match targets") + + false_summary_flags = { + "github_api_write_performed", + "repo_creation_performed", + "visibility_change_performed", + "refs_sync_performed", + "secret_values_collected", + } + enabled = sorted(flag for flag in false_summary_flags if summary.get(flag) is not False) + boundaries = _dict(payload.get("operation_boundaries")) + false_boundary_flags = { + "github_api_write_allowed", + "repo_creation_allowed", + "visibility_change_allowed", + "refs_sync_allowed", + "secret_value_collection_allowed", + } + enabled.extend(sorted(flag for flag in false_boundary_flags if boundaries.get(flag) is not False)) + if enabled: + raise ValueError(f"{label}: missing source readiness must remain read-only: {enabled}") + + def _require_owner_response_boundaries(payload: dict[str, Any], label: str) -> None: if payload.get("runtime_execution_authorized") is not False: raise ValueError(f"{label}: runtime_execution_authorized must be false") diff --git a/apps/api/tests/test_github_target_private_backup_evidence_gate.py b/apps/api/tests/test_github_target_private_backup_evidence_gate.py index 7dfbb3159..7c1d370e2 100644 --- a/apps/api/tests/test_github_target_private_backup_evidence_gate.py +++ b/apps/api/tests/test_github_target_private_backup_evidence_gate.py @@ -25,6 +25,13 @@ def test_load_github_target_private_backup_evidence_gate_from_committed_snapshot assert snapshot["summary"]["github_connector_readback_count"] == 9 assert snapshot["summary"]["github_connector_private_visibility_count"] == 4 assert snapshot["summary"]["github_connector_not_found_or_inaccessible_count"] == 5 + assert snapshot["summary"]["github_missing_target_resolution_count"] == 5 + assert snapshot["summary"]["github_missing_target_gitea_source_candidate_count"] == 3 + assert snapshot["summary"]["github_missing_target_internal_remote_source_candidate_count"] == 2 + assert snapshot["summary"]["github_missing_target_local_worktree_candidate_count"] == 5 + assert snapshot["summary"]["github_missing_target_canonical_source_ambiguous_count"] == 5 + assert snapshot["summary"]["github_missing_target_create_private_repo_ready_count"] == 0 + assert snapshot["summary"]["github_missing_target_refs_sync_ready_count"] == 0 assert snapshot["summary"]["private_backup_verified_count"] == 4 assert snapshot["summary"]["private_visibility_verified_count"] == 4 assert snapshot["summary"]["blocked_target_count"] == 9 @@ -48,7 +55,17 @@ def test_load_github_target_private_backup_evidence_gate_from_committed_snapshot assert targets["owenhytsai/ewoooc"]["visibility_evidence_status"] == ( "blocked_private_or_absent_not_verified" ) + assert targets["owenhytsai/ewoooc"]["missing_target_source_resolution_status"] == ( + "blocked_canonical_source_ambiguous" + ) + assert targets["owenhytsai/ewoooc"]["missing_target_gitea_repo"] == "wooo/ewoooc" + assert targets["owenhytsai/ewoooc"]["missing_target_create_private_repo_ready"] is False + assert targets["owenhytsai/ewoooc"]["missing_target_refs_sync_ready"] is False assert targets["owenhytsai/ewoooc"]["private_backup_verified"] is False + assert targets["owenhytsai/VibeWork"]["missing_target_source_resolution_status"] == ( + "blocked_product_boundary_and_local_divergence" + ) + assert targets["owenhytsai/VibeWork"]["missing_target_gitea_repo"] == "wooo/vibework" assert targets["nexu-io/open-design"]["visibility_evidence_status"] == ( "external_scope_not_backup_target" ) @@ -76,6 +93,17 @@ def test_github_target_private_backup_gate_requires_decision_rollup_consistency( load_latest_github_target_private_backup_evidence_gate(tmp_path) +def test_github_target_private_backup_gate_rejects_missing_source_write_flags(tmp_path): + _copy_security_snapshots(tmp_path) + readiness_path = tmp_path / "github-target-missing-source-readiness.snapshot.json" + readiness = json.loads(readiness_path.read_text(encoding="utf-8")) + readiness["summary"]["repo_creation_performed"] = True + readiness_path.write_text(json.dumps(readiness), encoding="utf-8") + + with pytest.raises(ValueError, match="missing source readiness must remain read-only"): + load_latest_github_target_private_backup_evidence_gate(tmp_path) + + def _copy_security_snapshots(tmp_path: Path) -> None: source_dir = default_security_dir(Path(__file__)) for filename in ( @@ -84,5 +112,6 @@ def _copy_security_snapshots(tmp_path: Path) -> None: "github-target-repo-approval-package.snapshot.json", "github-target-probe.snapshot.json", "github-target-connector-readback.snapshot.json", + "github-target-missing-source-readiness.snapshot.json", ): shutil.copy(source_dir / filename, tmp_path / filename) diff --git a/apps/api/tests/test_github_target_private_backup_evidence_gate_api.py b/apps/api/tests/test_github_target_private_backup_evidence_gate_api.py index f3c5e2ec6..d483dd4e1 100644 --- a/apps/api/tests/test_github_target_private_backup_evidence_gate_api.py +++ b/apps/api/tests/test_github_target_private_backup_evidence_gate_api.py @@ -21,6 +21,10 @@ def test_github_target_private_backup_evidence_gate_endpoint_returns_read_only_g assert data["summary"]["github_connector_readback_count"] == 9 assert data["summary"]["github_connector_private_visibility_count"] == 4 assert data["summary"]["github_connector_not_found_or_inaccessible_count"] == 5 + assert data["summary"]["github_missing_target_resolution_count"] == 5 + assert data["summary"]["github_missing_target_gitea_source_candidate_count"] == 3 + assert data["summary"]["github_missing_target_create_private_repo_ready_count"] == 0 + assert data["summary"]["github_missing_target_refs_sync_ready_count"] == 0 assert data["summary"]["private_backup_verified_count"] == 4 assert data["summary"]["private_visibility_verified_count"] == 4 assert data["summary"]["blocked_target_count"] == 9 diff --git a/docs/security/github-target-missing-source-readiness.snapshot.json b/docs/security/github-target-missing-source-readiness.snapshot.json new file mode 100644 index 000000000..5b0a27eff --- /dev/null +++ b/docs/security/github-target-missing-source-readiness.snapshot.json @@ -0,0 +1,172 @@ +{ + "schema_version": "github_target_missing_source_readiness_v1", + "generated_at": "2026-06-27T19:24:30+08:00", + "mode": "read_only_missing_github_target_source_resolution", + "summary": { + "target_count": 5, + "github_visible_private_count": 0, + "github_not_found_or_inaccessible_count": 5, + "gitea_source_candidate_count": 3, + "internal_remote_source_candidate_count": 2, + "local_worktree_candidate_count": 5, + "local_dirty_or_diverged_count": 3, + "canonical_source_ambiguous_count": 5, + "create_private_repo_ready_count": 0, + "refs_sync_ready_count": 0, + "owner_response_required_count": 5, + "github_api_write_performed": false, + "repo_creation_performed": false, + "visibility_change_performed": false, + "refs_sync_performed": false, + "secret_values_collected": false + }, + "targets": [ + { + "github_repo": "owenhytsai/ewoooc", + "github_readback_status": "not_found_or_inaccessible", + "source_resolution_status": "blocked_canonical_source_ambiguous", + "source_candidate_type": "gitea_repo_exists_with_momo_lineage_conflict", + "gitea_repo": "wooo/ewoooc", + "gitea_default_branch": "main", + "gitea_main_sha": "fdaa4bb2c98db9b5f79daf995f9f944e7deea796", + "local_worktree_path": "/Users/ogt/momo-pro-system", + "local_branch": "main", + "local_head": "00948f5", + "local_worktree_status": "exists_but_not_canonical_for_ewoooc", + "evidence_refs": [ + "docs/security/gitea-public-repo-search.snapshot.json", + "docs/security/local-repo-canonical-ewoooc-momo.snapshot.json", + "read_only_git_ls_remote:gitea-ssh:wooo/ewoooc.git" + ], + "required_owner_decisions": [ + "confirm_ewoooc_vs_momo_canonical_source", + "confirm_private_github_target_owner_and_name", + "approve_or_reject_private_repo_creation" + ], + "next_action": "resolve_canonical_source_before_private_repo_creation", + "create_private_repo_ready": false, + "refs_sync_ready": false, + "owner_response_required": true + }, + { + "github_repo": "owenhytsai/bitan-pharmacy", + "github_readback_status": "not_found_or_inaccessible", + "source_resolution_status": "blocked_local_worktree_dirty", + "source_candidate_type": "internal_remote_snapshot_candidate", + "gitea_repo": null, + "gitea_default_branch": null, + "gitea_main_sha": null, + "local_worktree_path": "/Users/ogt/bitan-pharmacy", + "local_branch": "main", + "local_head": "e122c8c", + "local_worktree_status": "tracked_changes_present", + "evidence_refs": [ + "docs/security/git-remote-refs-bitan-tsenyang.snapshot.json", + "read_only_local_git_status:/Users/ogt/bitan-pharmacy", + "read_only_git_ls_remote:gitea-ssh:wooo/bitan-pharmacy.git:not_found" + ], + "required_owner_decisions": [ + "confirm_bitan_active_status", + "confirm_internal_remote_as_source_of_truth", + "confirm_private_github_target_owner_and_name", + "approve_or_reject_private_repo_creation" + ], + "next_action": "clean_or_owner_freeze_local_source_before_private_repo_creation", + "create_private_repo_ready": false, + "refs_sync_ready": false, + "owner_response_required": true + }, + { + "github_repo": "owenhytsai/tsenyang-website", + "github_readback_status": "not_found_or_inaccessible", + "source_resolution_status": "blocked_local_worktree_dirty", + "source_candidate_type": "internal_remote_snapshot_candidate", + "gitea_repo": null, + "gitea_default_branch": null, + "gitea_main_sha": null, + "local_worktree_path": "/Users/ogt/tsenyang-website", + "local_branch": "main", + "local_head": "b369ed8", + "local_worktree_status": "tracked_and_untracked_changes_present", + "evidence_refs": [ + "docs/security/git-remote-refs-bitan-tsenyang.snapshot.json", + "read_only_local_git_status:/Users/ogt/tsenyang-website", + "read_only_git_ls_remote:gitea-ssh:wooo/tsenyang-website.git:not_found" + ], + "required_owner_decisions": [ + "confirm_tsenyang_active_status", + "confirm_internal_remote_as_source_of_truth", + "confirm_private_github_target_owner_and_name", + "approve_or_reject_private_repo_creation" + ], + "next_action": "clean_or_owner_freeze_local_source_before_private_repo_creation", + "create_private_repo_ready": false, + "refs_sync_ready": false, + "owner_response_required": true + }, + { + "github_repo": "owenhytsai/VibeWork", + "github_readback_status": "not_found_or_inaccessible", + "source_resolution_status": "blocked_product_boundary_and_local_divergence", + "source_candidate_type": "gitea_repo_exists_local_worktree_diverged", + "gitea_repo": "wooo/vibework", + "gitea_default_branch": "main", + "gitea_main_sha": "76a4ee15026af278a3660ad4b4547e9308b107be", + "local_worktree_path": "/Users/ogt/Documents/VibeWork", + "local_branch": "main", + "local_head": "48275cc", + "local_worktree_status": "ahead_3_behind_92_with_changes", + "evidence_refs": [ + "docs/security/VIBEWORK-IWOOOS-ONBOARDING-HANDOFF.md", + "docs/security/source-control-workflow-secret-name-local-evidence.snapshot.json", + "read_only_git_ls_remote:gitea-ssh:wooo/vibework.git" + ], + "required_owner_decisions": [ + "confirm_vibework_independent_product_boundary", + "confirm_gitea_main_or_local_worktree_source_of_truth", + "confirm_private_github_target_owner_and_name", + "approve_or_reject_private_repo_creation" + ], + "next_action": "resolve_vibework_source_of_truth_before_private_repo_creation", + "create_private_repo_ready": false, + "refs_sync_ready": false, + "owner_response_required": true + }, + { + "github_repo": "owenhytsai/agent-bounty-protocol", + "github_readback_status": "not_found_or_inaccessible", + "source_resolution_status": "blocked_high_risk_owner_response_required", + "source_candidate_type": "gitea_repo_exists_high_risk_runtime_surface", + "gitea_repo": "wooo/agent-bounty-protocol", + "gitea_default_branch": "main", + "gitea_main_sha": "b7a733f44f4f645dd21a9b4a9075b89c4a324f64", + "local_worktree_path": "/Users/ogt/Documents/agent-bounty-protocol", + "local_branch": "main", + "local_head": "0601df8", + "local_worktree_status": "large_worktree_dirty_scan_not_completed", + "evidence_refs": [ + "docs/security/AGENT-BOUNTY-IWOOOS-ONBOARDING-HANDOFF.md", + "docs/security/source-control-workflow-secret-name-local-evidence.snapshot.json", + "read_only_git_ls_remote:gitea-ssh:wooo/agent-bounty-protocol.git" + ], + "required_owner_decisions": [ + "confirm_agent_bounty_repo_deployment_and_treasury_boundary", + "confirm_gitea_main_or_local_worktree_source_of_truth", + "confirm_private_github_target_owner_and_name", + "approve_or_reject_private_repo_creation" + ], + "next_action": "resolve_high_risk_owner_boundary_before_private_repo_creation", + "create_private_repo_ready": false, + "refs_sync_ready": false, + "owner_response_required": true + } + ], + "operation_boundaries": { + "read_only_api_allowed": true, + "github_api_write_allowed": false, + "repo_creation_allowed": false, + "visibility_change_allowed": false, + "refs_sync_allowed": false, + "secret_value_collection_allowed": false + } +}