feat(governance): 新增候選操作 dry-run 證據
All checks were successful
Code Review / ai-code-review (push) Successful in 16s
CD Pipeline / tests (push) Successful in 1m33s
CD Pipeline / build-and-deploy (push) Successful in 8m17s
CD Pipeline / post-deploy-checks (push) Successful in 2m22s

This commit is contained in:
Your Name
2026-06-12 15:45:52 +08:00
parent c4ceb30511
commit 9dbbc579d2
14 changed files with 1927 additions and 14 deletions

View File

@@ -49,6 +49,9 @@ from src.services.ai_agent_automation_backlog_snapshot import (
from src.services.ai_agent_automation_inventory_snapshot import (
load_latest_ai_agent_automation_inventory_snapshot,
)
from src.services.ai_agent_candidate_operation_dry_run_evidence import (
load_latest_ai_agent_candidate_operation_dry_run_evidence,
)
from src.services.ai_agent_communication_learning_contract import (
load_latest_ai_agent_communication_learning_contract,
)
@@ -1066,6 +1069,34 @@ async def get_agent_operation_permission_model() -> dict[str, Any]:
) from exc
@router.get(
"/agent-candidate-operation-dry-run-evidence",
response_model=dict[str, Any],
summary="取得 AI Agent 候選操作 dry-run 證據",
description=(
"讀取最新已提交的 P2-102 候選操作 dry-run 證據;此端點只回傳 candidate operation、"
"dry-run evidence hash、side-effect count、verifier plan、gate requirement 與 operator handoff"
"不啟動 runtime worker、不寫 Gateway queue、不送 Telegram、不呼叫 Bot API、"
"不寫讀報回執、不執行 verifier live readback、不寫 production target、不讀 secret。"
),
)
async def get_agent_candidate_operation_dry_run_evidence() -> dict[str, Any]:
"""Return the latest read-only AI Agent candidate operation dry-run evidence."""
try:
return await asyncio.to_thread(load_latest_ai_agent_candidate_operation_dry_run_evidence)
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_candidate_operation_dry_run_evidence_invalid", error=str(exc))
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="AI Agent 候選操作 dry-run 證據無效",
) from exc
@router.get(
"/agent-owner-approved-fixture-dry-run",
response_model=dict[str, Any],

View File

@@ -0,0 +1,298 @@
"""
AI Agent candidate operation dry-run evidence snapshot.
Loads the latest committed P2-102 candidate operation dry-run evidence.
This module validates repo-committed evidence only; it never starts runtime
workers, writes Gateway queues, sends Telegram messages, reads secrets, or
writes production targets.
"""
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_candidate_operation_dry_run_evidence_*.json"
_SCHEMA_VERSION = "ai_agent_candidate_operation_dry_run_evidence_v1"
_RUNTIME_AUTHORITY = "candidate_operation_dry_run_evidence_only_no_live_execution_or_send"
def load_latest_ai_agent_candidate_operation_dry_run_evidence(
evaluations_dir: Path | None = None,
) -> dict[str, Any]:
"""Load the newest committed AI Agent candidate operation dry-run evidence."""
directory = evaluations_dir or _DEFAULT_EVALUATIONS_DIR
candidates = sorted(directory.glob(_SNAPSHOT_PATTERN))
if not candidates:
raise FileNotFoundError(f"no AI Agent candidate operation dry-run evidence 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, str(latest))
_require_no_live_boundaries(payload, str(latest))
_require_candidate_operations(payload, str(latest))
_require_verifier_plans(payload, str(latest))
_require_gate_requirements(payload, str(latest))
_require_operator_handoffs(payload, str(latest))
_require_redaction_contract(payload, str(latest))
_require_rollup_consistency(payload, str(latest))
return payload
def _require_schema(payload: dict[str, Any], label: str) -> None:
if payload.get("schema_version") != _SCHEMA_VERSION:
raise ValueError(f"{label}: expected schema_version={_SCHEMA_VERSION}")
status = payload.get("program_status") or {}
if status.get("read_only_mode") is not True:
raise ValueError(f"{label}: program_status.read_only_mode must be true")
if status.get("runtime_authority") != _RUNTIME_AUTHORITY:
raise ValueError(f"{label}: runtime_authority must remain {_RUNTIME_AUTHORITY}")
if status.get("current_task_id") != "P2-102":
raise ValueError(f"{label}: current_task_id must be P2-102")
if status.get("next_task_id") != "P2-103":
raise ValueError(f"{label}: next_task_id must be P2-103")
def _require_no_live_boundaries(payload: dict[str, Any], label: str) -> None:
truth = payload.get("dry_run_truth") or {}
required_true = {
"p2_101_permission_model_loaded",
"dry_run_evidence_gate_ready",
"all_candidate_operations_have_dry_run_evidence",
"side_effect_counter_ready",
"verifier_plan_ready",
"rollback_or_noop_plan_ready",
"owner_review_packet_ready",
}
missing = sorted(field for field in required_true if truth.get(field) is not True)
if missing:
raise ValueError(f"{label}: dry-run readiness flags must remain true: {missing}")
required_false = {
"runtime_execution_enabled",
"gateway_queue_write_enabled",
"telegram_send_enabled",
"telegram_bot_api_call_enabled",
"delivery_receipt_write_enabled",
"ai_runtime_worker_enabled",
"medium_low_auto_worker_enabled",
"post_action_verifier_live_readback_enabled",
"production_write_enabled",
"secret_value_read_enabled",
"paid_provider_call_enabled",
"host_or_cluster_command_enabled",
"destructive_operation_enabled",
"work_window_transcript_display_allowed",
}
unsafe = sorted(field for field in required_false if truth.get(field) is not False)
if unsafe:
raise ValueError(f"{label}: live execution/send/write flags must remain false: {unsafe}")
zero_counts = {
"runtime_execution_count_24h",
"gateway_queue_write_count_24h",
"telegram_send_count_24h",
"telegram_bot_api_call_count_24h",
"delivery_receipt_write_count_24h",
"ai_runtime_worker_run_count_24h",
"medium_low_auto_execution_count_24h",
"post_action_verifier_live_readback_count_24h",
"production_write_count_24h",
"secret_value_read_count_24h",
"paid_provider_call_count_24h",
"host_or_cluster_command_count_24h",
"destructive_operation_count_24h",
}
non_zero = sorted(field for field in zero_counts if truth.get(field) != 0)
if non_zero:
raise ValueError(f"{label}: live execution/send/write counts must remain zero: {non_zero}")
def _require_candidate_operations(payload: dict[str, Any], label: str) -> None:
candidates = payload.get("candidate_operations") or []
candidate_ids = {candidate.get("candidate_id") for candidate in candidates}
required = {
"candidate_observe_inventory_read",
"candidate_diagnose_correlate_evidence",
"candidate_report_digest_queue",
"candidate_shadow_no_write_replay",
"candidate_manual_sop_draft",
"candidate_repair_candidate_proposal",
"candidate_low_risk_noop_execution",
"candidate_medium_risk_repair_execution",
"candidate_post_action_verifier_live_readback",
"candidate_telegram_gateway_queue_write",
"candidate_production_config_or_data_write",
"candidate_secret_or_paid_provider_access",
"candidate_destructive_host_or_cluster_action",
}
if candidate_ids != required:
raise ValueError(f"{label}: candidate operations must match {sorted(required)}")
valid_statuses = {"passed_no_write", "needs_owner_review", "blocked_until_allowlist", "blocked_by_policy"}
for candidate in candidates:
candidate_id = candidate.get("candidate_id")
if candidate.get("dry_run_status") not in valid_statuses:
raise ValueError(f"{label}: candidate {candidate_id} dry_run_status is invalid")
if not _is_redacted_sha256(candidate.get("input_evidence_hash")):
raise ValueError(f"{label}: candidate {candidate_id} must expose input_evidence_hash")
if not _is_redacted_sha256(candidate.get("output_evidence_hash")):
raise ValueError(f"{label}: candidate {candidate_id} must expose output_evidence_hash")
zero_fields = {
"side_effect_count",
"production_write_count",
"gateway_queue_write_count",
"telegram_send_count",
"secret_value_read_count",
"destructive_action_count",
}
non_zero = sorted(field for field in zero_fields if candidate.get(field) != 0)
if non_zero:
raise ValueError(f"{label}: candidate {candidate_id} side-effect counts must remain zero: {non_zero}")
if not candidate.get("blocked_actions"):
raise ValueError(f"{label}: candidate {candidate_id} must list blocked_actions")
if not candidate.get("required_human_decision"):
raise ValueError(f"{label}: candidate {candidate_id} must list required_human_decision")
if not candidate.get("verifier_plan_id"):
raise ValueError(f"{label}: candidate {candidate_id} must bind verifier_plan_id")
if not candidate.get("next_gate"):
raise ValueError(f"{label}: candidate {candidate_id} must list next_gate")
def _require_verifier_plans(payload: dict[str, Any], label: str) -> None:
plans = payload.get("verifier_plans") or []
plan_ids = {plan.get("plan_id") for plan in plans}
required = {
"verifier_redacted_evidence_hash",
"verifier_gateway_queue_preview",
"verifier_shadow_replay_fixture",
"verifier_repair_candidate_consistency",
"verifier_live_readback_allowlist",
"verifier_destructive_boundary_preflight",
}
if plan_ids != required:
raise ValueError(f"{label}: verifier plans must match {sorted(required)}")
for plan in plans:
plan_id = plan.get("plan_id")
if plan.get("live_readback_enabled") is not False:
raise ValueError(f"{label}: verifier {plan_id} live_readback_enabled must remain false")
if plan.get("writes_result") is not False:
raise ValueError(f"{label}: verifier {plan_id} writes_result must remain false")
if plan.get("requires_secret_value") is not False:
raise ValueError(f"{label}: verifier {plan_id} requires_secret_value must remain false")
if not _is_redacted_sha256(plan.get("evidence_hash")):
raise ValueError(f"{label}: verifier {plan_id} must expose evidence_hash")
def _require_gate_requirements(payload: dict[str, Any], label: str) -> None:
gates = payload.get("gate_evidence_requirements") or []
gate_ids = {gate.get("gate_id") for gate in gates}
required = {
"p2_102_dry_run_evidence_gate",
"gateway_queue_write_permission_gate",
"medium_low_auto_worker_permission_gate",
"post_action_verifier_live_gate",
"production_write_permission_gate",
"secret_or_paid_provider_gate",
"break_glass_or_destructive_action_gate",
}
if gate_ids != required:
raise ValueError(f"{label}: gate evidence requirements must match {sorted(required)}")
for gate in gates:
gate_id = gate.get("gate_id")
if gate.get("opens_live_execution") is not False:
raise ValueError(f"{label}: gate {gate_id} opens_live_execution must remain false")
if not gate.get("required_evidence"):
raise ValueError(f"{label}: gate {gate_id} must list required_evidence")
def _require_operator_handoffs(payload: dict[str, Any], label: str) -> None:
handoffs = payload.get("operator_handoffs") or []
handoff_ids = {handoff.get("handoff_id") for handoff in handoffs}
required = {
"handoff_collect_missing_evidence",
"handoff_review_repair_candidate",
"handoff_review_sre_queue_preview",
"handoff_review_verifier_allowlist",
"handoff_escalate_blocked_operation",
}
if handoff_ids != required:
raise ValueError(f"{label}: operator handoffs must match {sorted(required)}")
for handoff in handoffs:
handoff_id = handoff.get("handoff_id")
if handoff.get("creates_runtime_action") is not False:
raise ValueError(f"{label}: handoff {handoff_id} creates_runtime_action must remain false")
if handoff.get("requires_human_review") is not True:
raise ValueError(f"{label}: handoff {handoff_id} requires_human_review must remain true")
def _require_redaction_contract(payload: dict[str, Any], label: str) -> None:
contract = payload.get("display_redaction_contract") or {}
required_false = {
"raw_prompt_display_allowed",
"private_reasoning_display_allowed",
"secret_value_display_allowed",
"raw_telegram_payload_display_allowed",
"work_window_transcript_display_allowed",
}
if contract.get("redaction_required") is not True:
raise ValueError(f"{label}: display redaction must remain required")
unsafe = sorted(field for field in required_false if contract.get(field) is not False)
if unsafe:
raise ValueError(f"{label}: display redaction fields must remain false: {unsafe}")
def _require_rollup_consistency(payload: dict[str, Any], label: str) -> None:
rollups = payload.get("rollups") or {}
truth = payload.get("dry_run_truth") or {}
candidates = payload.get("candidate_operations") or []
plans = payload.get("verifier_plans") or []
gates = payload.get("gate_evidence_requirements") or []
handoffs = payload.get("operator_handoffs") or []
expected = {
"candidate_operation_count": len(candidates),
"candidate_with_dry_run_evidence_count": sum(
1
for candidate in candidates
if _is_redacted_sha256(candidate.get("input_evidence_hash"))
and _is_redacted_sha256(candidate.get("output_evidence_hash"))
),
"passed_no_write_count": sum(1 for candidate in candidates if candidate.get("dry_run_status") == "passed_no_write"),
"needs_owner_review_count": sum(1 for candidate in candidates if candidate.get("dry_run_status") == "needs_owner_review"),
"blocked_until_allowlist_count": sum(1 for candidate in candidates if candidate.get("dry_run_status") == "blocked_until_allowlist"),
"blocked_by_policy_count": sum(1 for candidate in candidates if candidate.get("dry_run_status") == "blocked_by_policy"),
"verifier_plan_count": len(plans),
"gate_evidence_requirement_count": len(gates),
"operator_handoff_count": len(handoffs),
"side_effect_count": sum(candidate.get("side_effect_count", 0) for candidate in candidates),
"runtime_execution_count": truth.get("runtime_execution_count_24h"),
"gateway_queue_write_count": truth.get("gateway_queue_write_count_24h"),
"telegram_send_count": truth.get("telegram_send_count_24h"),
"production_write_count": truth.get("production_write_count_24h"),
"secret_value_read_count": truth.get("secret_value_read_count_24h"),
"destructive_operation_count": truth.get("destructive_operation_count_24h"),
}
mismatches = {
key: {"expected": expected_value, "actual": rollups.get(key)}
for key, expected_value in expected.items()
if rollups.get(key) != expected_value
}
if mismatches:
raise ValueError(f"{label}: rollup counts mismatch: {mismatches}")
def _is_redacted_sha256(value: Any) -> bool:
if not isinstance(value, str):
return False
if not value.startswith("sha256:") or len(value) != 71:
return False
return all(char in "0123456789abcdef" for char in value.removeprefix("sha256:"))