feat(governance): 新增 Agent Gitea PR 草案 lane
All checks were successful
CD Pipeline / tests (push) Successful in 1m32s
Code Review / ai-code-review (push) Successful in 14s
CD Pipeline / build-and-deploy (push) Successful in 6m10s
CD Pipeline / post-deploy-checks (push) Successful in 1m38s

This commit is contained in:
Your Name
2026-06-11 15:09:39 +08:00
parent cd92885277
commit 4da7f2c506
16 changed files with 1779 additions and 27 deletions

View File

@@ -55,6 +55,9 @@ from src.services.ai_agent_communication_learning_contract import (
from src.services.ai_agent_deployment_layout import (
load_latest_ai_agent_deployment_layout,
)
from src.services.ai_agent_gitea_pr_draft_lane import (
load_latest_ai_agent_gitea_pr_draft_lane,
)
from src.services.ai_agent_proactive_operations_contract import (
load_latest_ai_agent_proactive_operations_contract,
)
@@ -677,6 +680,35 @@ async def get_agent_telegram_action_required_digest_policy() -> dict[str, Any]:
) from exc
@router.get(
"/agent-gitea-pr-draft-lane",
response_model=dict[str, Any],
summary="取得 AI Agent Gitea PR 草案 lane",
description=(
"讀取最新已提交的 AI Agent Gitea PR 草案 lane"
"此端點只回傳 grouping、automerge=false、測試證據、rollback、owner response 與 redaction 邊界,"
"不 push branch、不建立或更新 Gitea PR、不留言、不 auto merge、不觸發 workflow、不改 CI、"
"不寫 lockfile、不升級套件、不 build/pull image、不改 production route、不發 Telegram、"
"不讀取 secret、不回傳工作視窗對話內容。"
),
)
async def get_agent_gitea_pr_draft_lane() -> dict[str, Any]:
"""Return the latest read-only AI Agent Gitea PR draft lane policy."""
try:
return await asyncio.to_thread(load_latest_ai_agent_gitea_pr_draft_lane)
except FileNotFoundError as exc:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=str(exc),
) from exc
except (json.JSONDecodeError, ValueError) as exc:
logger.error("ai_agent_gitea_pr_draft_lane_invalid", error=str(exc))
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="AI Agent Gitea PR 草案 lane 無效",
) from exc
@router.get(
"/runtime-surface-inventory",
response_model=dict[str, Any],

View File

@@ -0,0 +1,300 @@
"""
AI Agent Gitea PR draft lane snapshot.
Loads the latest committed, read-only policy for AI Agent generated Gitea PR
draft plans. This module never pushes branches, creates PRs, edits workflows,
writes lockfiles, upgrades packages, triggers CI, sends Telegram messages, or
exposes work-window transcripts.
"""
from __future__ import annotations
import json
from pathlib import Path
from typing import Any
from src.services.snapshot_paths import default_evaluations_dir
_DEFAULT_EVALUATIONS_DIR = default_evaluations_dir(Path(__file__))
_SNAPSHOT_PATTERN = "ai_agent_gitea_pr_draft_lane_*.json"
_SCHEMA_VERSION = "ai_agent_gitea_pr_draft_lane_v1"
_RUNTIME_AUTHORITY = "draft_lane_only_no_pr_creation_or_branch_push"
_TRANSCRIPT_MARKERS = {
"# In app browser",
"My request for Codex",
"Current URL:",
"AGENTS.md instructions",
"<environment_context>",
"批准!繼續",
}
def load_latest_ai_agent_gitea_pr_draft_lane(
evaluations_dir: Path | None = None,
) -> dict[str, Any]:
"""Load the newest committed AI Agent Gitea PR draft lane policy."""
directory = evaluations_dir or _DEFAULT_EVALUATIONS_DIR
candidates = sorted(directory.glob(_SNAPSHOT_PATTERN))
if not candidates:
raise FileNotFoundError(f"no AI Agent Gitea PR draft lane snapshots found in {directory}")
latest = candidates[-1]
with latest.open(encoding="utf-8") as handle:
payload = json.load(handle)
if not isinstance(payload, dict):
raise ValueError(f"{latest}: expected JSON object")
_require_schema(payload, _SCHEMA_VERSION, str(latest))
_require_read_only_boundaries(payload, str(latest))
_require_rollup_consistency(payload, str(latest))
_require_grouping_and_checks(payload, str(latest))
_require_owner_and_rollback_contracts(payload, str(latest))
_require_template_redaction(payload, str(latest))
_require_no_plaintext_secret_payload_keys(payload, str(latest))
_require_no_conversation_transcript_content(payload, str(latest))
return payload
def _require_schema(payload: dict[str, Any], expected: str, label: str) -> None:
actual = payload.get("schema_version")
if actual != expected:
raise ValueError(f"{label}: expected schema_version={expected}, got {actual!r}")
def _require_read_only_boundaries(payload: dict[str, Any], label: str) -> None:
program_status = payload.get("program_status") or {}
if program_status.get("read_only_mode") is not True:
raise ValueError(f"{label}: program_status.read_only_mode must be true")
if program_status.get("runtime_authority") != _RUNTIME_AUTHORITY:
raise ValueError(f"{label}: runtime_authority must stay {_RUNTIME_AUTHORITY}")
operation_boundaries = payload.get("operation_boundaries") or {}
if operation_boundaries.get("read_only_lane_allowed") is not True:
raise ValueError(f"{label}: read_only_lane_allowed must be true")
blocked_operation_flags = {
"gitea_branch_push_allowed",
"gitea_pr_creation_allowed",
"gitea_pr_update_allowed",
"gitea_pr_comment_allowed",
"auto_merge_allowed",
"workflow_trigger_allowed",
"ci_workflow_change_allowed",
"lockfile_write_allowed",
"package_upgrade_allowed",
"file_mutation_allowed",
"external_registry_lookup_allowed",
"vulnerability_database_download_allowed",
"docker_build_allowed",
"image_pull_allowed",
"production_route_change_allowed",
"telegram_direct_send_allowed",
"telegram_gateway_queue_write_allowed",
"secret_plaintext_allowed",
"conversation_transcript_allowed",
}
allowed_operation_flags = sorted(
flag
for flag in blocked_operation_flags
if operation_boundaries.get(flag) is not False
)
if allowed_operation_flags:
raise ValueError(
f"{label}: operation boundaries must remain false: {allowed_operation_flags}"
)
approval_boundaries = payload.get("approval_boundaries") or {}
allowed_approval_flags = sorted(
flag for flag, value in approval_boundaries.items() if value is not False
)
if allowed_approval_flags:
raise ValueError(
f"{label}: approval boundaries must remain false: {allowed_approval_flags}"
)
def _require_rollup_consistency(payload: dict[str, Any], label: str) -> None:
grouping_rules = payload.get("grouping_rules") or []
lane_steps = payload.get("lane_steps") or []
required_checks = payload.get("required_checks") or []
owner_requirements = payload.get("owner_response_requirements") or []
rollback_requirements = payload.get("rollback_requirements") or []
templates = payload.get("draft_templates") or []
rollups = payload.get("rollups") or {}
expected_counts = {
"grouping_rule_count": len(grouping_rules),
"lane_step_count": len(lane_steps),
"required_check_count": len(required_checks),
"owner_response_requirement_count": len(owner_requirements),
"rollback_requirement_count": len(rollback_requirements),
"draft_template_count": len(templates),
}
mismatched = {
key: {"expected": expected, "actual": rollups.get(key)}
for key, expected in expected_counts.items()
if rollups.get(key) != expected
}
if mismatched:
raise ValueError(f"{label}: rollup counts must match payload sections: {mismatched}")
expected_group_ids = sorted(rule.get("group_id") for rule in grouping_rules)
if sorted(rollups.get("draft_group_ids") or []) != expected_group_ids:
raise ValueError(f"{label}: rollups.draft_group_ids mismatch")
expected_owner_ids = sorted(
requirement.get("requirement_id") for requirement in owner_requirements
)
if sorted(rollups.get("owner_response_requirement_ids") or []) != expected_owner_ids:
raise ValueError(f"{label}: rollups.owner_response_requirement_ids mismatch")
zero_rollups = {
"gitea_branch_push_allowed_count",
"gitea_pr_creation_allowed_count",
"auto_merge_allowed_count",
"workflow_trigger_allowed_count",
"lockfile_write_allowed_count",
"telegram_direct_send_allowed_count",
"conversation_transcript_allowed_count",
}
nonzero = sorted(key for key in zero_rollups if rollups.get(key) != 0)
if nonzero:
raise ValueError(f"{label}: draft lane safety counters must remain 0: {nonzero}")
def _require_grouping_and_checks(payload: dict[str, Any], label: str) -> None:
unsafe_groups = [
rule.get("group_id")
for rule in payload.get("grouping_rules") or []
if rule.get("draft_only") is not True
or rule.get("automerge") is not False
or rule.get("requires_openclaw_review") is not True
or rule.get("rollback_required") is not True
or not rule.get("required_check_ids")
or not isinstance(rule.get("max_batch_size"), int)
or rule.get("max_batch_size", 0) < 1
]
if unsafe_groups:
raise ValueError(f"{label}: grouping rules must stay draft-only and gated: {unsafe_groups}")
check_ids = {check.get("check_id") for check in payload.get("required_checks") or []}
unknown_check_refs = sorted(
{
check_id
for rule in payload.get("grouping_rules") or []
for check_id in rule.get("required_check_ids") or []
if check_id not in check_ids
}
)
if unknown_check_refs:
raise ValueError(f"{label}: grouping rules reference unknown checks: {unknown_check_refs}")
unsafe_checks = [
check.get("check_id")
for check in payload.get("required_checks") or []
if check.get("blocking") is not True
or check.get("evidence_required") is not True
or check.get("run_now_allowed") is not False
]
if unsafe_checks:
raise ValueError(f"{label}: required checks must be blocking evidence-only: {unsafe_checks}")
unsafe_steps = [
step.get("step_id")
for step in payload.get("lane_steps") or []
if step.get("runtime_execution_allowed") is not False
or step.get("repo_write_allowed") is not False
or not step.get("planned_output")
]
if unsafe_steps:
raise ValueError(f"{label}: lane steps must remain read-only plans: {unsafe_steps}")
def _require_owner_and_rollback_contracts(payload: dict[str, Any], label: str) -> None:
required_owner_fields = {
"owner",
"decision",
"business_impact",
"risk_acceptance",
"rollback_acceptance",
"maintenance_window",
"evidence_ref",
}
actual_owner_fields = {
field
for requirement in payload.get("owner_response_requirements") or []
for field in requirement.get("required_fields") or []
}
if not required_owner_fields.issubset(actual_owner_fields):
raise ValueError(f"{label}: owner response requirements missing required fields")
unsafe_rollback = [
item.get("requirement_id")
for item in payload.get("rollback_requirements") or []
if item.get("required") is not True
or item.get("must_be_attached_before_pr_creation") is not True
]
if unsafe_rollback:
raise ValueError(f"{label}: rollback requirements must be attached before PR: {unsafe_rollback}")
def _require_template_redaction(payload: dict[str, Any], label: str) -> None:
forbidden_fields = {
"secret_value",
"token",
"authorization_header",
"work_window_transcript",
"codex_user_message",
"prompt_text",
"chain_of_thought",
"session_id",
"browser_context",
}
for template in payload.get("draft_templates") or []:
template_id = template.get("template_id")
if template.get("automerge") is not False:
raise ValueError(f"{label}: draft template must keep automerge=false: {template_id}")
if template.get("branch_push_allowed") is not False:
raise ValueError(f"{label}: draft template must not allow branch push: {template_id}")
if not forbidden_fields.issubset(set(template.get("forbidden_fields") or [])):
raise ValueError(f"{label}: draft template missing redaction fields: {template_id}")
display = payload.get("display_redaction_contract") or {}
if display.get("conversation_transcript_display_allowed") is not False:
raise ValueError(f"{label}: conversation transcript display must remain false")
if display.get("redaction_required") is not True:
raise ValueError(f"{label}: display redaction must be required")
def _require_no_plaintext_secret_payload_keys(value: Any, label: str, path: str = "$") -> None:
if isinstance(value, dict):
forbidden_key_fragments = {
"secret_value",
"token_plaintext",
"authorization_header",
"private_key",
"credential_value",
}
for key, nested in value.items():
normalized_key = str(key).lower()
if any(fragment in normalized_key for fragment in forbidden_key_fragments):
raise ValueError(f"{label}: forbidden plaintext secret key at {path}.{key}")
_require_no_plaintext_secret_payload_keys(nested, label, f"{path}.{key}")
elif isinstance(value, list):
for index, nested in enumerate(value):
_require_no_plaintext_secret_payload_keys(nested, label, f"{path}[{index}]")
def _require_no_conversation_transcript_content(value: Any, label: str, path: str = "$") -> None:
if isinstance(value, str):
for marker in _TRANSCRIPT_MARKERS:
if marker in value:
raise ValueError(
f"{label}: forbidden work-window conversation content at {path}: {marker}"
)
elif isinstance(value, dict):
for key, nested in value.items():
_require_no_conversation_transcript_content(nested, label, f"{path}.{key}")
elif isinstance(value, list):
for index, nested in enumerate(value):
_require_no_conversation_transcript_content(nested, label, f"{path}[{index}]")