Files
awoooi/scripts/security/blocked-products-owner-response-intake-preflight.py

264 lines
10 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/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 仍必須是 falseremote 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())