#!/usr/bin/env python3 """ IwoooS 高價值配置變更 Gate。 本工具只讀取 git diff 或手動指定的檔案路徑,將變更分類到 C0/C1/C2/C3 配置控管範圍,並輸出 owner response、rollback、evidence 與驗證需求。 它不修改 workflow、不讀 secret value、不 SSH、不碰 runtime。 """ from __future__ import annotations import argparse import fnmatch import json import subprocess import sys from dataclasses import dataclass from datetime import datetime, timedelta, timezone from pathlib import Path from typing import Any TAIPEI = timezone(timedelta(hours=8)) TIER_ORDER = {"C0": 0, "C1": 1, "C2": 2, "C3": 3} PRIORITY_ORDER = {"P0": 0, "P1": 1, "P2": 2, "P3": 3} REQUIRED_OWNER_FIELDS = [ "owner_role_or_team", "decision", "decision_reason", "affected_scope", "redacted_evidence_refs", "followup_owner", "rollback_owner", "maintenance_window", "validation_plan", ] OWNER_FIELD_ALIASES = { "owner_role_or_team": ["owner_role_or_team", "owner_role_team", "owner_role", "owner_team", "responsible_team"], "decision": ["decision", "owner_decision", "scope_decision", "disposition"], "decision_reason": ["decision_reason", "reason", "decision_summary", "owner_note", "rationale"], "affected_scope": ["affected_scope", "affected_sources", "affected_repos", "host_scope", "target_scope"], "redacted_evidence_refs": ["redacted_evidence_refs", "evidence_refs", "redacted_refs", "source_refs"], "followup_owner": ["followup_owner", "next_owner", "followup_team", "resolution_owner"], "rollback_owner": ["rollback_owner", "rollback_team"], "maintenance_window": ["maintenance_window", "change_window"], "validation_plan": ["validation_plan", "validation_checks", "verification_plan"], } REQUIRED_FALSE_FLAGS = [ "runtime_execution_authorized", "host_write_authorized", "secret_value_collection_allowed", "workflow_modification_authorized", "runner_change_authorized", "refs_sync_authorized", "force_push_authorized", "active_scan_authorized", "action_buttons_allowed", ] @dataclass(frozen=True) class ControlCategory: category_id: str label: str priority: str control_tier: str required_gate: str patterns: tuple[str, ...] required_validation: tuple[str, ...] CATEGORIES = [ ControlCategory( category_id="nginx_public_gateway", label="Nginx / reverse proxy / public route", priority="P0", control_tier="C0", required_gate="public_gateway_owner_response_required", patterns=( "infra/ansible/roles/nginx/templates/*.j2", "infra/ansible/playbooks/nginx-sync.yml", "ops/nginx/**", "docs/runbooks/disaster-recovery/DR-Nginx.md", ), required_validation=( "rendered_diff", "nginx_t", "affected_route_smoke", "admin_route_smoke_if_affected", "acme_path_smoke_if_affected", "rollback_ref", ), ), ControlCategory( category_id="dns_tls_certbot", label="DNS / TLS / certbot / certificate path", priority="P0", control_tier="C0", required_gate="domain_tls_owner_response_required", patterns=( "docs/runbooks/REGISTRY-CERTBOT-188.md", "docs/runbooks/**/*CERTBOT*.md", "docs/runbooks/**/*TLS*.md", "ops/**/*cert*", "ops/**/*tls*", "infra/**/*cert*", "infra/**/*tls*", "k8s/**/*tls*", ), required_validation=( "domain_inventory", "certificate_path_check", "renewal_window", "acme_path_smoke", "public_https_smoke", "rollback_ref", ), ), ControlCategory( category_id="k8s_production_gitops", label="K8s / ArgoCD / production manifests", priority="P0", control_tier="C0", required_gate="gitops_owner_response_required", patterns=( "k8s/awoooi-prod/**", "k8s/argocd/**", "k8s/velero/**", "k8s/monitoring/**", ), required_validation=( "gitops_diff", "argocd_health_readback", "sync_authorization_check", "rollback_revision", "post_deploy_health_if_executed", ), ), ControlCategory( category_id="secret_metadata", label="Secret metadata / injection / redaction", priority="P0", control_tier="C0", required_gate="secret_metadata_owner_response_required", patterns=( "k8s/**/*secret*", "k8s/**/*Secret*", ".gitea/workflows/*.yml", ".gitea/workflows/*.yaml", ".github/workflows/*.yml", ".github/workflows/*.yaml", "docs/runbooks/SECRETS-MANAGEMENT.md", "docs/security/SECRETS_REFERENCE.md", ), required_validation=( "secret_name_parity", "metadata_only_check", "no_secret_value_check", "rotation_owner", "injection_readback_if_deployed", ), ), ControlCategory( category_id="gitea_workflow_runner_source_control", label="Gitea workflow / runner / deploy key / webhook / branch protection", priority="P0", control_tier="C0", required_gate="workflow_source_control_owner_response_required", patterns=( ".gitea/workflows/**", ".github/workflows/**", "ops/runner/**", "scripts/setup-runner*.sh", "scripts/**/*runner*", "docs/security/SOURCE-CONTROL-*", "docs/security/GITEA-*", "docs/security/GITHUB-*", ), required_validation=( "workflow_diff", "runner_label_owner", "deploy_key_metadata_only", "webhook_metadata_only", "branch_protection_metadata", "no_token_value_check", ), ), ControlCategory( category_id="public_admin_api_runtime_config", label="Public / admin / API / frontend runtime config", priority="P0", control_tier="C0", required_gate="public_runtime_config_owner_response_required", patterns=( "apps/web/next.config.*", "apps/web/src/lib/config.*", "apps/api/src/core/config.py", "apps/api/src/api/v1/monitoring.py", "apps/api/src/middleware/**", "apps/web/src/middleware.*", ), required_validation=( "public_url_check", "frontend_internal_ip_ban", "cors_boundary_check", "admin_auth_boundary_check", "desktop_mobile_smoke_if_frontend", ), ), ControlCategory( category_id="backup_restore_credential", label="Backup / restore / escrow / retention", priority="P0", control_tier="C0", required_gate="backup_restore_owner_response_required", patterns=( "scripts/backup/**", "k8s/velero/**", "docs/runbooks/disaster-recovery/**", "docs/runbooks/**/*RESTORE*.md", "docs/runbooks/**/*BACKUP*.md", ), required_validation=( "credential_absence_check", "restore_drill_gate", "retention_policy", "escrow_owner", "rollback_ref", ), ), ControlCategory( category_id="agent_bounty_protocol_runtime", label="agent-bounty-protocol runtime / MCP / A2A / treasury boundary", priority="P0", control_tier="C0", required_gate="agent_bounty_owner_response_required", patterns=( "docs/security/AGENT-BOUNTY-IWOOOS-ONBOARDING-HANDOFF.md", "docs/security/agent-bounty-iwooos-onboarding-handoff.snapshot.json", "docs/schemas/agent_bounty_iwooos_onboarding_handoff_v1.schema.json", "agent-bounty-protocol/**", ), required_validation=( "repo_owner_scope", "runtime_gate_false", "no_payout_or_treasury_execution", "no_mcp_a2a_runtime_execution", "redacted_evidence_refs_only", ), ), ControlCategory( category_id="monitoring_alerting_observability", label="Prometheus / Alertmanager / Grafana / SigNoz / Sentry / Langfuse", priority="P1", control_tier="C1", required_gate="monitoring_observability_owner_response_required", patterns=( "ops/monitoring/**", "ops/alertmanager/**", "ops/grafana/**", "ops/signoz/**", "ops/sentry-self-hosted/**", "infra/langfuse/**", "k8s/monitoring/**", ), required_validation=( "rule_diff", "receiver_diff", "reload_gate", "failure_notification_policy", "public_route_smoke_if_affected", ), ), ControlCategory( category_id="docker_compose_systemd_host_config", label="Docker Compose / systemd / host service config", priority="P1", control_tier="C1", required_gate="host_service_owner_response_required", patterns=( "docker-compose*.yml", "docker-compose*.yaml", "ops/**/docker-compose*.yml", "ops/**/docker-compose*.yaml", "scripts/reboot-recovery/**", "scripts/**/*.service", "ops/**/*.service", ), required_validation=( "port_conflict_check", "volume_diff", "env_name_diff", "restart_window", "rollback_owner", ), ), ControlCategory( category_id="ssh_firewall_network_access", label="SSH / sudoers / known_hosts / firewall / WireGuard / NodePort", priority="P1", control_tier="C1", required_gate="network_access_owner_response_required", patterns=( "infra/ansible/inventory/**", "infra/ansible/**/*known_hosts*", "infra/ansible/**/*ssh*", "scripts/**/*ssh*", "scripts/**/*known_hosts*", "ops/**/*wireguard*", "ops/**/*firewall*", "k8s/**/*network*", "k8s/**/*Network*", ), required_validation=( "target_whitelist", "host_key_policy", "ingress_egress_matrix", "rollback_owner", "maintenance_window", ), ), ControlCategory( category_id="ai_provider_model_routing", label="AI provider / model routing / Ollama proxy / cost and privacy", priority="P1", control_tier="C1", required_gate="ai_provider_owner_response_required", patterns=( "apps/api/src/services/ai_providers/**", "apps/api/src/services/**/*model*", "apps/api/src/services/**/*provider*", "infra/ansible/roles/nginx/templates/110-ollama-proxy.conf.j2", "docs/ai/**", "docs/**/*Ollama*", ), required_validation=( "dry_run", "benchmark", "cost_review", "privacy_review", "fallback_order_check", ), ), ControlCategory( category_id="product_surface_runtime_routes", label="AWOOOI / AwoooP / IwoooS / VibeWork / other product runtime routes", priority="P2", control_tier="C2", required_gate="product_surface_owner_response_required", patterns=( "apps/web/src/app/**", "apps/web/messages/*.json", "docs/security/VIBEWORK-IWOOOS-ONBOARDING-HANDOFF.md", "docs/security/vibework-iwooos-onboarding-handoff.snapshot.json", ), required_validation=( "product_boundary_check", "i18n_traditional_chinese_check", "no_internal_transcript_check", "desktop_mobile_smoke_if_frontend", ), ), ControlCategory( category_id="security_evidence_tooling", label="Security evidence / snapshot / guard tooling", priority="P3", control_tier="C3", required_gate="security_evidence_owner_review_required", patterns=( "docs/security/**", "docs/schemas/**", "scripts/security/**", "docs/LOGBOOK.md", ), required_validation=( "snapshot_parse", "guard_smoke", "doc_secret_sanity", "no_runtime_gate_increase", ), ), ] 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 diff_files(root: Path, base: str, head: str) -> list[str]: result = subprocess.run( ["git", "diff", "--name-only", f"{base}..{head}"], cwd=root, check=True, capture_output=True, text=True, ) return sorted({line.strip() for line in result.stdout.splitlines() if line.strip()}) def matches(pattern: str, path: str) -> bool: if fnmatch.fnmatch(path, pattern): return True if "**" in pattern and fnmatch.fnmatch(path, pattern.replace("**/", "")): return True return False def classify_path(path: str) -> list[ControlCategory]: matched: list[ControlCategory] = [] for category in CATEGORIES: if any(matches(pattern, path) for pattern in category.patterns): matched.append(category) return sorted(matched, key=lambda item: (TIER_ORDER[item.control_tier], PRIORITY_ORDER[item.priority], item.category_id)) def strongest_tier(categories: list[dict[str, Any]]) -> str | None: if not categories: return None return min((item["control_tier"] for item in categories), key=lambda value: TIER_ORDER[value]) def strongest_priority(categories: list[dict[str, Any]]) -> str | None: if not categories: return None return min((item["priority"] for item in categories), key=lambda value: PRIORITY_ORDER[value]) def load_evidence(path: Path | None) -> dict[str, Any] | None: if path is None: return None return json.loads(path.read_text(encoding="utf-8")) def has_owner_field(evidence: dict[str, Any], field: str) -> bool: aliases = OWNER_FIELD_ALIASES.get(field, [field]) return any(bool(evidence.get(alias)) for alias in aliases) def validate_evidence(evidence: dict[str, Any] | None) -> dict[str, Any]: if evidence is None: return { "provided": False, "complete": False, "missing_owner_fields": REQUIRED_OWNER_FIELDS, "invalid_false_flags": [], "note": "未提供 owner response evidence;本階段只能分類,不得執行 runtime 變更。", } missing = [field for field in REQUIRED_OWNER_FIELDS if not has_owner_field(evidence, field)] false_flags = evidence.get("false_flags", {}) invalid_false_flags = [ flag for flag in REQUIRED_FALSE_FLAGS if false_flags.get(flag) is not False ] return { "provided": True, "complete": not missing and not invalid_false_flags, "missing_owner_fields": missing, "invalid_false_flags": invalid_false_flags, "note": "owner response evidence 僅驗證欄位與 false flags;不代表 runtime 授權。", } def category_inventory() -> list[dict[str, Any]]: return [ { "category_id": category.category_id, "label": category.label, "priority": category.priority, "control_tier": category.control_tier, "required_gate": category.required_gate, "path_patterns": list(category.patterns), "required_owner_fields": REQUIRED_OWNER_FIELDS, "required_validation": list(category.required_validation), } for category in CATEGORIES ] def build_report( root: Path, changed_files: list[str], generated_at: str | None, base: str | None, head: str | None, evidence: dict[str, Any] | None, ) -> dict[str, Any]: report_time = generated_at or datetime.now(TAIPEI).isoformat(timespec="seconds") path_reports: list[dict[str, Any]] = [] impacted_by_id: dict[str, dict[str, Any]] = {} for path in sorted(set(changed_files)): matched_categories = classify_path(path) path_category_reports: list[dict[str, Any]] = [] for category in matched_categories: category_report = { "category_id": category.category_id, "label": category.label, "priority": category.priority, "control_tier": category.control_tier, "required_gate": category.required_gate, "required_validation": list(category.required_validation), } path_category_reports.append(category_report) impacted_by_id.setdefault(category.category_id, category_report) path_reports.append( { "path": path, "matched": bool(path_category_reports), "strongest_tier": strongest_tier(path_category_reports), "strongest_priority": strongest_priority(path_category_reports), "categories": path_category_reports, } ) impacted_categories = sorted( impacted_by_id.values(), key=lambda item: ( TIER_ORDER[item["control_tier"]], PRIORITY_ORDER[item["priority"]], item["category_id"], ), ) evidence_validation = validate_evidence(evidence) c0_count = sum(1 for item in impacted_categories if item["control_tier"] == "C0") c1_count = sum(1 for item in impacted_categories if item["control_tier"] == "C1") return { "schema_version": "high_value_config_change_gate_v1", "generated_at": report_time, "git_commit": git_short_sha(root), "mode": "classification_only", "diff": { "base": base, "head": head, "changed_file_count": len(path_reports), }, "execution_boundaries": { "ssh_executed": False, "host_write_executed": False, "workflow_modified": False, "runtime_deploy_executed": False, "nginx_reload_executed": False, "dns_tls_modified": False, "secret_value_collected": False, "active_scan_executed": False, "runtime_gate_opened": False, }, "required_false_flags": REQUIRED_FALSE_FLAGS, "required_owner_fields": REQUIRED_OWNER_FIELDS, "summary": { "changed_file_count": len(path_reports), "matched_high_value_file_count": sum(1 for item in path_reports if item["matched"]), "impacted_category_count": len(impacted_categories), "impacted_c0_category_count": c0_count, "impacted_c1_category_count": c1_count, "strongest_tier": strongest_tier(impacted_categories), "strongest_priority": strongest_priority(impacted_categories), "owner_evidence_provided": evidence_validation["provided"], "owner_evidence_complete": evidence_validation["complete"], "runtime_execution_authorized": False, }, "impacted_categories": impacted_categories, "changed_files": path_reports, "owner_evidence_validation": evidence_validation, "control_category_inventory": category_inventory(), "next_steps": [ "若 impacted_c0_category_count > 0,先建立 owner response packet,不得直接 reload、deploy、sync 或修改主機。", "owner response 必須包含 owner role / team、decision、decision reason、affected scope、redacted evidence refs、followup owner、rollback owner、maintenance window 與 validation plan。", "若只有 C3 security evidence / tooling,仍需跑 guard 與 doc secret sanity,但不得藉此提高 runtime gate。", ], } def main() -> int: parser = argparse.ArgumentParser(description="IwoooS 高價值配置變更 Gate") parser.add_argument("--root", default=".", help="repo root") parser.add_argument("--base", help="git diff base") parser.add_argument("--head", default="HEAD", help="git diff head") parser.add_argument("--changed-file", action="append", default=[], help="手動指定變更檔案,可重複") parser.add_argument("--evidence", help="owner response evidence JSON,只驗證欄位與 false flags") parser.add_argument("--output", help="寫出 JSON 報告") parser.add_argument("--generated-at", help="固定報告時間,供 committed snapshot 使用") parser.add_argument("--fail-on-c0", action="store_true", help="若有 C0 分類則回傳 1") parser.add_argument( "--fail-on-missing-evidence", action="store_true", help="若命中 C0/C1 但 owner evidence 不完整則回傳 1", ) args = parser.parse_args() root = Path(args.root).resolve() changed_files = list(args.changed_file) if args.base: changed_files.extend(diff_files(root, args.base, args.head)) evidence = load_evidence(Path(args.evidence)) if args.evidence else None report = build_report(root, changed_files, args.generated_at, args.base, args.head, evidence) payload = json.dumps(report, ensure_ascii=False, indent=2, sort_keys=True) if args.output: output = Path(args.output) output.parent.mkdir(parents=True, exist_ok=True) output.write_text(payload + "\n", encoding="utf-8") else: print(payload) summary = report["summary"] print( "HIGH_VALUE_CONFIG_CHANGE_GATE_OK " f"changed_files={summary['changed_file_count']} " f"matched={summary['matched_high_value_file_count']} " f"categories={summary['impacted_category_count']} " f"c0={summary['impacted_c0_category_count']} " f"c1={summary['impacted_c1_category_count']}", file=sys.stderr, ) if args.fail_on_c0 and summary["impacted_c0_category_count"] > 0: return 1 if ( args.fail_on_missing_evidence and (summary["impacted_c0_category_count"] > 0 or summary["impacted_c1_category_count"] > 0) and not summary["owner_evidence_complete"] ): return 1 return 0 if __name__ == "__main__": sys.exit(main())