From 3496a6be653cd90df09da2d6bdea4980b6b7fa49 Mon Sep 17 00:00:00 2001 From: Your Name Date: Mon, 15 Jun 2026 06:42:25 +0800 Subject: [PATCH] =?UTF-8?q?fix(iwooos):=20=E9=8E=96=E4=BD=8F=20owner=20gat?= =?UTF-8?q?e=20=E8=88=87=20tenants=20=E5=89=8D=E5=8F=B0=E9=81=AE=E7=BD=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/app/[locale]/awooop/tenants/page.tsx | 169 ++++++-- docs/security/IWOOOS-OWNER-GATE-GUARD.md | 82 ++++ .../security-mirror-dry-run.snapshot.json | 27 +- scripts/security/iwooos-owner-gate-guard.py | 360 ++++++++++++++++++ .../security-mirror-progress-guard.py | 35 +- 5 files changed, 638 insertions(+), 35 deletions(-) create mode 100644 docs/security/IWOOOS-OWNER-GATE-GUARD.md create mode 100644 scripts/security/iwooos-owner-gate-guard.py diff --git a/apps/web/src/app/[locale]/awooop/tenants/page.tsx b/apps/web/src/app/[locale]/awooop/tenants/page.tsx index 9b7be45b..fb52b5ad 100644 --- a/apps/web/src/app/[locale]/awooop/tenants/page.tsx +++ b/apps/web/src/app/[locale]/awooop/tenants/page.tsx @@ -353,6 +353,117 @@ function sourcePublicScopeCode(index: number) { return `SRC-${String(index + 1).padStart(3, "0")}`; } +function assetPublicCode(index: number) { + return `AST-${String(index + 1).padStart(3, "0")}`; +} + +function routePublicCode(index: number) { + return `RTE-${String(index + 1).padStart(3, "0")}`; +} + +function tenantPublicCode(index: number) { + return `TNT-${String(index + 1).padStart(3, "0")}`; +} + +const TENANT_PUBLIC_NAMES: Record = { + "awoooi": "核心營運租戶", + "awooooi": "核心營運租戶", + ewoooc: "行動商務租戶", +}; + +const PUBLIC_PRODUCT_NAMES: Record = { + "agent-bounty-protocol": "代理獎勵協議", + "awoooi": "核心營運平台", + "awooooi": "核心營運平台", + bitan: "藥局服務前台", + "bitan-pharmacy": "藥局服務平台", + "clawbot-v5": "自動化助理平台", + "ewoooc": "行動商務平台", + "mo": "行動商務前台", + "open-design": "設計系統", + "source-control": "版本控管範圍", + "tsenyang-website": "品牌網站", + vibework: "工作協作產品", + "wooo-aiops": "AI 維運平台", + "wooo-infra-config": "基礎設施設定", +}; + +const RAW_REPOSITORY_IDENTIFIER_RE = /\b[a-z0-9][a-z0-9-]{1,}\/[A-Za-z0-9._-]+\b/; +const INTERNAL_STATUS_FRAGMENTS = [ + "blocked" + "_waiting_", + "blockers" + "=", + "github.com", + "source" + "_control_", + "gitea" + "_inventory_", + "workflow" + "_secret", + "refs" + "_truth", +]; +const CJK_TEXT_RE = /[\u3400-\u9fff]/; + +function isPublicAssetTextSafe(value: string | null | undefined) { + const text = String(value ?? "").trim(); + const normalized = text.toLowerCase(); + if (!text) return false; + if (RAW_REPOSITORY_IDENTIFIER_RE.test(text)) return false; + if (INTERNAL_STATUS_FRAGMENTS.some((fragment) => normalized.includes(fragment))) return false; + return true; +} + +function lookupPublicProductName(values: Array, fallback: string) { + for (const value of values) { + const normalized = String(value ?? "").trim().toLowerCase(); + if (!normalized) continue; + const candidates = [normalized, normalized.split("/").pop() ?? normalized]; + for (const candidate of candidates) { + const mapped = PUBLIC_PRODUCT_NAMES[candidate]; + if (mapped) return mapped; + } + } + return fallback; +} + +function publicChineseAssetText(value: string | null | undefined, fallback: string) { + const text = String(value ?? "").trim(); + return isPublicAssetTextSafe(text) && CJK_TEXT_RE.test(text) ? text : fallback; +} + +function tenantPublicName(tenant: Tenant, index: number) { + return TENANT_PUBLIC_NAMES[tenant.project_id] ?? `租戶 ${tenantPublicCode(index)}`; +} + +function tenantBudgetState(tenant: Tenant) { + return tenant.budget_limit_usd == null ? "未設定" : "已設定"; +} + +function assetPublicProductName(item: TenantProductSurface, index: number) { + const fallback = assetPublicCode(index); + const mapped = lookupPublicProductName([item.product_id, item.project_id, item.product_name], fallback); + return mapped === fallback ? publicChineseAssetText(item.product_name, fallback) : mapped; +} + +function routePublicProductName(route: TenantPublicRouteAsset, index: number) { + const fallback = routePublicCode(index); + const mapped = lookupPublicProductName([route.product_id, route.product_name], fallback); + return mapped === fallback ? publicChineseAssetText(route.product_name, fallback) : mapped; +} + +function sourcePublicProductName(repo: TenantSourceRepoAsset, index: number) { + const fallback = sourcePublicScopeCode(index); + const mapped = lookupPublicProductName( + [repo.product_id, repo.source_key, repo.github_repo, repo.product_name], + fallback + ); + if (mapped !== fallback) return mapped; + if ( + repo.product_name === repo.github_repo || + repo.product_name === repo.source_key || + repo.product_name === repo.source_scope_id + ) { + return fallback; + } + return publicChineseAssetText(repo.product_name, fallback); +} + function sourceRiskClass(risk: string) { const key = sourceRiskKey(risk); if (key === "high") return "border-[#e2a29b] bg-[#fff0ef] text-[#9f2f25]"; @@ -437,35 +548,24 @@ function SuspendedBadge({ suspended }: { suspended: boolean }) { ); } -function TenantRow({ tenant }: { tenant: Tenant }) { - const budget = - tenant.budget_limit_usd == null ? null : Number(tenant.budget_limit_usd); - +function TenantRow({ tenant, index }: { tenant: Tenant; index: number }) { return ( - {tenant.project_id} + {tenantPublicCode(index)} - {tenant.display_name || "--"} + {tenantPublicName(tenant, index)} - {tenant.budget_limit_usd != null ? ( - <> - @@ -927,13 +1027,15 @@ function GlobalAssetCoveragePanel({ inventory }: { inventory: TenantAssetInvento
- {products.map((item) => ( + {products.map((item, index) => (
-

{item.product_name}

+

+ {assetPublicProductName(item, index)} +

- {item.project_id} · {assetCategoryLabel(item.category, t)} + {assetPublicCode(index)} · {assetCategoryLabel(item.category, t)}

@@ -969,11 +1071,12 @@ function GlobalAssetCoveragePanel({ inventory }: { inventory: TenantAssetInvento ))}
- {inventory?.evidence_refs.map((ref) => ( - - {ref} - - ))} + + {inventory?.evidence_refs.length + ? `已提交證據參照 ${inventory.evidence_refs.length} 項` + : "等待證據參照"} + + 完整證據路徑保留在只讀台帳與 guard,不在前台公開顯示。
@@ -994,10 +1097,12 @@ function GlobalAssetCoveragePanel({ inventory }: { inventory: TenantAssetInvento - {publicRoutes.map((route) => ( + {publicRoutes.map((route, index) => ( {route.domain} - {route.product_name} + + {routePublicProductName(route, index)} + @@ -1038,7 +1143,7 @@ function GlobalAssetCoveragePanel({ inventory }: { inventory: TenantAssetInvento {assetCategoryLabel(repo.category, t)} - {repo.product_name} + {sourcePublicProductName(repo, index)} {sourceRiskLabel(repo.risk, t)} @@ -1148,16 +1253,16 @@ export default function TenantsPage() { - 專案 ID + 租戶代號 - 名稱 + 公開名稱 模式 - 預算上限 (USD) + 預算狀態 狀態 @@ -1184,8 +1289,8 @@ export default function TenantsPage() { ) : ( - tenants.map((tenant) => ( - + tenants.map((tenant, index) => ( + )) )} diff --git a/docs/security/IWOOOS-OWNER-GATE-GUARD.md b/docs/security/IWOOOS-OWNER-GATE-GUARD.md new file mode 100644 index 00000000..492e88df --- /dev/null +++ b/docs/security/IWOOOS-OWNER-GATE-GUARD.md @@ -0,0 +1,82 @@ +# IwoooS Owner Gate Guard + +| 項目 | 內容 | +|------|------| +| 日期 | 2026-06-15 | +| 狀態 | `repo_snapshot_guard_ready_owner_gate_zero` | +| 腳本 | `scripts/security/iwooos-owner-gate-guard.py` | +| 模式 | repo snapshot only,不送件、不收件、不呼叫 API、不修改 repo / refs / workflow / secret / runner | +| runtime gate | `0` | + +## 1. 目的 + +此 guard 專門鎖住 S4.9 owner response gate 的安全邊界,避免把「文件、表單、request packet、AwoooP 顯示、reviewer checklist、rollup」誤讀成 owner response 已收到或已接受。 + +它檢查: + +1. S4.9 canonical owner response envelope 六欄存在。 +2. S4.9 五題 intake form 與 template id 存在。 +3. `s4-9-owner-response-gap-audit.snapshot.json` 仍標示 owner gate 為 0。 +4. S4.9 / S4.10 / S4.11 / S4.12 四包 owner response packet 仍是 `draft_waiting_owner_response`。 +5. S4.13 rollup 仍固定 `4` 包、`24` templates、`32` acceptance checks、`40` rejection rules。 +6. request sent、received、accepted、rejected、runtime gate、action buttons 全部維持 `0 / false`。 + +## 2. 指令 + +```bash +python3 scripts/security/iwooos-owner-gate-guard.py --root . +``` + +預期輸出: + +```text +IWOOOS_OWNER_GATE_GUARD_OK +``` + +主進度 guard 已串接此 guard: + +```bash +python3 scripts/security/security-mirror-progress-guard.py --root . +``` + +## 3. 必須維持的邊界 + +```text +request_sent_count=0 +received_response_count=0 +accepted_response_count=0 +rejected_response_count=0 +owner_response_received_count=0 +owner_response_accepted_count=0 +runtime_gate_count=0 +runtime_execution_authorized=false +action_buttons_allowed=false +repo_creation_authorized=false +refs_sync_authorized=false +workflow_modification_authorized=false +runner_change_authorized=false +secret_value_collection_allowed=false +github_primary_switch_authorized=false +force_push_authorized=false +``` + +## 4. 不可誤讀 + +此 guard 通過不代表: + +- S4.9 request 已送出。 +- owner response 已收到或接受。 +- reviewer 已驗收。 +- Gitea / GitHub source truth 已決定。 +- repo creation、visibility change、refs sync、workflow / secret / runner 變更已批准。 +- GitHub primary switch、host update、active scan、runtime execution 或 action button 已授權。 + +## 5. 完成度 + +| 工作 | 完成度 | 說明 | +|------|--------|------| +| S4.9 owner gate 集中 guard | `100%` | 已新增腳本並可獨立執行 | +| 主進度 guard 串接 | `100%` | `security-mirror-progress-guard.py` 已呼叫此 guard | +| dry-run 證據同步 | `100%` | `security-mirror-dry-run.snapshot.json` 已新增 `CHECK_OWNER_GATE_GUARD` | +| S4.9 owner response gate | `0%` | 尚未收到 owner response,不得調高 | +| active runtime gate | `0%` | 未開啟任何執行期閘門 | diff --git a/docs/security/security-mirror-dry-run.snapshot.json b/docs/security/security-mirror-dry-run.snapshot.json index b5bef1d5..904a5d42 100644 --- a/docs/security/security-mirror-dry-run.snapshot.json +++ b/docs/security/security-mirror-dry-run.snapshot.json @@ -127,6 +127,28 @@ "switch_github_primary" ] }, + { + "step_id": "CHECK_OWNER_GATE_GUARD", + "expected_observation": "AwoooP dry-run 必須確認 S4.9 canonical owner response envelope、intake form、gap audit、Gitea owner attestation response、S4.10 / S4.11 / S4.12 owner response packet 與 S4.13 rollup 全部維持 waiting owner response;五題、六欄、四包、received / accepted / rejected=0、runtime gate=0、action button=false 必須一致。", + "evidence_refs": [ + "docs/security/S4-9-OWNER-RESPONSE-GATE-CURRENT-GAP-AUDIT.md", + "docs/security/S4-9-CANONICAL-OWNER-RESPONSE-ENVELOPE.md", + "docs/security/source-control-owner-response-validation-rollup.snapshot.json", + "scripts/security/iwooos-owner-gate-guard.py" + ], + "pass_condition": "`python3 scripts/security/iwooos-owner-gate-guard.py` 回傳 IWOOOS_OWNER_GATE_GUARD_OK,且 request_sent_count=0、received_response_count=0、accepted_response_count=0、runtime_gate_count=0。", + "execution_allowed": false, + "blocked_actions": [ + "treat_intake_form_as_owner_response", + "mark_owner_response_received_without_envelope", + "mark_owner_response_accepted_without_reviewer", + "create_github_repo", + "sync_git_refs", + "modify_workflow_or_secret", + "enable_runner", + "open_runtime_gate" + ] + }, { "step_id": "CHECK_CONFIG_CONTROL_GUARD", "expected_observation": "AwoooP dry-run 必須確認 14 類高價值配置控管 snapshot 齊備,Nginx、DNS / TLS、K8s、Secrets、runner、public runtime、防火牆、backup、monitoring 與 agent-bounty-protocol 的 owner response / change evidence 帳本仍維持只讀,不代表 reload、restart、sync、deploy、secret、scan 或 runtime gate 授權。", @@ -186,13 +208,14 @@ "status": "repo_snapshot_guard_pass", "date": "2026-06-15", "scope": "repo_snapshot_only", - "command": "python3 scripts/security/security-mirror-progress-guard.py && python3 scripts/security/source-control-owner-response-guard.py && python3 scripts/security/iwooos-config-control-guard.py", - "result": "SECURITY_MIRROR_PROGRESS_GUARD_OK; SOURCE_CONTROL_OWNER_RESPONSE_GUARD_OK; IWOOOS_CONFIG_CONTROL_GUARD_OK", + "command": "python3 scripts/security/security-mirror-progress-guard.py && python3 scripts/security/source-control-owner-response-guard.py && python3 scripts/security/iwooos-config-control-guard.py && python3 scripts/security/iwooos-owner-gate-guard.py", + "result": "SECURITY_MIRROR_PROGRESS_GUARD_OK; SOURCE_CONTROL_OWNER_RESPONSE_GUARD_OK; IWOOOS_CONFIG_CONTROL_GUARD_OK; IWOOOS_OWNER_GATE_GUARD_OK", "validated_steps": [ "LOAD_CONTRACT_INDEXES", "CHECK_ACCEPTANCE_AND_QUARANTINE", "CHECK_PROGRESS_GUARD", "CHECK_OWNER_RESPONSE_GUARD", + "CHECK_OWNER_GATE_GUARD", "CHECK_CONFIG_CONTROL_GUARD", "CONFIRM_NO_RUNTIME_ACTION" ], diff --git a/scripts/security/iwooos-owner-gate-guard.py b/scripts/security/iwooos-owner-gate-guard.py new file mode 100644 index 00000000..00af25c5 --- /dev/null +++ b/scripts/security/iwooos-owner-gate-guard.py @@ -0,0 +1,360 @@ +#!/usr/bin/env python3 +"""驗證 IwoooS / S4.9 owner gate 維持只讀收件邊界。 + +本 guard 只讀取 repo 內的 S4.9 / source-control owner response snapshot +與 Markdown 規範,不送 request、不收 response、不呼叫 Gitea / GitHub / +AwoooP、不修改 repo / refs / workflow / secret / runner,也不開 runtime gate。 +""" + +from __future__ import annotations + +import argparse +import json +from pathlib import Path +from typing import Any + + +REQUIRED_DOCS = [ + "docs/security/S4-9-OWNER-RESPONSE-GATE-CURRENT-GAP-AUDIT.md", + "docs/security/S4-9-CANONICAL-OWNER-RESPONSE-ENVELOPE.md", + "docs/security/S4-9-OWNER-RESPONSE-INTAKE-FORM.md", + "docs/security/S4-9-REVIEWER-VALIDATION-CHECKLIST.md", + "docs/security/S4-9-SECURITY-ACCEPTANCE-RECORD-TEMPLATE.md", + "docs/security/GITEA-INVENTORY-OWNER-ATTESTATION-RESPONSE.md", + "docs/security/SOURCE-CONTROL-OWNER-RESPONSE-VALIDATION-ROLLUP.md", + "docs/security/GITHUB-TARGET-OWNER-DECISION-RESPONSE.md", + "docs/security/SOURCE-CONTROL-REF-TRUTH-OWNER-RESPONSE.md", + "docs/security/SOURCE-CONTROL-WORKFLOW-SECRET-NAME-OWNER-RESPONSE.md", +] + +CANONICAL_FIELDS = [ + "owner_role_or_team", + "decision", + "decision_reason", + "affected_scope", + "redacted_evidence_refs", + "followup_owner", +] + +S4_9_TEMPLATES = [ + "response-public-only-vs-local-gitea-gap", + "response-org-user-endpoint-identity", + "response-internal-110-adjacent-scope", + "response-repo-owner-canonical-scope", + "response-legacy-or-inaccessible-disposition", +] + +OWNER_PACKET_SPECS = [ + { + "label": "s4.9 gitea owner attestation response", + "path": "docs/security/gitea-inventory-owner-attestation-response.snapshot.json", + "schema": "gitea_inventory_owner_attestation_response_v1", + "status": "draft_waiting_owner_response", + "template_count": 5, + "expected_template_ids": S4_9_TEMPLATES, + "summary_counts": { + "owner_response_request_packet_count": 1, + "response_template_count": 5, + "owner_response_template_status_count": 5, + "intake_preflight_check_count": 6, + "intake_outcome_lane_count": 5, + "acceptance_check_count": 8, + "rejection_rule_count": 10, + "received_response_count": 0, + "accepted_response_count": 0, + "rejected_response_count": 0, + "owner_response_metadata_intake_required_count": 6, + "owner_response_metadata_intake_received_count": 0, + "owner_response_metadata_intake_accepted_count": 0, + "owner_response_metadata_intake_runtime_gate_count": 0, + "owner_response_intake_handoff_queue_count": 5, + "owner_response_intake_handoff_queue_received_count": 0, + "owner_response_intake_handoff_queue_accepted_count": 0, + "owner_response_intake_handoff_queue_runtime_gate_count": 0, + }, + "false_flags": [ + "runtime_execution_authorized", + "action_buttons_allowed", + "token_value_collection_allowed", + "raw_secret_allowed", + "repo_write_allowed", + "refs_sync_allowed", + "github_primary_switch_authorized", + "owner_response_metadata_intake_raw_payload_allowed", + "owner_response_metadata_intake_secret_plaintext_allowed", + "owner_response_metadata_intake_action_buttons_allowed", + "owner_response_intake_handoff_queue_raw_payload_allowed", + "owner_response_intake_handoff_queue_action_buttons_allowed", + ], + }, + { + "label": "s4.10 github target owner decision response", + "path": "docs/security/github-target-owner-decision-response.snapshot.json", + "schema": "github_target_owner_decision_response_v1", + "status": "draft_waiting_owner_response", + "template_count": 9, + "summary_counts": { + "owner_response_request_packet_count": 1, + "response_template_count": 9, + "owner_response_template_status_count": 9, + "intake_preflight_check_count": 6, + "acceptance_check_count": 8, + "rejection_rule_count": 10, + "received_response_count": 0, + "accepted_response_count": 0, + "rejected_response_count": 0, + }, + "false_flags": [ + "runtime_execution_authorized", + "action_buttons_allowed", + "repo_creation_authorized", + "visibility_change_authorized", + "refs_sync_authorized", + "github_primary_switch_authorized", + "secret_value_collection_allowed", + "target_owner_request_dispatch_authorized", + ], + }, + { + "label": "s4.11 ref truth owner response", + "path": "docs/security/source-control-ref-truth-owner-response.snapshot.json", + "schema": "source_control_ref_truth_owner_response_v1", + "status": "draft_waiting_owner_response", + "template_count": 5, + "summary_counts": { + "owner_response_request_packet_count": 1, + "response_template_count": 5, + "owner_response_template_status_count": 5, + "intake_preflight_check_count": 6, + "acceptance_check_count": 8, + "rejection_rule_count": 10, + "received_response_count": 0, + "accepted_response_count": 0, + "rejected_response_count": 0, + }, + "false_flags": [ + "runtime_execution_authorized", + "action_buttons_allowed", + "refs_sync_authorized", + "refs_delete_authorized", + "force_push_authorized", + "github_primary_switch_authorized", + "secret_value_collection_allowed", + ], + }, + { + "label": "s4.12 workflow secret owner response", + "path": "docs/security/source-control-workflow-secret-name-owner-response.snapshot.json", + "schema": "source_control_workflow_secret_name_owner_response_v1", + "status": "draft_waiting_owner_response", + "template_count": 5, + "summary_counts": { + "owner_response_request_packet_count": 1, + "response_template_count": 5, + "owner_response_template_status_count": 5, + "intake_preflight_check_count": 6, + "acceptance_check_count": 8, + "rejection_rule_count": 10, + "received_response_count": 0, + "accepted_response_count": 0, + "rejected_response_count": 0, + }, + "false_flags": [ + "runtime_execution_authorized", + "action_buttons_allowed", + "workflow_modification_authorized", + "repo_secret_change_authorized", + "runner_change_authorized", + "webhook_modification_authorized", + "branch_protection_change_authorized", + "deploy_key_change_authorized", + "github_hosted_runner_enable_authorized", + "secret_value_collection_allowed", + "secret_value_or_hash_collection_allowed", + "workflow_secret_owner_request_dispatch_authorized", + "write_token_allowed", + ], + }, +] + + +def load_json(path: Path) -> dict[str, Any]: + return json.loads(path.read_text(encoding="utf-8")) + + +def fail(message: str) -> None: + raise SystemExit(f"BLOCKED {message}") + + +def assert_equal(label: str, actual: Any, expected: Any) -> None: + if actual != expected: + fail(f"{label}: expected {expected!r}, got {actual!r}") + + +def assert_contains(label: str, values: list[Any], expected: Any) -> None: + if expected not in values: + fail(f"{label}: missing {expected!r}") + + +def assert_text_contains(label: str, text: str, expected: str) -> None: + if expected not in text: + fail(f"{label}: missing {expected!r}") + + +def assert_path_exists(root: Path, relative_path: str) -> None: + if not (root / relative_path).exists(): + fail(f"path missing: {relative_path}") + + +def summary_value(data: dict[str, Any], key: str) -> Any: + summary = data.get("summary", {}) + if key in summary: + return summary[key] + return data.get(key) + + +def assert_false_summary_flag(label: str, data: dict[str, Any], key: str) -> None: + value = summary_value(data, key) + assert_equal(f"{label}.{key}", value, False) + + +def validate_docs(root: Path) -> None: + for relative_path in REQUIRED_DOCS: + assert_path_exists(root, relative_path) + + canonical_text = (root / "docs/security/S4-9-CANONICAL-OWNER-RESPONSE-ENVELOPE.md").read_text( + encoding="utf-8" + ) + intake_text = (root / "docs/security/S4-9-OWNER-RESPONSE-INTAKE-FORM.md").read_text( + encoding="utf-8" + ) + gap_text = (root / "docs/security/S4-9-OWNER-RESPONSE-GATE-CURRENT-GAP-AUDIT.md").read_text( + encoding="utf-8" + ) + for field in CANONICAL_FIELDS: + assert_text_contains("canonical_envelope.fields", canonical_text, field) + assert_text_contains("intake_form.fields", intake_text, field) + for template_id in S4_9_TEMPLATES: + assert_text_contains("canonical_envelope.templates", canonical_text, template_id) + assert_text_contains("intake_form.templates", intake_text, template_id) + for marker in [ + "request_sent=false", + "received_response_count=0", + "accepted_response_count=0", + "runtime_execution_authorized=false", + "action_buttons_allowed=false", + ]: + assert_text_contains("canonical_envelope.zero_boundary", canonical_text, marker) + assert_text_contains("intake_form.zero_boundary", intake_text, marker) + assert_text_contains("gap_audit.owner_gate_zero", gap_text, "S4.9 owner response gate 仍是 `0%`") + + +def validate_gap_audit(root: Path) -> None: + gap = load_json(root / "docs/security/s4-9-owner-response-gap-audit.snapshot.json") + summary = gap["summary"] + assert_equal("gap.schema_version", gap["schema_version"], "s4_9_owner_response_gap_audit_v1") + assert_equal("gap.status", gap["status"], "gap_audit_ready_owner_gate_zero") + assert_equal("gap.summary.current_requirement_gap_count", summary["current_requirement_gap_count"], 8) + assert_equal("gap.summary.new_rule_count", summary["new_rule_count"], 7) + assert_equal("gap.summary.rule_adjustment_count", summary["rule_adjustment_count"], 7) + assert_equal("gap.summary.priority_work_item_count", summary["priority_work_item_count"], 9) + for key in [ + "request_sent_count", + "owner_response_received_count", + "owner_response_accepted_count", + "owner_response_rejected_count", + "runtime_gate_count", + ]: + assert_equal(f"gap.summary.{key}", summary[key], 0) + assert_equal("gap.summary.public_surface_raw_namespace_allowed", summary["public_surface_raw_namespace_allowed"], False) + assert_equal( + "gap.summary.work_session_transcript_public_allowed", + summary["work_session_transcript_public_allowed"], + False, + ) + false_boundaries = gap["false_boundaries"] + for key, value in false_boundaries.items(): + if value is not False: + fail(f"gap.false_boundaries.{key}: expected false, got {value!r}") + + +def validate_owner_packet(root: Path, spec: dict[str, Any]) -> None: + data = load_json(root / spec["path"]) + label = spec["label"] + assert_equal(f"{label}.schema_version", data.get("schema_version"), spec["schema"]) + assert_equal(f"{label}.status", data.get("status"), spec["status"]) + assert_equal(f"{label}.runtime_execution_authorized", data.get("runtime_execution_authorized"), False) + for key, expected in spec["summary_counts"].items(): + assert_equal(f"{label}.summary.{key}", summary_value(data, key), expected) + for key in spec["false_flags"]: + assert_false_summary_flag(label, data, key) + + templates = data.get("response_templates", []) + if not isinstance(templates, list): + fail(f"{label}.response_templates: expected list") + assert_equal(f"{label}.response_templates.count", len(templates), spec["template_count"]) + if "expected_template_ids" in spec: + template_ids = [item.get("template_id") or item.get("id") for item in templates] + for template_id in spec["expected_template_ids"]: + assert_contains(f"{label}.response_templates", template_ids, template_id) + + +def validate_rollup(root: Path) -> None: + rollup = load_json(root / "docs/security/source-control-owner-response-validation-rollup.snapshot.json") + summary = rollup["summary"] + assert_equal("rollup.schema_version", rollup["schema_version"], "source_control_owner_response_validation_rollup_v1") + assert_equal("rollup.status", rollup["status"], "draft_waiting_owner_responses") + assert_equal("rollup.summary.response_packet_count", summary["response_packet_count"], 4) + assert_equal("rollup.summary.total_response_template_count", summary["total_response_template_count"], 24) + assert_equal("rollup.summary.total_acceptance_check_count", summary["total_acceptance_check_count"], 32) + assert_equal("rollup.summary.total_rejection_rule_count", summary["total_rejection_rule_count"], 40) + assert_equal("rollup.summary.validation_lane_count", summary["validation_lane_count"], 4) + assert_equal("rollup.summary.owner_response_validation_reviewer_checklist_count", summary["owner_response_validation_reviewer_checklist_count"], 9) + assert_equal("rollup.summary.owner_response_validation_reviewer_outcome_lane_count", summary["owner_response_validation_reviewer_outcome_lane_count"], 7) + for key in [ + "total_received_response_count", + "total_accepted_response_count", + "total_rejected_response_count", + "primary_ready_count", + ]: + assert_equal(f"rollup.summary.{key}", summary[key], 0) + for key in [ + "runtime_execution_authorized", + "action_buttons_allowed", + "repo_creation_authorized", + "visibility_change_authorized", + "refs_sync_authorized", + "refs_delete_authorized", + "workflow_modification_authorized", + "runner_enablement_authorized", + "secret_value_collection_allowed", + "github_primary_switch_authorized", + "force_push_authorized", + "write_token_allowed", + ]: + assert_false_summary_flag("rollup", rollup, key) + + +def validate(root: Path) -> None: + validate_docs(root) + validate_gap_audit(root) + validate_rollup(root) + for spec in OWNER_PACKET_SPECS: + validate_owner_packet(root, spec) + + +def main() -> None: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument( + "--root", + default=Path(__file__).resolve().parents[2], + type=Path, + help="Repository root. Defaults to the current script's repository.", + ) + args = parser.parse_args() + validate(args.root.resolve()) + print("IWOOOS_OWNER_GATE_GUARD_OK") + + +if __name__ == "__main__": + main() diff --git a/scripts/security/security-mirror-progress-guard.py b/scripts/security/security-mirror-progress-guard.py index 9185d2ee..25641664 100755 --- a/scripts/security/security-mirror-progress-guard.py +++ b/scripts/security/security-mirror-progress-guard.py @@ -77,6 +77,8 @@ def validate(root: Path) -> None: security_dir = root / "docs" / "security" config_control_guard = runpy.run_path(str(root / "scripts" / "security" / "iwooos-config-control-guard.py")) config_control_guard["validate"](root) + owner_gate_guard = runpy.run_path(str(root / "scripts" / "security" / "iwooos-owner-gate-guard.py")) + owner_gate_guard["validate"](root) manifest = load_json(security_dir / "security-supply-chain-contract-manifest.snapshot.json") readiness = load_json(security_dir / "security-mirror-readiness.snapshot.json") @@ -11982,12 +11984,28 @@ def validate(root: Path) -> None: for text in [ "sourcePublicScopeCode(index)", + "assetPublicCode(index)", + "routePublicCode(index)", + "tenantPublicCode(index)", + "tenantPublicName(tenant, index)", + "tenantBudgetState(tenant)", + "isPublicAssetTextSafe", + "lookupPublicProductName", + "publicChineseAssetText", + "assetPublicProductName(item, index)", + "routePublicProductName(route, index)", + "sourcePublicProductName(repo, index)", "sourceRepos.map((repo, index)", "sourceRiskLabel(repo.risk, t)", "sourceReadinessLabel(repo.readiness_state, t)", "sourceActionLabel(repo.readiness_state, t)", "redactedScope", "nextControl", + "租戶代號", + "公開名稱", + "預算狀態", + "已提交證據參照", + "完整證據路徑保留在只讀台帳與 guard", ]: assert_text_contains("awooop_tenants_page.source_namespace_redaction", awooop_tenants_page, text) for text in [ @@ -12005,6 +12023,16 @@ def validate(root: Path) -> None: "repo_owner_namespace_redacted=true", "raw_repository_namespace_visible=false", "public_api_raw_repo_namespace_allowed=false", + "{tenant.display_name || \"--\"}", + "budget?.toLocaleString", + "minimumFractionDigits", + "專案 ID", + "預算上限 (USD)", + "{item.project_id}", + "publicAssetText(item.product_name, item.project_id)", + "publicAssetText(route.product_name, route.product_id)", + "evidence_refs.map((ref)", + "{ref}", ]: assert_text_not_contains("awooop_tenants_page.raw_source_control_status_render", awooop_tenants_page, text) assert_text_not_contains( @@ -24964,7 +24992,7 @@ def validate(root: Path) -> None: assert_equal( "dry_run.latest_local_validation.result", local_validation["result"], - "SECURITY_MIRROR_PROGRESS_GUARD_OK; SOURCE_CONTROL_OWNER_RESPONSE_GUARD_OK; IWOOOS_CONFIG_CONTROL_GUARD_OK", + "SECURITY_MIRROR_PROGRESS_GUARD_OK; SOURCE_CONTROL_OWNER_RESPONSE_GUARD_OK; IWOOOS_CONFIG_CONTROL_GUARD_OK; IWOOOS_OWNER_GATE_GUARD_OK", ) assert_contains("dry_run.latest_local_validation.validated_steps", local_validation["validated_steps"], "CHECK_PROGRESS_GUARD") assert_contains( @@ -24972,6 +25000,11 @@ def validate(root: Path) -> None: local_validation["validated_steps"], "CHECK_OWNER_RESPONSE_GUARD", ) + assert_contains( + "dry_run.latest_local_validation.validated_steps", + local_validation["validated_steps"], + "CHECK_OWNER_GATE_GUARD", + ) assert_contains( "dry_run.latest_local_validation.validated_steps", local_validation["validated_steps"],