264 lines
10 KiB
Python
264 lines
10 KiB
Python
#!/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())
|