#!/usr/bin/env python3 """ Codex / Gitea blocked products owner response intake preflight. This is a repo-only metadata gate. It reads the committed acceptance ledger and optionally scans redacted owner response metadata files. It must never read secret values, .env contents, raw Codex conversations, raw .git directories, or runtime volumes. """ from __future__ import annotations import argparse import json import subprocess from datetime import datetime, timedelta, timezone from pathlib import Path from typing import Any TAIPEI = timezone(timedelta(hours=8)) DEFAULT_LEDGER = Path("docs/operations/codex-gitea-blocked-products-owner-response-acceptance.snapshot.json") DEFAULT_RESPONSE_DIR = Path("docs/operations/blocked-product-owner-responses") INTAKE_LANES = [ { "lane_id": "keep_waiting_owner_response", "instruction": "沒有產品級 redacted owner response,或只有一般批准語句時維持等待。", "gate_effect": "received / accepted / remote_dev_ready 全部維持 0。", }, { "lane_id": "quarantine_sensitive_payload", "instruction": "疑似包含 secret、.env、raw conversation、raw .git、runtime volume 或未脫敏 payload 時隔離。", "gate_effect": "不得保存 raw payload,不得渲染到前端,不得進 acceptance review。", }, { "lane_id": "reject_execution_request", "instruction": "夾帶 repo write、remote dev branch、deploy、restart、DB、K8s、host 或 runtime 執行要求時拒收。", "gate_effect": "不得建立 action button,不得轉成執行批准。", }, { "lane_id": "request_supplement", "instruction": "欄位不足、scope 不清、include / exclude 模糊或 evidence refs 不足時補件。", "gate_effect": "不得增加 accepted 或 remote_dev_ready。", }, { "lane_id": "ready_for_acceptance_review", "instruction": "欄位完整、證據脫敏、無敏感 payload 且無執行要求時才可進 reviewer checklist。", "gate_effect": "仍不是 accepted,也不是 remote dev 授權。", }, { "lane_id": "metadata_accepted_waiting_branch_confirmation", "instruction": "即使 metadata accepted,也還需要逐產品 branch / baseline final confirmation。", "gate_effect": "runtime_write 仍必須是 false;remote dev 仍需獨立最終確認。", }, ] FORBIDDEN_KEYS = [ "secret", "token", "password", "private_key", "cookie", "session", "authorization", "env_content", "raw_git", "raw_conversation", "runtime_volume", ] def git_short_sha(root: Path) -> str: try: result = subprocess.run( ["git", "rev-parse", "--short", "HEAD"], cwd=root, check=True, capture_output=True, text=True, ) return result.stdout.strip() except Exception: return "unknown" def load_json(path: Path) -> dict[str, Any]: return json.loads(path.read_text(encoding="utf-8")) def response_files(response_dir: Path) -> list[Path]: if not response_dir.exists(): return [] return sorted(path for path in response_dir.glob("*.json") if path.is_file()) def has_forbidden_key(payload: Any) -> bool: if isinstance(payload, dict): for key, value in payload.items(): normalized = str(key).lower() if any(forbidden in normalized for forbidden in FORBIDDEN_KEYS): return True if has_forbidden_key(value): return True elif isinstance(payload, list): return any(has_forbidden_key(item) for item in payload) return False def inspect_responses(response_paths: list[Path]) -> list[dict[str, Any]]: inspected: list[dict[str, Any]] = [] for path in response_paths: try: payload = load_json(path) parse_ok = True parse_error = "" except Exception as exc: payload = {} parse_ok = False parse_error = str(exc) product_id = payload.get("product_id", path.stem) if isinstance(payload, dict) else path.stem inspected.append( { "path": str(path), "product_id": product_id, "parse_ok": parse_ok, "parse_error": parse_error, "contains_forbidden_key": has_forbidden_key(payload), "received": parse_ok, "accepted": False, "review_branch_ready": False, "remote_dev_branch_ready": False, "runtime_write_authorized": False, } ) return inspected def repo_relative(root: Path, path: Path) -> str: try: return path.relative_to(root).as_posix() except ValueError: return str(path) def candidate_from_product(product: dict[str, Any]) -> dict[str, Any]: return { "product_id": product["product_id"], "status": "waiting_owner_response", "decision_package": product["decision_package"], "response_template_section": product["response_template_section"], "default_blockers": product["default_blockers"], "intake_lane": "keep_waiting_owner_response", "owner_response_received": False, "owner_response_accepted": False, "owner_response_rejected": False, "supplement_requested": False, "quarantined": False, "review_branch_ready": False, "remote_dev_branch_ready": False, "runtime_write_authorized": False, "secret_values_collected": False, } def build_report(root: Path, ledger: dict[str, Any], response_dir: Path, generated_at: str | None) -> dict[str, Any]: report_time = generated_at or datetime.now(TAIPEI).isoformat(timespec="seconds") products = ledger.get("products", []) response_paths = response_files(response_dir) inspected = inspect_responses(response_paths) parsed_received = [item for item in inspected if item["received"] and not item["contains_forbidden_key"]] quarantined = [item for item in inspected if item["contains_forbidden_key"] or not item["parse_ok"]] return { "schema_version": "codex_gitea_blocked_products_owner_response_intake_preflight_v1", "generated_at": report_time, "git_commit": git_short_sha(root), "source_ledger_schema_version": ledger.get("schema_version"), "status": "waiting_owner_response", "source_ledger": str(DEFAULT_LEDGER), "response_directory": repo_relative(root, response_dir), "summary": { "blocked_product_count": len(products), "intake_candidate_count": len(products), "response_file_count": len(response_paths), "parsed_response_file_count": sum(1 for item in inspected if item["parse_ok"]), "quarantined_response_file_count": len(quarantined), "valid_redacted_response_file_count": len(parsed_received), "required_owner_response_field_count": len(ledger.get("required_owner_response_fields", [])), "acceptance_check_count": len(ledger.get("acceptance_checks", [])), "rejection_guard_count": len(ledger.get("rejection_guards", [])), "intake_lane_count": len(INTAKE_LANES), "owner_response_received_count": 0, "owner_response_accepted_count": 0, "owner_response_rejected_count": 0, "supplement_requested_count": 0, "review_branch_ready_count": 0, "remote_dev_branch_ready_count": 0, "remote_dev_branch_created_count": 0, "product_repo_write_authorized_count": 0, "runtime_write_authorized_count": 0, "secret_values_collected_count": 0, "action_button_count": 0, }, "intake_lanes": INTAKE_LANES, "required_owner_response_fields": ledger.get("required_owner_response_fields", []), "acceptance_checks": ledger.get("acceptance_checks", []), "rejection_guards": ledger.get("rejection_guards", []), "products": [candidate_from_product(product) for product in products], "inspected_response_files": inspected, "hard_gates": [ "Only redacted owner response metadata JSON files may be inspected.", "Generic approval text is not a product-specific owner response.", "Accepted count stays 0 until reviewer acceptance is recorded.", "Remote dev branch readiness stays 0 until product-specific final confirmation.", "Runtime write, product repo write, secret collection, raw .git sync, raw conversation sync, .env read, and runtime volume sync remain forbidden.", ], "secret_values_collected": False, "env_file_content_read": False, "raw_git_sync_allowed": False, "raw_conversation_sync_allowed": False, "runtime_write_authorized": False, "product_repo_write_authorized": False, } def main() -> int: parser = argparse.ArgumentParser(description=__doc__) parser.add_argument("--root", default=".", help="Repository root.") parser.add_argument("--ledger", default=str(DEFAULT_LEDGER), help="Acceptance ledger snapshot path.") parser.add_argument("--response-dir", default=str(DEFAULT_RESPONSE_DIR), help="Redacted response metadata directory.") parser.add_argument("--generated-at", default=None, help="Override generated_at timestamp.") parser.add_argument("--output", default=None, help="Optional output JSON path.") args = parser.parse_args() root = Path(args.root).resolve() ledger_path = (root / args.ledger).resolve() response_dir = (root / args.response_dir).resolve() ledger = load_json(ledger_path) report = build_report(root, ledger, response_dir, args.generated_at) text = json.dumps(report, ensure_ascii=False, indent=2) + "\n" if args.output: Path(args.output).write_text(text, encoding="utf-8") else: print(text, end="") summary = report["summary"] print( "BLOCKED_PRODUCTS_OWNER_RESPONSE_INTAKE_PREFLIGHT_OK " f"products={summary['blocked_product_count']} " f"responses={summary['response_file_count']} " f"accepted={summary['owner_response_accepted_count']} " f"remote_dev_ready={summary['remote_dev_branch_ready_count']} " f"runtime_write={summary['runtime_write_authorized_count']}" ) return 0 if __name__ == "__main__": raise SystemExit(main())