agent-lifecycle-kit 1.50.1__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- agent_lifecycle/__init__.py +5 -0
- agent_lifecycle/__main__.py +5 -0
- agent_lifecycle/_version.py +3 -0
- agent_lifecycle/adapter_sessions/__init__.py +41 -0
- agent_lifecycle/adapter_sessions/contracts.py +133 -0
- agent_lifecycle/adapter_sessions/env.py +44 -0
- agent_lifecycle/adapter_sessions/launcher.py +101 -0
- agent_lifecycle/adapter_sessions/process.py +39 -0
- agent_lifecycle/adapter_sessions/redaction.py +30 -0
- agent_lifecycle/adapter_sessions/session_store.py +64 -0
- agent_lifecycle/adapter_sessions/task_intake.py +450 -0
- agent_lifecycle/adapter_sessions/workflow_bridge.py +154 -0
- agent_lifecycle/audit/__init__.py +67 -0
- agent_lifecycle/audit/bug_forensics.py +94 -0
- agent_lifecycle/audit/implementation.py +516 -0
- agent_lifecycle/audit/ownership.py +119 -0
- agent_lifecycle/audit/proof_integrity.py +814 -0
- agent_lifecycle/audit/review_verdict.py +116 -0
- agent_lifecycle/changesets/__init__.py +5 -0
- agent_lifecycle/changesets/git.py +35 -0
- agent_lifecycle/cli/__init__.py +5 -0
- agent_lifecycle/cli/adapter.py +360 -0
- agent_lifecycle/cli/dispatch.py +1220 -0
- agent_lifecycle/cli/followup.py +89 -0
- agent_lifecycle/cli/main.py +41 -0
- agent_lifecycle/cli/metrics_parser.py +49 -0
- agent_lifecycle/cli/parsers.py +618 -0
- agent_lifecycle/cli/policy.py +101 -0
- agent_lifecycle/cli/progress_hooks.py +117 -0
- agent_lifecycle/cli/worktree.py +74 -0
- agent_lifecycle/compiler/__init__.py +17 -0
- agent_lifecycle/compiler/small_model_packets.py +322 -0
- agent_lifecycle/compiler/task_packets.py +229 -0
- agent_lifecycle/context/__init__.py +20 -0
- agent_lifecycle/context/episode_retrieval.py +30 -0
- agent_lifecycle/context/external_memory.py +71 -0
- agent_lifecycle/context/profiles.py +104 -0
- agent_lifecycle/context/rendering.py +226 -0
- agent_lifecycle/contracts/__init__.py +25 -0
- agent_lifecycle/contracts/adapter_contract_schemas.py +130 -0
- agent_lifecycle/contracts/adapter_event_schemas.py +118 -0
- agent_lifecycle/contracts/adapter_session_schemas.py +130 -0
- agent_lifecycle/contracts/adapter_task_schemas.py +90 -0
- agent_lifecycle/contracts/audit_schemas.py +133 -0
- agent_lifecycle/contracts/bug_forensics_schemas.py +400 -0
- agent_lifecycle/contracts/canonical.py +55 -0
- agent_lifecycle/contracts/compatibility.py +242 -0
- agent_lifecycle/contracts/context_model_schemas.py +265 -0
- agent_lifecycle/contracts/core_schemas.py +252 -0
- agent_lifecycle/contracts/cross_check_schemas.py +121 -0
- agent_lifecycle/contracts/errors.py +26 -0
- agent_lifecycle/contracts/evidence_import_schemas.py +152 -0
- agent_lifecycle/contracts/host_capability_schemas.py +64 -0
- agent_lifecycle/contracts/import_dialect_schemas.py +166 -0
- agent_lifecycle/contracts/metric_schemas.py +315 -0
- agent_lifecycle/contracts/paths.py +31 -0
- agent_lifecycle/contracts/plan_contract_schemas.py +148 -0
- agent_lifecycle/contracts/policy_schemas.py +350 -0
- agent_lifecycle/contracts/progress_bridge_schemas.py +114 -0
- agent_lifecycle/contracts/progress_hook_schemas.py +119 -0
- agent_lifecycle/contracts/proof_integrity_schemas.py +240 -0
- agent_lifecycle/contracts/release_contract_schemas.py +476 -0
- agent_lifecycle/contracts/review_mesh_recommendation_schemas.py +64 -0
- agent_lifecycle/contracts/review_mesh_schemas.py +222 -0
- agent_lifecycle/contracts/review_quality_schemas.py +109 -0
- agent_lifecycle/contracts/runner_schemas.py +91 -0
- agent_lifecycle/contracts/runner_worktree_schemas.py +389 -0
- agent_lifecycle/contracts/sandbox_schemas.py +159 -0
- agent_lifecycle/contracts/schema_builders.py +23 -0
- agent_lifecycle/contracts/schemas.py +93 -0
- agent_lifecycle/contracts/status_goal_schemas.py +469 -0
- agent_lifecycle/contracts/task_template_schemas.py +96 -0
- agent_lifecycle/contracts/usage_export_schemas.py +68 -0
- agent_lifecycle/diagnostics/__init__.py +17 -0
- agent_lifecycle/diagnostics/bundles.py +124 -0
- agent_lifecycle/diagnostics/readiness.py +733 -0
- agent_lifecycle/evidence_index/__init__.py +39 -0
- agent_lifecycle/evidence_index/core.py +272 -0
- agent_lifecycle/evidence_index/episode_index.py +329 -0
- agent_lifecycle/evidence_index/external_context.py +276 -0
- agent_lifecycle/followup/__init__.py +23 -0
- agent_lifecycle/followup/records.py +404 -0
- agent_lifecycle/freeze/__init__.py +5 -0
- agent_lifecycle/freeze/locks.py +23 -0
- agent_lifecycle/goal/__init__.py +15 -0
- agent_lifecycle/goal/records.py +244 -0
- agent_lifecycle/goal/view.py +109 -0
- agent_lifecycle/host_protocol/__init__.py +65 -0
- agent_lifecycle/host_protocol/acp_capability.py +195 -0
- agent_lifecycle/host_protocol/capabilities.py +268 -0
- agent_lifecycle/host_protocol/contracts.py +214 -0
- agent_lifecycle/host_protocol/event_capture.py +390 -0
- agent_lifecycle/host_protocol/events.py +196 -0
- agent_lifecycle/host_protocol/inspection.py +194 -0
- agent_lifecycle/host_protocol/inspection_common.py +310 -0
- agent_lifecycle/host_protocol/inspection_cursor.py +164 -0
- agent_lifecycle/host_protocol/inspection_gemini_cli.py +163 -0
- agent_lifecycle/host_protocol/inspection_hermes.py +161 -0
- agent_lifecycle/host_protocol/inspection_kimi_code.py +169 -0
- agent_lifecycle/host_protocol/inspection_opencode.py +147 -0
- agent_lifecycle/host_protocol/inspection_qwen_code.py +143 -0
- agent_lifecycle/host_protocol/receipts.py +58 -0
- agent_lifecycle/host_protocol/scaffold.py +329 -0
- agent_lifecycle/host_protocol/validation.py +229 -0
- agent_lifecycle/imports/__init__.py +76 -0
- agent_lifecycle/imports/agentskills_profile.py +54 -0
- agent_lifecycle/imports/bmad_profile.py +49 -0
- agent_lifecycle/imports/constitution_adr.py +91 -0
- agent_lifecycle/imports/dialect_profiles.py +121 -0
- agent_lifecycle/imports/external_agent.py +28 -0
- agent_lifecycle/imports/external_dialects.py +486 -0
- agent_lifecycle/imports/external_workflow.py +28 -0
- agent_lifecycle/imports/multi_markdown.py +111 -0
- agent_lifecycle/imports/openspec_profile.py +49 -0
- agent_lifecycle/imports/planning.py +536 -0
- agent_lifecycle/imports/spec_kit_profile.py +49 -0
- agent_lifecycle/imports/spec_kitty_profile.py +49 -0
- agent_lifecycle/metrics/__init__.py +60 -0
- agent_lifecycle/metrics/cost_collection.py +315 -0
- agent_lifecycle/metrics/costs.py +203 -0
- agent_lifecycle/metrics/outcome_index.py +330 -0
- agent_lifecycle/metrics/phase_resources.py +208 -0
- agent_lifecycle/metrics/recommendations.py +507 -0
- agent_lifecycle/metrics/regression_signals.py +64 -0
- agent_lifecycle/metrics/usage_export.py +390 -0
- agent_lifecycle/model_routing/__init__.py +19 -0
- agent_lifecycle/model_routing/profiles.py +323 -0
- agent_lifecycle/model_routing/receipts.py +140 -0
- agent_lifecycle/model_routing/resolver.py +566 -0
- agent_lifecycle/neutrality/__init__.py +12 -0
- agent_lifecycle/neutrality/__main__.py +6 -0
- agent_lifecycle/neutrality/authority.py +265 -0
- agent_lifecycle/neutrality/canonical.py +28 -0
- agent_lifecycle/neutrality/cli.py +294 -0
- agent_lifecycle/neutrality/ed25519.py +124 -0
- agent_lifecycle/neutrality/errors.py +89 -0
- agent_lifecycle/neutrality/gate.py +269 -0
- agent_lifecycle/neutrality/paths.py +92 -0
- agent_lifecycle/neutrality/policy.py +76 -0
- agent_lifecycle/neutrality/receipt.py +135 -0
- agent_lifecycle/neutrality/scanner.py +310 -0
- agent_lifecycle/neutrality/test_runner.py +116 -0
- agent_lifecycle/planning/__init__.py +47 -0
- agent_lifecycle/planning/acceptance_markdown.py +159 -0
- agent_lifecycle/planning/completeness.py +362 -0
- agent_lifecycle/planning/continuity.py +255 -0
- agent_lifecycle/planning/templates.py +294 -0
- agent_lifecycle/planning/tiering.py +87 -0
- agent_lifecycle/planning/validation.py +72 -0
- agent_lifecycle/policy/__init__.py +37 -0
- agent_lifecycle/policy/adaptive_lifecycle.py +416 -0
- agent_lifecycle/policy/apply.py +44 -0
- agent_lifecycle/policy/proposals.py +189 -0
- agent_lifecycle/policy/quality_floor.py +144 -0
- agent_lifecycle/policy/runtime_receipts.py +160 -0
- agent_lifecycle/py.typed +1 -0
- agent_lifecycle/quality/__init__.py +89 -0
- agent_lifecycle/quality/bug_forensics.py +727 -0
- agent_lifecycle/quality/bug_forensics_advisor.py +88 -0
- agent_lifecycle/quality/bug_forensics_recipes.py +239 -0
- agent_lifecycle/quality/cross_check.py +437 -0
- agent_lifecycle/quality/failure_classification.py +318 -0
- agent_lifecycle/quality/packs.py +265 -0
- agent_lifecycle/reporting/__init__.py +37 -0
- agent_lifecycle/reporting/change_summary.py +156 -0
- agent_lifecycle/reporting/event_feed.py +101 -0
- agent_lifecycle/reporting/progress_bridge.py +153 -0
- agent_lifecycle/reporting/progress_hooks.py +161 -0
- agent_lifecycle/reporting/progress_terminal.py +95 -0
- agent_lifecycle/reporting/progress_view.py +284 -0
- agent_lifecycle/reporting/status_view.py +125 -0
- agent_lifecycle/reporting/usage_export.py +59 -0
- agent_lifecycle/review/__init__.py +5 -0
- agent_lifecycle/review/validation.py +29 -0
- agent_lifecycle/review_mesh/__init__.py +83 -0
- agent_lifecycle/review_mesh/assignments.py +160 -0
- agent_lifecycle/review_mesh/contracts.py +685 -0
- agent_lifecycle/review_mesh/operator_templates.py +320 -0
- agent_lifecycle/review_mesh/quorum.py +38 -0
- agent_lifecycle/review_mesh/recommendation.py +487 -0
- agent_lifecycle/review_mesh/results.py +98 -0
- agent_lifecycle/review_mesh/synthesis.py +102 -0
- agent_lifecycle/runner/__init__.py +47 -0
- agent_lifecycle/runner/attempt_snapshots.py +244 -0
- agent_lifecycle/runner/core.py +653 -0
- agent_lifecycle/runner/sandbox_receipts.py +628 -0
- agent_lifecycle/specification/__init__.py +23 -0
- agent_lifecycle/specification/completion_check.py +135 -0
- agent_lifecycle/specification/completion_gate.py +501 -0
- agent_lifecycle/specification/completion_signal.py +75 -0
- agent_lifecycle/specification/validation.py +48 -0
- agent_lifecycle/workflow/__init__.py +79 -0
- agent_lifecycle/workflow/artifacts.py +74 -0
- agent_lifecycle/workflow/budget_decisions.py +370 -0
- agent_lifecycle/workflow/budget_policy.py +140 -0
- agent_lifecycle/workflow/budget_receipts.py +70 -0
- agent_lifecycle/workflow/bug_forensics_gates.py +284 -0
- agent_lifecycle/workflow/controller.py +45 -0
- agent_lifecycle/workflow/events.py +50 -0
- agent_lifecycle/workflow/final_proof_integrity.py +74 -0
- agent_lifecycle/workflow/finalization.py +422 -0
- agent_lifecycle/workflow/gates.py +185 -0
- agent_lifecycle/workflow/implementation_audit_gate.py +126 -0
- agent_lifecycle/workflow/leases.py +220 -0
- agent_lifecycle/workflow/lineage.py +108 -0
- agent_lifecycle/workflow/managed_runner.py +167 -0
- agent_lifecycle/workflow/model_usage.py +106 -0
- agent_lifecycle/workflow/next_action.py +86 -0
- agent_lifecycle/workflow/operation_kernel.py +98 -0
- agent_lifecycle/workflow/plan_adoption.py +439 -0
- agent_lifecycle/workflow/query.py +52 -0
- agent_lifecycle/workflow/review_mesh_gate.py +93 -0
- agent_lifecycle/workflow/reviews.py +168 -0
- agent_lifecycle/workflow/run_transitions.py +163 -0
- agent_lifecycle/workflow/sandbox_policy.py +214 -0
- agent_lifecycle/workflow/selectors.py +57 -0
- agent_lifecycle/workflow/state.py +130 -0
- agent_lifecycle/workflow/task_transitions.py +421 -0
- agent_lifecycle/worktree/__init__.py +17 -0
- agent_lifecycle/worktree/isolation.py +407 -0
- agent_lifecycle_kit-1.50.1.dist-info/METADATA +201 -0
- agent_lifecycle_kit-1.50.1.dist-info/RECORD +227 -0
- agent_lifecycle_kit-1.50.1.dist-info/WHEEL +5 -0
- agent_lifecycle_kit-1.50.1.dist-info/entry_points.txt +3 -0
- agent_lifecycle_kit-1.50.1.dist-info/licenses/LICENSE +201 -0
- agent_lifecycle_kit-1.50.1.dist-info/licenses/NOTICE +5 -0
- agent_lifecycle_kit-1.50.1.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,814 @@
|
|
|
1
|
+
"""Proof-integrity receipts for findings, root causes and final evidence."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
from agent_lifecycle.contracts import LifecycleError, canonical_digest
|
|
8
|
+
from agent_lifecycle.contracts.paths import normalize_repo_path
|
|
9
|
+
|
|
10
|
+
FINDING_SCHEMA = "agent-proof-finding.v1"
|
|
11
|
+
ROOT_CAUSE_SCHEMA = "agent-root-cause-evidence.v1"
|
|
12
|
+
FIX_IMPACT_SCHEMA = "agent-fix-impact-receipt.v1"
|
|
13
|
+
HASH_CHAIN_SCHEMA = "agent-receipt-hash-chain.v1"
|
|
14
|
+
HASH_CHAIN_MIGRATION_POLICY_SCHEMA = "agent-hash-chain-migration-policy.v1"
|
|
15
|
+
HASH_CHAIN_MIGRATION_VALIDATION_SCHEMA = "agent-hash-chain-migration-validation.v1"
|
|
16
|
+
PROOF_INTEGRITY_RECEIPT_SCHEMA = "agent-proof-integrity-receipt.v1"
|
|
17
|
+
PROOF_INTEGRITY_VALIDATION_SCHEMA = "agent-proof-integrity-validation.v1"
|
|
18
|
+
|
|
19
|
+
ROOT_CAUSE_STATUSES = {"CONFIRMED", "REJECTED", "INCONCLUSIVE"}
|
|
20
|
+
FIX_IMPACT_STATUSES = {"PASS", "FAIL", "BLOCKED"}
|
|
21
|
+
VALIDATION_STATUSES = {"PASS", "FAIL"}
|
|
22
|
+
DEFAULT_LEGACY_EXEMPTIONS = ("pre-chain-release", "external-archive", "artifact-unavailable")
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def stable_finding_id(finding: dict[str, Any], *, namespace: str = "default") -> str:
|
|
26
|
+
"""Return a deterministic finding id from normalized finding identity fields."""
|
|
27
|
+
|
|
28
|
+
identity_fields = finding_identity_fields(finding, namespace=namespace)
|
|
29
|
+
return f"finding-{canonical_digest(identity_fields)}"
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def finding_identity_fields(finding: dict[str, Any], *, namespace: str = "default") -> dict[str, Any]:
|
|
33
|
+
"""Project a finding to fields that should survive retries and line shifts."""
|
|
34
|
+
|
|
35
|
+
if not isinstance(finding, dict):
|
|
36
|
+
raise LifecycleError("invalid-proof-finding", "finding must be an object")
|
|
37
|
+
location = finding.get("location") if isinstance(finding.get("location"), dict) else {}
|
|
38
|
+
raw_path = (
|
|
39
|
+
finding.get("path")
|
|
40
|
+
or finding.get("file")
|
|
41
|
+
or finding.get("sourcePath")
|
|
42
|
+
or location.get("path")
|
|
43
|
+
or location.get("file")
|
|
44
|
+
)
|
|
45
|
+
path = _optional_repo_path(raw_path, label="finding path")
|
|
46
|
+
symbol = _optional_text(
|
|
47
|
+
finding.get("symbol")
|
|
48
|
+
or finding.get("function")
|
|
49
|
+
or finding.get("scope")
|
|
50
|
+
or location.get("symbol")
|
|
51
|
+
or location.get("function")
|
|
52
|
+
)
|
|
53
|
+
rule_id = _optional_text(finding.get("ruleId") or finding.get("rule") or finding.get("code"))
|
|
54
|
+
category = _optional_text(finding.get("category") or finding.get("type") or finding.get("kind"))
|
|
55
|
+
severity = _optional_text(finding.get("severity"))
|
|
56
|
+
message = _optional_text(finding.get("message") or finding.get("summary") or finding.get("title"))
|
|
57
|
+
fields = {
|
|
58
|
+
"namespace": _normalize_text(namespace),
|
|
59
|
+
"ruleId": rule_id,
|
|
60
|
+
"category": category,
|
|
61
|
+
"severity": severity,
|
|
62
|
+
"path": path,
|
|
63
|
+
"symbol": symbol,
|
|
64
|
+
"message": message,
|
|
65
|
+
}
|
|
66
|
+
useful = {key: value for key, value in fields.items() if key != "namespace" and value is not None}
|
|
67
|
+
if not useful:
|
|
68
|
+
raise LifecycleError("invalid-proof-finding", "finding has no stable identity fields")
|
|
69
|
+
return fields
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def build_finding_identity(finding: dict[str, Any], *, namespace: str = "default") -> dict[str, Any]:
|
|
73
|
+
"""Build a schema-backed stable finding identity."""
|
|
74
|
+
|
|
75
|
+
identity_fields = finding_identity_fields(finding, namespace=namespace)
|
|
76
|
+
finding_digest = canonical_digest(identity_fields)
|
|
77
|
+
body = {
|
|
78
|
+
"schemaVersion": FINDING_SCHEMA,
|
|
79
|
+
"findingId": f"finding-{finding_digest}",
|
|
80
|
+
"findingDigest": finding_digest,
|
|
81
|
+
"identityFields": identity_fields,
|
|
82
|
+
"sourceFindingDigest": canonical_digest(finding),
|
|
83
|
+
"severity": _optional_text(finding.get("severity")),
|
|
84
|
+
"status": _optional_text(finding.get("status")),
|
|
85
|
+
}
|
|
86
|
+
return body
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def validate_finding_identity(identity: dict[str, Any]) -> dict[str, Any]:
|
|
90
|
+
blockers: list[dict[str, Any]] = []
|
|
91
|
+
if not isinstance(identity, dict):
|
|
92
|
+
raise LifecycleError("invalid-proof-finding", "finding identity must be an object")
|
|
93
|
+
if identity.get("schemaVersion") != FINDING_SCHEMA:
|
|
94
|
+
blockers.append({"code": "proof-finding-schema-invalid"})
|
|
95
|
+
identity_fields = identity.get("identityFields")
|
|
96
|
+
if not isinstance(identity_fields, dict):
|
|
97
|
+
blockers.append({"code": "proof-finding-fields-invalid"})
|
|
98
|
+
identity_fields = {}
|
|
99
|
+
expected_digest = canonical_digest(identity_fields)
|
|
100
|
+
if identity.get("findingDigest") != expected_digest:
|
|
101
|
+
blockers.append({"code": "proof-finding-digest-mismatch"})
|
|
102
|
+
expected_id = f"finding-{expected_digest}"
|
|
103
|
+
if identity.get("findingId") != expected_id:
|
|
104
|
+
blockers.append({"code": "proof-finding-id-mismatch"})
|
|
105
|
+
_check_digest(identity.get("sourceFindingDigest"), "proof-finding-source-digest", blockers)
|
|
106
|
+
return _validation("agent-proof-finding-validation.v1", blockers, findingId=identity.get("findingId"))
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def build_root_cause_evidence(
|
|
110
|
+
*,
|
|
111
|
+
finding_id: str,
|
|
112
|
+
root_cause: dict[str, Any],
|
|
113
|
+
evidence_ids: list[str],
|
|
114
|
+
verifier: dict[str, Any],
|
|
115
|
+
status: str = "CONFIRMED",
|
|
116
|
+
) -> dict[str, Any]:
|
|
117
|
+
if not isinstance(root_cause, dict):
|
|
118
|
+
raise LifecycleError("invalid-root-cause-evidence", "rootCause must be an object")
|
|
119
|
+
_required_string(finding_id, label="findingId", code="invalid-root-cause-evidence")
|
|
120
|
+
_string_list(evidence_ids, label="evidenceIds", code="invalid-root-cause-evidence", allow_empty=False)
|
|
121
|
+
_verifier(verifier, code="invalid-root-cause-evidence")
|
|
122
|
+
body = {
|
|
123
|
+
"schemaVersion": ROOT_CAUSE_SCHEMA,
|
|
124
|
+
"status": status,
|
|
125
|
+
"findingId": finding_id,
|
|
126
|
+
"rootCause": root_cause,
|
|
127
|
+
"rootCauseDigest": canonical_digest(root_cause),
|
|
128
|
+
"evidenceIds": list(evidence_ids),
|
|
129
|
+
"verifier": dict(verifier),
|
|
130
|
+
"productionPromotionClaimed": False,
|
|
131
|
+
}
|
|
132
|
+
return {**body, "evidenceDigest": canonical_digest(body)}
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def validate_root_cause_evidence(evidence: dict[str, Any]) -> dict[str, Any]:
|
|
136
|
+
blockers: list[dict[str, Any]] = []
|
|
137
|
+
if not isinstance(evidence, dict):
|
|
138
|
+
raise LifecycleError("invalid-root-cause-evidence", "root cause evidence must be an object")
|
|
139
|
+
if evidence.get("schemaVersion") != ROOT_CAUSE_SCHEMA:
|
|
140
|
+
blockers.append({"code": "root-cause-schema-invalid"})
|
|
141
|
+
if evidence.get("status") not in ROOT_CAUSE_STATUSES:
|
|
142
|
+
blockers.append({"code": "root-cause-status-invalid", "status": evidence.get("status")})
|
|
143
|
+
if not isinstance(evidence.get("findingId"), str) or not evidence["findingId"]:
|
|
144
|
+
blockers.append({"code": "root-cause-finding-id-missing"})
|
|
145
|
+
root_cause = evidence.get("rootCause")
|
|
146
|
+
if not isinstance(root_cause, dict):
|
|
147
|
+
blockers.append({"code": "root-cause-payload-invalid"})
|
|
148
|
+
root_cause = {}
|
|
149
|
+
if evidence.get("rootCauseDigest") != canonical_digest(root_cause):
|
|
150
|
+
blockers.append({"code": "root-cause-digest-mismatch"})
|
|
151
|
+
_check_string_list(evidence.get("evidenceIds"), "root-cause-evidence-ids", blockers, allow_empty=False)
|
|
152
|
+
_check_verifier(evidence.get("verifier"), "root-cause-verifier", blockers)
|
|
153
|
+
if evidence.get("productionPromotionClaimed") is not False:
|
|
154
|
+
blockers.append({"code": "root-cause-production-claim"})
|
|
155
|
+
expected = canonical_digest(_without_digest(evidence, "evidenceDigest"))
|
|
156
|
+
if evidence.get("evidenceDigest") != expected:
|
|
157
|
+
blockers.append({"code": "root-cause-evidence-digest-mismatch"})
|
|
158
|
+
return _validation(
|
|
159
|
+
"agent-root-cause-evidence-validation.v1",
|
|
160
|
+
blockers,
|
|
161
|
+
rootCauseDigest=evidence.get("rootCauseDigest"),
|
|
162
|
+
)
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
def build_fix_impact_receipt(
|
|
166
|
+
*,
|
|
167
|
+
lineage: dict[str, Any],
|
|
168
|
+
changed_files: list[str],
|
|
169
|
+
related_finding_ids: list[str],
|
|
170
|
+
root_cause_digests: list[str],
|
|
171
|
+
behavior_changes: list[dict[str, Any]],
|
|
172
|
+
preserved_behaviors: list[dict[str, Any]],
|
|
173
|
+
validation_evidence_ids: list[str],
|
|
174
|
+
collateral_damage: dict[str, Any],
|
|
175
|
+
verifier: dict[str, Any],
|
|
176
|
+
status: str = "PASS",
|
|
177
|
+
) -> dict[str, Any]:
|
|
178
|
+
normalized_files = [normalize_repo_path(path, label="changedFiles") for path in changed_files]
|
|
179
|
+
_lineage(lineage)
|
|
180
|
+
_string_list(related_finding_ids, label="relatedFindingIds", code="invalid-fix-impact-receipt", allow_empty=False)
|
|
181
|
+
_digest_list(root_cause_digests, label="rootCauseDigests", code="invalid-fix-impact-receipt")
|
|
182
|
+
_object_list(behavior_changes, label="behaviorChanges", code="invalid-fix-impact-receipt", allow_empty=False)
|
|
183
|
+
_object_list(preserved_behaviors, label="preservedBehaviors", code="invalid-fix-impact-receipt", allow_empty=False)
|
|
184
|
+
_string_list(validation_evidence_ids, label="validationEvidenceIds", code="invalid-fix-impact-receipt", allow_empty=False)
|
|
185
|
+
if not isinstance(collateral_damage, dict):
|
|
186
|
+
raise LifecycleError("invalid-fix-impact-receipt", "collateralDamage must be an object")
|
|
187
|
+
_verifier(verifier, code="invalid-fix-impact-receipt")
|
|
188
|
+
body = {
|
|
189
|
+
"schemaVersion": FIX_IMPACT_SCHEMA,
|
|
190
|
+
"status": status,
|
|
191
|
+
"lineage": dict(lineage),
|
|
192
|
+
"changedFiles": normalized_files,
|
|
193
|
+
"relatedFindingIds": list(related_finding_ids),
|
|
194
|
+
"rootCauseDigests": list(root_cause_digests),
|
|
195
|
+
"behaviorChanges": list(behavior_changes),
|
|
196
|
+
"preservedBehaviors": list(preserved_behaviors),
|
|
197
|
+
"validationEvidenceIds": list(validation_evidence_ids),
|
|
198
|
+
"collateralDamage": dict(collateral_damage),
|
|
199
|
+
"verifier": dict(verifier),
|
|
200
|
+
"productionPromotionClaimed": False,
|
|
201
|
+
}
|
|
202
|
+
return {**body, "impactDigest": canonical_digest(body)}
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
def validate_fix_impact_receipt(receipt: dict[str, Any]) -> dict[str, Any]:
|
|
206
|
+
blockers: list[dict[str, Any]] = []
|
|
207
|
+
if not isinstance(receipt, dict):
|
|
208
|
+
raise LifecycleError("invalid-fix-impact-receipt", "fix impact receipt must be an object")
|
|
209
|
+
if receipt.get("schemaVersion") != FIX_IMPACT_SCHEMA:
|
|
210
|
+
blockers.append({"code": "fix-impact-schema-invalid"})
|
|
211
|
+
if receipt.get("status") not in FIX_IMPACT_STATUSES:
|
|
212
|
+
blockers.append({"code": "fix-impact-status-invalid", "status": receipt.get("status")})
|
|
213
|
+
_check_lineage(receipt.get("lineage"), blockers, code="fix-impact-lineage")
|
|
214
|
+
_check_changed_files(receipt.get("changedFiles"), blockers)
|
|
215
|
+
_check_string_list(receipt.get("relatedFindingIds"), "fix-impact-finding-ids", blockers, allow_empty=False)
|
|
216
|
+
_check_digest_list(receipt.get("rootCauseDigests"), "fix-impact-root-cause-digests", blockers)
|
|
217
|
+
_check_object_list(receipt.get("behaviorChanges"), "fix-impact-behavior-changes", blockers, allow_empty=False)
|
|
218
|
+
_check_object_list(receipt.get("preservedBehaviors"), "fix-impact-preserved-behaviors", blockers, allow_empty=False)
|
|
219
|
+
_check_string_list(receipt.get("validationEvidenceIds"), "fix-impact-validation-evidence", blockers, allow_empty=False)
|
|
220
|
+
collateral = receipt.get("collateralDamage")
|
|
221
|
+
if not isinstance(collateral, dict):
|
|
222
|
+
blockers.append({"code": "fix-impact-collateral-damage-invalid"})
|
|
223
|
+
elif collateral.get("status") not in {"PASS", "WAIVED"}:
|
|
224
|
+
blockers.append({"code": "fix-impact-collateral-damage-status", "status": collateral.get("status")})
|
|
225
|
+
_check_verifier(receipt.get("verifier"), "fix-impact-verifier", blockers)
|
|
226
|
+
if receipt.get("productionPromotionClaimed") is not False:
|
|
227
|
+
blockers.append({"code": "fix-impact-production-claim"})
|
|
228
|
+
expected = canonical_digest(_without_digest(receipt, "impactDigest"))
|
|
229
|
+
if receipt.get("impactDigest") != expected:
|
|
230
|
+
blockers.append({"code": "fix-impact-digest-mismatch"})
|
|
231
|
+
return _validation("agent-fix-impact-receipt-validation.v1", blockers, impactDigest=receipt.get("impactDigest"))
|
|
232
|
+
|
|
233
|
+
|
|
234
|
+
def build_receipt_hash_chain(
|
|
235
|
+
entries: list[dict[str, Any]],
|
|
236
|
+
*,
|
|
237
|
+
chain_id: str,
|
|
238
|
+
lineage: dict[str, Any] | None = None,
|
|
239
|
+
) -> dict[str, Any]:
|
|
240
|
+
_required_string(chain_id, label="chainId", code="invalid-receipt-hash-chain")
|
|
241
|
+
chain_entries: list[dict[str, Any]] = []
|
|
242
|
+
previous: str | None = None
|
|
243
|
+
for index, item in enumerate(entries, start=1):
|
|
244
|
+
if not isinstance(item, dict):
|
|
245
|
+
raise LifecycleError("invalid-receipt-hash-chain", "chain entries must be objects")
|
|
246
|
+
artifact = _chain_artifact(item)
|
|
247
|
+
entry_body = {
|
|
248
|
+
"sequence": index,
|
|
249
|
+
"artifact": artifact,
|
|
250
|
+
"previousEntryHash": previous,
|
|
251
|
+
"operationId": _optional_text(item.get("operationId")),
|
|
252
|
+
"evidenceIds": _optional_string_list(item.get("evidenceIds")),
|
|
253
|
+
}
|
|
254
|
+
entry = {**entry_body, "entryHash": canonical_digest(entry_body)}
|
|
255
|
+
chain_entries.append(entry)
|
|
256
|
+
previous = entry["entryHash"]
|
|
257
|
+
body = {
|
|
258
|
+
"schemaVersion": HASH_CHAIN_SCHEMA,
|
|
259
|
+
"status": "PASS",
|
|
260
|
+
"chainId": chain_id,
|
|
261
|
+
"appendOnly": True,
|
|
262
|
+
"lineage": dict(lineage or {}),
|
|
263
|
+
"entries": chain_entries,
|
|
264
|
+
"blockers": [],
|
|
265
|
+
"productionPromotionClaimed": False,
|
|
266
|
+
}
|
|
267
|
+
if not chain_entries:
|
|
268
|
+
body["status"] = "FAIL"
|
|
269
|
+
body["blockers"].append({"code": "receipt-hash-chain-empty"})
|
|
270
|
+
return {**body, "chainDigest": canonical_digest(body)}
|
|
271
|
+
|
|
272
|
+
|
|
273
|
+
def validate_receipt_hash_chain(chain: dict[str, Any]) -> dict[str, Any]:
|
|
274
|
+
blockers: list[dict[str, Any]] = []
|
|
275
|
+
if not isinstance(chain, dict):
|
|
276
|
+
raise LifecycleError("invalid-receipt-hash-chain", "receipt hash chain must be an object")
|
|
277
|
+
if chain.get("schemaVersion") != HASH_CHAIN_SCHEMA:
|
|
278
|
+
blockers.append({"code": "receipt-hash-chain-schema-invalid"})
|
|
279
|
+
if chain.get("status") != "PASS":
|
|
280
|
+
blockers.append({"code": "receipt-hash-chain-status-not-pass", "status": chain.get("status")})
|
|
281
|
+
if not isinstance(chain.get("chainId"), str) or not chain["chainId"]:
|
|
282
|
+
blockers.append({"code": "receipt-hash-chain-id-missing"})
|
|
283
|
+
if chain.get("appendOnly") is not True:
|
|
284
|
+
blockers.append({"code": "receipt-hash-chain-not-append-only"})
|
|
285
|
+
entries = chain.get("entries")
|
|
286
|
+
if not isinstance(entries, list) or not entries:
|
|
287
|
+
blockers.append({"code": "receipt-hash-chain-entries-missing"})
|
|
288
|
+
entries = []
|
|
289
|
+
previous: str | None = None
|
|
290
|
+
for expected_sequence, entry in enumerate(entries, start=1):
|
|
291
|
+
if not isinstance(entry, dict):
|
|
292
|
+
blockers.append({"code": "receipt-hash-chain-entry-invalid", "sequence": expected_sequence})
|
|
293
|
+
continue
|
|
294
|
+
_validate_chain_entry(entry, expected_sequence, previous, blockers)
|
|
295
|
+
previous = entry.get("entryHash") if _is_digest(entry.get("entryHash")) else previous
|
|
296
|
+
if chain.get("productionPromotionClaimed") is not False:
|
|
297
|
+
blockers.append({"code": "receipt-hash-chain-production-claim"})
|
|
298
|
+
if chain.get("blockers"):
|
|
299
|
+
blockers.append({"code": "receipt-hash-chain-has-blockers", "blockers": chain.get("blockers")})
|
|
300
|
+
expected = canonical_digest(_without_digest(chain, "chainDigest"))
|
|
301
|
+
if chain.get("chainDigest") != expected:
|
|
302
|
+
blockers.append({"code": "receipt-hash-chain-digest-mismatch"})
|
|
303
|
+
return _validation(
|
|
304
|
+
"agent-receipt-hash-chain-validation.v1",
|
|
305
|
+
blockers,
|
|
306
|
+
chainDigest=chain.get("chainDigest"),
|
|
307
|
+
chainStatus=chain.get("status"),
|
|
308
|
+
entryCount=len(entries),
|
|
309
|
+
)
|
|
310
|
+
|
|
311
|
+
|
|
312
|
+
def build_hash_chain_migration_policy(
|
|
313
|
+
*,
|
|
314
|
+
allowed_legacy_exemptions: list[str] | None = None,
|
|
315
|
+
backfill_behavior: str = "backfill-hash-chain-when-artifacts-are-available",
|
|
316
|
+
) -> dict[str, Any]:
|
|
317
|
+
exemptions = list(allowed_legacy_exemptions or DEFAULT_LEGACY_EXEMPTIONS)
|
|
318
|
+
body = {
|
|
319
|
+
"schemaVersion": HASH_CHAIN_MIGRATION_POLICY_SCHEMA,
|
|
320
|
+
"status": "PASS",
|
|
321
|
+
"mode": "required-for-new-runs",
|
|
322
|
+
"newRunsRequireChain": True,
|
|
323
|
+
"legacyReceiptPolicy": "explicit-exemption-or-backfill",
|
|
324
|
+
"allowedLegacyExemptions": exemptions,
|
|
325
|
+
"backfillBehavior": backfill_behavior,
|
|
326
|
+
"productionPromotionClaimed": False,
|
|
327
|
+
}
|
|
328
|
+
return {**body, "policyDigest": canonical_digest(body)}
|
|
329
|
+
|
|
330
|
+
|
|
331
|
+
def validate_hash_chain_migration_policy(
|
|
332
|
+
policy: dict[str, Any],
|
|
333
|
+
*,
|
|
334
|
+
new_run: bool,
|
|
335
|
+
hash_chain: dict[str, Any] | None,
|
|
336
|
+
legacy_exemption: dict[str, Any] | None = None,
|
|
337
|
+
) -> dict[str, Any]:
|
|
338
|
+
blockers: list[dict[str, Any]] = []
|
|
339
|
+
if not isinstance(policy, dict):
|
|
340
|
+
raise LifecycleError("invalid-hash-chain-migration-policy", "hash chain migration policy must be an object")
|
|
341
|
+
if policy.get("schemaVersion") != HASH_CHAIN_MIGRATION_POLICY_SCHEMA:
|
|
342
|
+
blockers.append({"code": "hash-chain-policy-schema-invalid"})
|
|
343
|
+
if policy.get("status") != "PASS":
|
|
344
|
+
blockers.append({"code": "hash-chain-policy-status-not-pass", "status": policy.get("status")})
|
|
345
|
+
if policy.get("mode") != "required-for-new-runs":
|
|
346
|
+
blockers.append({"code": "hash-chain-policy-mode-invalid"})
|
|
347
|
+
if policy.get("newRunsRequireChain") is not True:
|
|
348
|
+
blockers.append({"code": "hash-chain-policy-new-runs-not-required"})
|
|
349
|
+
if policy.get("legacyReceiptPolicy") != "explicit-exemption-or-backfill":
|
|
350
|
+
blockers.append({"code": "hash-chain-policy-legacy-invalid"})
|
|
351
|
+
exemptions = policy.get("allowedLegacyExemptions")
|
|
352
|
+
if not isinstance(exemptions, list) or not exemptions or not all(isinstance(item, str) and item for item in exemptions):
|
|
353
|
+
blockers.append({"code": "hash-chain-policy-exemptions-invalid"})
|
|
354
|
+
exemptions = []
|
|
355
|
+
if not isinstance(policy.get("backfillBehavior"), str) or not policy["backfillBehavior"]:
|
|
356
|
+
blockers.append({"code": "hash-chain-policy-backfill-missing"})
|
|
357
|
+
if policy.get("productionPromotionClaimed") is not False:
|
|
358
|
+
blockers.append({"code": "hash-chain-policy-production-claim"})
|
|
359
|
+
expected = canonical_digest(_without_digest(policy, "policyDigest"))
|
|
360
|
+
if policy.get("policyDigest") != expected:
|
|
361
|
+
blockers.append({"code": "hash-chain-policy-digest-mismatch"})
|
|
362
|
+
if new_run and hash_chain is None:
|
|
363
|
+
blockers.append({"code": "hash-chain-required-for-new-run"})
|
|
364
|
+
if not new_run and hash_chain is None:
|
|
365
|
+
_validate_legacy_exemption(legacy_exemption, exemptions, blockers)
|
|
366
|
+
body = {
|
|
367
|
+
"schemaVersion": HASH_CHAIN_MIGRATION_VALIDATION_SCHEMA,
|
|
368
|
+
"status": "PASS" if not blockers else "FAIL",
|
|
369
|
+
"newRun": new_run,
|
|
370
|
+
"blockers": blockers,
|
|
371
|
+
"policyDigest": policy.get("policyDigest"),
|
|
372
|
+
}
|
|
373
|
+
return {**body, "validationDigest": canonical_digest(body)}
|
|
374
|
+
|
|
375
|
+
|
|
376
|
+
def build_proof_integrity_receipt(
|
|
377
|
+
*,
|
|
378
|
+
lineage: dict[str, Any],
|
|
379
|
+
findings: list[dict[str, Any]],
|
|
380
|
+
root_causes: list[dict[str, Any]],
|
|
381
|
+
fix_impact_receipts: list[dict[str, Any]],
|
|
382
|
+
hash_chain: dict[str, Any],
|
|
383
|
+
migration_policy: dict[str, Any] | None = None,
|
|
384
|
+
required_evidence_ids: list[str] | None = None,
|
|
385
|
+
verifier: dict[str, Any],
|
|
386
|
+
) -> dict[str, Any]:
|
|
387
|
+
_lineage(lineage)
|
|
388
|
+
_verifier(verifier, code="invalid-proof-integrity-receipt")
|
|
389
|
+
body = {
|
|
390
|
+
"schemaVersion": PROOF_INTEGRITY_RECEIPT_SCHEMA,
|
|
391
|
+
"status": "PASS",
|
|
392
|
+
"lineage": dict(lineage),
|
|
393
|
+
"requiredEvidenceIds": list(required_evidence_ids or []),
|
|
394
|
+
"findings": list(findings),
|
|
395
|
+
"rootCauses": list(root_causes),
|
|
396
|
+
"fixImpactReceipts": list(fix_impact_receipts),
|
|
397
|
+
"hashChain": dict(hash_chain),
|
|
398
|
+
"migrationPolicy": dict(migration_policy or build_hash_chain_migration_policy()),
|
|
399
|
+
"blockers": [],
|
|
400
|
+
"verifier": dict(verifier),
|
|
401
|
+
"productionPromotionClaimed": False,
|
|
402
|
+
}
|
|
403
|
+
return {**body, "receiptDigest": canonical_digest(body)}
|
|
404
|
+
|
|
405
|
+
|
|
406
|
+
def validate_proof_integrity_receipt(
|
|
407
|
+
receipt: dict[str, Any],
|
|
408
|
+
*,
|
|
409
|
+
state: dict[str, Any] | None = None,
|
|
410
|
+
final_audit: dict[str, Any] | None = None,
|
|
411
|
+
final_proof: dict[str, Any] | None = None,
|
|
412
|
+
require_hash_chain: bool = True,
|
|
413
|
+
new_run: bool = True,
|
|
414
|
+
) -> dict[str, Any]:
|
|
415
|
+
blockers: list[dict[str, Any]] = []
|
|
416
|
+
if not isinstance(receipt, dict):
|
|
417
|
+
raise LifecycleError("invalid-proof-integrity-receipt", "proof integrity receipt must be an object")
|
|
418
|
+
if receipt.get("schemaVersion") != PROOF_INTEGRITY_RECEIPT_SCHEMA:
|
|
419
|
+
blockers.append({"code": "proof-integrity-schema-invalid"})
|
|
420
|
+
if receipt.get("status") != "PASS":
|
|
421
|
+
blockers.append({"code": "proof-integrity-status-not-pass", "status": receipt.get("status")})
|
|
422
|
+
if receipt.get("productionPromotionClaimed") is not False:
|
|
423
|
+
blockers.append({"code": "proof-integrity-production-claim"})
|
|
424
|
+
_check_lineage(receipt.get("lineage"), blockers, code="proof-integrity-lineage")
|
|
425
|
+
_compare_lineage(receipt.get("lineage"), state, final_audit, final_proof, blockers)
|
|
426
|
+
findings = _object_items(receipt.get("findings"), "proof-integrity-findings", blockers)
|
|
427
|
+
root_causes = _object_items(receipt.get("rootCauses"), "proof-integrity-root-causes", blockers)
|
|
428
|
+
fix_impacts = _object_items(receipt.get("fixImpactReceipts"), "proof-integrity-fix-impacts", blockers)
|
|
429
|
+
finding_ids = _validate_findings(findings, blockers)
|
|
430
|
+
root_cause_digests = _validate_root_causes(root_causes, finding_ids, blockers)
|
|
431
|
+
_validate_fix_impacts(fix_impacts, finding_ids, root_cause_digests, blockers)
|
|
432
|
+
_validate_required_sets(receipt, final_audit, finding_ids, root_cause_digests, fix_impacts, blockers)
|
|
433
|
+
hash_chain = receipt.get("hashChain")
|
|
434
|
+
chain_status: str | None = None
|
|
435
|
+
if require_hash_chain and not isinstance(hash_chain, dict):
|
|
436
|
+
blockers.append({"code": "proof-integrity-hash-chain-missing"})
|
|
437
|
+
hash_chain = None
|
|
438
|
+
if isinstance(hash_chain, dict):
|
|
439
|
+
chain_validation = validate_receipt_hash_chain(hash_chain)
|
|
440
|
+
chain_status = chain_validation["status"]
|
|
441
|
+
if chain_validation["status"] != "PASS":
|
|
442
|
+
blockers.append({"code": "proof-integrity-hash-chain-invalid", "validation": chain_validation})
|
|
443
|
+
policy = receipt.get("migrationPolicy")
|
|
444
|
+
if not isinstance(policy, dict):
|
|
445
|
+
blockers.append({"code": "proof-integrity-migration-policy-missing"})
|
|
446
|
+
else:
|
|
447
|
+
migration_validation = validate_hash_chain_migration_policy(
|
|
448
|
+
policy,
|
|
449
|
+
new_run=new_run,
|
|
450
|
+
hash_chain=hash_chain if isinstance(hash_chain, dict) and chain_status == "PASS" else None,
|
|
451
|
+
legacy_exemption=receipt.get("legacyHashChainExemption"),
|
|
452
|
+
)
|
|
453
|
+
if migration_validation["status"] != "PASS":
|
|
454
|
+
blockers.append({"code": "proof-integrity-migration-policy-invalid", "validation": migration_validation})
|
|
455
|
+
if receipt.get("blockers"):
|
|
456
|
+
blockers.append({"code": "proof-integrity-receipt-has-blockers", "blockers": receipt.get("blockers")})
|
|
457
|
+
_check_verifier(receipt.get("verifier"), "proof-integrity-verifier", blockers)
|
|
458
|
+
expected = canonical_digest(_without_digest(receipt, "receiptDigest"))
|
|
459
|
+
if receipt.get("receiptDigest") != expected:
|
|
460
|
+
blockers.append({"code": "proof-integrity-receipt-digest-mismatch"})
|
|
461
|
+
body = {
|
|
462
|
+
"schemaVersion": PROOF_INTEGRITY_VALIDATION_SCHEMA,
|
|
463
|
+
"status": "PASS" if not blockers else "FAIL",
|
|
464
|
+
"findingCount": len(findings),
|
|
465
|
+
"rootCauseCount": len(root_causes),
|
|
466
|
+
"fixImpactCount": len(fix_impacts),
|
|
467
|
+
"chainStatus": chain_status,
|
|
468
|
+
"blockers": blockers,
|
|
469
|
+
"receiptDigest": receipt.get("receiptDigest"),
|
|
470
|
+
}
|
|
471
|
+
return {**body, "validationDigest": canonical_digest(body)}
|
|
472
|
+
|
|
473
|
+
|
|
474
|
+
def require_proof_integrity_pass(validation: dict[str, Any]) -> dict[str, Any]:
|
|
475
|
+
if validation.get("status") != "PASS":
|
|
476
|
+
raise LifecycleError("proof-integrity-validation-failed", "proof integrity validation failed", {"validation": validation})
|
|
477
|
+
return validation
|
|
478
|
+
|
|
479
|
+
|
|
480
|
+
def _validate_findings(findings: list[dict[str, Any]], blockers: list[dict[str, Any]]) -> set[str]:
|
|
481
|
+
finding_ids: set[str] = set()
|
|
482
|
+
for index, finding in enumerate(findings):
|
|
483
|
+
validation = validate_finding_identity(finding)
|
|
484
|
+
if validation["status"] != "PASS":
|
|
485
|
+
blockers.append({"code": "proof-integrity-finding-invalid", "index": index, "validation": validation})
|
|
486
|
+
finding_id = finding.get("findingId")
|
|
487
|
+
if isinstance(finding_id, str) and finding_id:
|
|
488
|
+
if finding_id in finding_ids:
|
|
489
|
+
blockers.append({"code": "proof-integrity-finding-duplicate", "findingId": finding_id})
|
|
490
|
+
finding_ids.add(finding_id)
|
|
491
|
+
return finding_ids
|
|
492
|
+
|
|
493
|
+
|
|
494
|
+
def _validate_root_causes(
|
|
495
|
+
root_causes: list[dict[str, Any]],
|
|
496
|
+
finding_ids: set[str],
|
|
497
|
+
blockers: list[dict[str, Any]],
|
|
498
|
+
) -> set[str]:
|
|
499
|
+
root_cause_digests: set[str] = set()
|
|
500
|
+
root_causes_by_finding: set[str] = set()
|
|
501
|
+
for index, evidence in enumerate(root_causes):
|
|
502
|
+
validation = validate_root_cause_evidence(evidence)
|
|
503
|
+
if validation["status"] != "PASS":
|
|
504
|
+
blockers.append({"code": "proof-integrity-root-cause-invalid", "index": index, "validation": validation})
|
|
505
|
+
if evidence.get("status") != "CONFIRMED":
|
|
506
|
+
blockers.append({"code": "proof-integrity-root-cause-not-confirmed", "index": index, "status": evidence.get("status")})
|
|
507
|
+
finding_id = evidence.get("findingId")
|
|
508
|
+
if isinstance(finding_id, str) and finding_id:
|
|
509
|
+
root_causes_by_finding.add(finding_id)
|
|
510
|
+
if finding_ids and finding_id not in finding_ids:
|
|
511
|
+
blockers.append({"code": "proof-integrity-root-cause-unmatched-finding", "findingId": finding_id})
|
|
512
|
+
digest = evidence.get("rootCauseDigest")
|
|
513
|
+
if _is_digest(digest):
|
|
514
|
+
root_cause_digests.add(str(digest))
|
|
515
|
+
for finding_id in sorted(finding_ids.difference(root_causes_by_finding)):
|
|
516
|
+
blockers.append({"code": "proof-integrity-root-cause-missing", "findingId": finding_id})
|
|
517
|
+
return root_cause_digests
|
|
518
|
+
|
|
519
|
+
|
|
520
|
+
def _validate_fix_impacts(
|
|
521
|
+
fix_impacts: list[dict[str, Any]],
|
|
522
|
+
finding_ids: set[str],
|
|
523
|
+
root_cause_digests: set[str],
|
|
524
|
+
blockers: list[dict[str, Any]],
|
|
525
|
+
) -> None:
|
|
526
|
+
covered_findings: set[str] = set()
|
|
527
|
+
covered_root_causes: set[str] = set()
|
|
528
|
+
for index, receipt in enumerate(fix_impacts):
|
|
529
|
+
validation = validate_fix_impact_receipt(receipt)
|
|
530
|
+
if validation["status"] != "PASS":
|
|
531
|
+
blockers.append({"code": "proof-integrity-fix-impact-invalid", "index": index, "validation": validation})
|
|
532
|
+
if receipt.get("status") != "PASS":
|
|
533
|
+
blockers.append({"code": "proof-integrity-fix-impact-not-pass", "index": index, "status": receipt.get("status")})
|
|
534
|
+
for finding_id in receipt.get("relatedFindingIds") or []:
|
|
535
|
+
if isinstance(finding_id, str):
|
|
536
|
+
covered_findings.add(finding_id)
|
|
537
|
+
if finding_ids and finding_id not in finding_ids:
|
|
538
|
+
blockers.append({"code": "proof-integrity-fix-impact-unmatched-finding", "findingId": finding_id})
|
|
539
|
+
for digest in receipt.get("rootCauseDigests") or []:
|
|
540
|
+
if isinstance(digest, str):
|
|
541
|
+
covered_root_causes.add(digest)
|
|
542
|
+
if root_cause_digests and digest not in root_cause_digests:
|
|
543
|
+
blockers.append({"code": "proof-integrity-fix-impact-unmatched-root-cause", "rootCauseDigest": digest})
|
|
544
|
+
for finding_id in sorted(finding_ids.difference(covered_findings)):
|
|
545
|
+
blockers.append({"code": "proof-integrity-fix-impact-missing", "findingId": finding_id})
|
|
546
|
+
for digest in sorted(root_cause_digests.difference(covered_root_causes)):
|
|
547
|
+
blockers.append({"code": "proof-integrity-root-cause-fix-impact-missing", "rootCauseDigest": digest})
|
|
548
|
+
|
|
549
|
+
|
|
550
|
+
def _validate_required_sets(
|
|
551
|
+
receipt: dict[str, Any],
|
|
552
|
+
final_audit: dict[str, Any] | None,
|
|
553
|
+
finding_ids: set[str],
|
|
554
|
+
root_cause_digests: set[str],
|
|
555
|
+
fix_impacts: list[dict[str, Any]],
|
|
556
|
+
blockers: list[dict[str, Any]],
|
|
557
|
+
) -> None:
|
|
558
|
+
requirements = final_audit.get("proofIntegrityEvidence") if isinstance(final_audit, dict) else None
|
|
559
|
+
if not isinstance(requirements, dict):
|
|
560
|
+
return
|
|
561
|
+
required_findings = set(_optional_string_list(requirements.get("requiredFindingIds")))
|
|
562
|
+
required_root_causes = set(_optional_string_list(requirements.get("requiredRootCauseDigests")))
|
|
563
|
+
required_fix_impacts = set(_optional_string_list(requirements.get("requiredFixImpactDigests")))
|
|
564
|
+
fix_impact_digests = {str(item.get("impactDigest")) for item in fix_impacts if _is_digest(item.get("impactDigest"))}
|
|
565
|
+
for finding_id in sorted(required_findings.difference(finding_ids)):
|
|
566
|
+
blockers.append({"code": "proof-integrity-required-finding-missing", "findingId": finding_id})
|
|
567
|
+
for digest in sorted(required_root_causes.difference(root_cause_digests)):
|
|
568
|
+
blockers.append({"code": "proof-integrity-required-root-cause-missing", "rootCauseDigest": digest})
|
|
569
|
+
for digest in sorted(required_fix_impacts.difference(fix_impact_digests)):
|
|
570
|
+
blockers.append({"code": "proof-integrity-required-fix-impact-missing", "impactDigest": digest})
|
|
571
|
+
required_evidence_ids = set(_optional_string_list(requirements.get("requiredEvidenceIds")))
|
|
572
|
+
receipt_evidence_ids = set(_optional_string_list(receipt.get("requiredEvidenceIds")))
|
|
573
|
+
for evidence_id in sorted(required_evidence_ids.difference(receipt_evidence_ids)):
|
|
574
|
+
blockers.append({"code": "proof-integrity-required-evidence-id-missing", "evidenceId": evidence_id})
|
|
575
|
+
|
|
576
|
+
|
|
577
|
+
def _validate_chain_entry(
|
|
578
|
+
entry: dict[str, Any],
|
|
579
|
+
expected_sequence: int,
|
|
580
|
+
expected_previous: str | None,
|
|
581
|
+
blockers: list[dict[str, Any]],
|
|
582
|
+
) -> None:
|
|
583
|
+
if entry.get("sequence") != expected_sequence:
|
|
584
|
+
blockers.append({"code": "receipt-hash-chain-sequence-mismatch", "sequence": expected_sequence})
|
|
585
|
+
artifact = entry.get("artifact")
|
|
586
|
+
if not isinstance(artifact, dict):
|
|
587
|
+
blockers.append({"code": "receipt-hash-chain-artifact-invalid", "sequence": expected_sequence})
|
|
588
|
+
else:
|
|
589
|
+
try:
|
|
590
|
+
normalize_repo_path(str(artifact.get("path")), label="chain artifact path")
|
|
591
|
+
except LifecycleError as exc:
|
|
592
|
+
blockers.append({"code": "receipt-hash-chain-artifact-path-invalid", "sequence": expected_sequence, "reason": exc.code})
|
|
593
|
+
_check_digest(artifact.get("digest"), "receipt-hash-chain-artifact-digest-invalid", blockers)
|
|
594
|
+
if entry.get("previousEntryHash") != expected_previous:
|
|
595
|
+
blockers.append({"code": "receipt-hash-chain-previous-mismatch", "sequence": expected_sequence})
|
|
596
|
+
expected_hash = canonical_digest(_without_digest(entry, "entryHash"))
|
|
597
|
+
if entry.get("entryHash") != expected_hash:
|
|
598
|
+
blockers.append({"code": "receipt-hash-chain-entry-hash-mismatch", "sequence": expected_sequence})
|
|
599
|
+
|
|
600
|
+
|
|
601
|
+
def _chain_artifact(item: dict[str, Any]) -> dict[str, Any]:
|
|
602
|
+
raw_artifact = item.get("artifact") if isinstance(item.get("artifact"), dict) else item
|
|
603
|
+
path = normalize_repo_path(str(raw_artifact.get("path") or raw_artifact.get("sourcePath")), label="chain artifact path")
|
|
604
|
+
digest = raw_artifact.get("digest") or raw_artifact.get("artifactDigest") or raw_artifact.get("sha256")
|
|
605
|
+
if not _is_digest(digest):
|
|
606
|
+
raise LifecycleError("invalid-receipt-hash-chain", "chain artifact digest must be a 64-character hex digest")
|
|
607
|
+
artifact = {"path": path, "digest": str(digest)}
|
|
608
|
+
schema_version = raw_artifact.get("schemaVersion") or item.get("artifactType")
|
|
609
|
+
if isinstance(schema_version, str) and schema_version:
|
|
610
|
+
artifact["schemaVersion"] = schema_version
|
|
611
|
+
return artifact
|
|
612
|
+
|
|
613
|
+
|
|
614
|
+
def _compare_lineage(
|
|
615
|
+
lineage: Any,
|
|
616
|
+
state: dict[str, Any] | None,
|
|
617
|
+
final_audit: dict[str, Any] | None,
|
|
618
|
+
final_proof: dict[str, Any] | None,
|
|
619
|
+
blockers: list[dict[str, Any]],
|
|
620
|
+
) -> None:
|
|
621
|
+
if not isinstance(lineage, dict):
|
|
622
|
+
return
|
|
623
|
+
for label, artifact in (("state", state), ("final-audit", final_audit), ("final-proof", final_proof)):
|
|
624
|
+
if not isinstance(artifact, dict):
|
|
625
|
+
continue
|
|
626
|
+
for key in ("runId", "packageId", "planRevision", "planDigest", "sourceRevision"):
|
|
627
|
+
if key in artifact and lineage.get(key) != artifact.get(key):
|
|
628
|
+
blockers.append({"code": "proof-integrity-lineage-mismatch", "artifact": label, "field": key})
|
|
629
|
+
|
|
630
|
+
|
|
631
|
+
def _validate_legacy_exemption(
|
|
632
|
+
exemption: dict[str, Any] | None,
|
|
633
|
+
allowed_reasons: list[Any],
|
|
634
|
+
blockers: list[dict[str, Any]],
|
|
635
|
+
) -> None:
|
|
636
|
+
if not isinstance(exemption, dict):
|
|
637
|
+
blockers.append({"code": "hash-chain-legacy-exemption-required"})
|
|
638
|
+
return
|
|
639
|
+
reason = exemption.get("reason")
|
|
640
|
+
if reason not in allowed_reasons:
|
|
641
|
+
blockers.append({"code": "hash-chain-legacy-exemption-reason-invalid", "reason": reason})
|
|
642
|
+
if not isinstance(exemption.get("approvedBy"), str) or not exemption["approvedBy"]:
|
|
643
|
+
blockers.append({"code": "hash-chain-legacy-exemption-approval-missing"})
|
|
644
|
+
|
|
645
|
+
|
|
646
|
+
def _validation(schema: str, blockers: list[dict[str, Any]], **extra: Any) -> dict[str, Any]:
|
|
647
|
+
body = {
|
|
648
|
+
"schemaVersion": schema,
|
|
649
|
+
"status": "PASS" if not blockers else "FAIL",
|
|
650
|
+
"blockers": blockers,
|
|
651
|
+
**extra,
|
|
652
|
+
}
|
|
653
|
+
return {**body, "validationDigest": canonical_digest(body)}
|
|
654
|
+
|
|
655
|
+
|
|
656
|
+
def _lineage(lineage: dict[str, Any]) -> None:
|
|
657
|
+
if not isinstance(lineage, dict):
|
|
658
|
+
raise LifecycleError("invalid-lineage", "lineage must be an object")
|
|
659
|
+
missing = [key for key in ("runId", "packageId", "planRevision", "planDigest", "sourceRevision") if key not in lineage]
|
|
660
|
+
if missing:
|
|
661
|
+
raise LifecycleError("invalid-lineage", "lineage is missing required fields", {"fields": missing})
|
|
662
|
+
if not _is_digest(lineage.get("planDigest")):
|
|
663
|
+
raise LifecycleError("invalid-lineage", "lineage.planDigest must be a 64-character digest")
|
|
664
|
+
|
|
665
|
+
|
|
666
|
+
def _check_lineage(lineage: Any, blockers: list[dict[str, Any]], *, code: str) -> None:
|
|
667
|
+
if not isinstance(lineage, dict):
|
|
668
|
+
blockers.append({"code": f"{code}-invalid"})
|
|
669
|
+
return
|
|
670
|
+
for key in ("runId", "packageId", "sourceRevision"):
|
|
671
|
+
if not isinstance(lineage.get(key), str) or not lineage[key]:
|
|
672
|
+
blockers.append({"code": f"{code}-{key}-missing"})
|
|
673
|
+
if not isinstance(lineage.get("planRevision"), int) or lineage["planRevision"] < 1:
|
|
674
|
+
blockers.append({"code": f"{code}-plan-revision-invalid"})
|
|
675
|
+
_check_digest(lineage.get("planDigest"), f"{code}-plan-digest-invalid", blockers)
|
|
676
|
+
|
|
677
|
+
|
|
678
|
+
def _object_items(value: Any, code: str, blockers: list[dict[str, Any]]) -> list[dict[str, Any]]:
|
|
679
|
+
if not isinstance(value, list):
|
|
680
|
+
blockers.append({"code": f"{code}-invalid"})
|
|
681
|
+
return []
|
|
682
|
+
items: list[dict[str, Any]] = []
|
|
683
|
+
for index, item in enumerate(value):
|
|
684
|
+
if not isinstance(item, dict):
|
|
685
|
+
blockers.append({"code": f"{code}-item-invalid", "index": index})
|
|
686
|
+
else:
|
|
687
|
+
items.append(item)
|
|
688
|
+
return items
|
|
689
|
+
|
|
690
|
+
|
|
691
|
+
def _object_list(value: list[dict[str, Any]], *, label: str, code: str, allow_empty: bool) -> None:
|
|
692
|
+
if not isinstance(value, list) or (not allow_empty and not value):
|
|
693
|
+
raise LifecycleError(code, f"{label} must be a non-empty array")
|
|
694
|
+
if not all(isinstance(item, dict) for item in value):
|
|
695
|
+
raise LifecycleError(code, f"{label} entries must be objects")
|
|
696
|
+
|
|
697
|
+
|
|
698
|
+
def _check_object_list(value: Any, code: str, blockers: list[dict[str, Any]], *, allow_empty: bool) -> None:
|
|
699
|
+
if not isinstance(value, list) or (not allow_empty and not value):
|
|
700
|
+
blockers.append({"code": code})
|
|
701
|
+
return
|
|
702
|
+
if not all(isinstance(item, dict) for item in value):
|
|
703
|
+
blockers.append({"code": f"{code}-item-invalid"})
|
|
704
|
+
|
|
705
|
+
|
|
706
|
+
def _string_list(value: list[str], *, label: str, code: str, allow_empty: bool) -> None:
|
|
707
|
+
if not isinstance(value, list) or (not allow_empty and not value):
|
|
708
|
+
raise LifecycleError(code, f"{label} must be a non-empty array")
|
|
709
|
+
if not all(isinstance(item, str) and item for item in value):
|
|
710
|
+
raise LifecycleError(code, f"{label} entries must be non-empty strings")
|
|
711
|
+
|
|
712
|
+
|
|
713
|
+
def _check_string_list(value: Any, code: str, blockers: list[dict[str, Any]], *, allow_empty: bool) -> None:
|
|
714
|
+
if not isinstance(value, list) or (not allow_empty and not value):
|
|
715
|
+
blockers.append({"code": code})
|
|
716
|
+
return
|
|
717
|
+
if not all(isinstance(item, str) and item for item in value):
|
|
718
|
+
blockers.append({"code": f"{code}-item-invalid"})
|
|
719
|
+
|
|
720
|
+
|
|
721
|
+
def _optional_string_list(value: Any) -> list[str]:
|
|
722
|
+
if not isinstance(value, list):
|
|
723
|
+
return []
|
|
724
|
+
return [item for item in value if isinstance(item, str) and item]
|
|
725
|
+
|
|
726
|
+
|
|
727
|
+
def _digest_list(value: list[str], *, label: str, code: str) -> None:
|
|
728
|
+
if not isinstance(value, list) or not value:
|
|
729
|
+
raise LifecycleError(code, f"{label} must be a non-empty array")
|
|
730
|
+
if not all(_is_digest(item) for item in value):
|
|
731
|
+
raise LifecycleError(code, f"{label} entries must be 64-character digests")
|
|
732
|
+
|
|
733
|
+
|
|
734
|
+
def _check_digest_list(value: Any, code: str, blockers: list[dict[str, Any]]) -> None:
|
|
735
|
+
if not isinstance(value, list) or not value:
|
|
736
|
+
blockers.append({"code": code})
|
|
737
|
+
return
|
|
738
|
+
if not all(_is_digest(item) for item in value):
|
|
739
|
+
blockers.append({"code": f"{code}-item-invalid"})
|
|
740
|
+
|
|
741
|
+
|
|
742
|
+
def _check_changed_files(value: Any, blockers: list[dict[str, Any]]) -> None:
|
|
743
|
+
if not isinstance(value, list) or not value:
|
|
744
|
+
blockers.append({"code": "fix-impact-changed-files-missing"})
|
|
745
|
+
return
|
|
746
|
+
for index, path in enumerate(value):
|
|
747
|
+
try:
|
|
748
|
+
normalize_repo_path(str(path), label="changedFiles")
|
|
749
|
+
except LifecycleError as exc:
|
|
750
|
+
blockers.append({"code": "fix-impact-changed-file-invalid", "index": index, "reason": exc.code})
|
|
751
|
+
|
|
752
|
+
|
|
753
|
+
def _verifier(verifier: dict[str, Any], *, code: str) -> None:
|
|
754
|
+
if not isinstance(verifier, dict) or not isinstance(verifier.get("id"), str) or not verifier["id"]:
|
|
755
|
+
raise LifecycleError(code, "verifier.id is required")
|
|
756
|
+
|
|
757
|
+
|
|
758
|
+
def _check_verifier(value: Any, code: str, blockers: list[dict[str, Any]]) -> None:
|
|
759
|
+
if not isinstance(value, dict) or not isinstance(value.get("id"), str) or not value["id"]:
|
|
760
|
+
blockers.append({"code": code})
|
|
761
|
+
|
|
762
|
+
|
|
763
|
+
def _required_string(value: str, *, label: str, code: str) -> None:
|
|
764
|
+
if not isinstance(value, str) or not value:
|
|
765
|
+
raise LifecycleError(code, f"{label} is required")
|
|
766
|
+
|
|
767
|
+
|
|
768
|
+
def _optional_repo_path(value: Any, *, label: str) -> str | None:
|
|
769
|
+
if value is None:
|
|
770
|
+
return None
|
|
771
|
+
return normalize_repo_path(str(value), label=label)
|
|
772
|
+
|
|
773
|
+
|
|
774
|
+
def _optional_text(value: Any) -> str | None:
|
|
775
|
+
if value is None:
|
|
776
|
+
return None
|
|
777
|
+
text = _normalize_text(str(value))
|
|
778
|
+
return text or None
|
|
779
|
+
|
|
780
|
+
|
|
781
|
+
def _normalize_text(value: str) -> str:
|
|
782
|
+
return " ".join(value.strip().casefold().split())
|
|
783
|
+
|
|
784
|
+
|
|
785
|
+
def _is_digest(value: Any) -> bool:
|
|
786
|
+
return isinstance(value, str) and len(value) == 64 and all(char in "0123456789abcdef" for char in value)
|
|
787
|
+
|
|
788
|
+
|
|
789
|
+
def _check_digest(value: Any, code: str, blockers: list[dict[str, Any]]) -> None:
|
|
790
|
+
if not _is_digest(value):
|
|
791
|
+
blockers.append({"code": code})
|
|
792
|
+
|
|
793
|
+
|
|
794
|
+
def _without_digest(value: dict[str, Any], digest_key: str) -> dict[str, Any]:
|
|
795
|
+
return {key: item for key, item in value.items() if key != digest_key}
|
|
796
|
+
|
|
797
|
+
|
|
798
|
+
__all__ = [
|
|
799
|
+
"build_finding_identity",
|
|
800
|
+
"build_fix_impact_receipt",
|
|
801
|
+
"build_hash_chain_migration_policy",
|
|
802
|
+
"build_proof_integrity_receipt",
|
|
803
|
+
"build_receipt_hash_chain",
|
|
804
|
+
"build_root_cause_evidence",
|
|
805
|
+
"finding_identity_fields",
|
|
806
|
+
"require_proof_integrity_pass",
|
|
807
|
+
"stable_finding_id",
|
|
808
|
+
"validate_finding_identity",
|
|
809
|
+
"validate_fix_impact_receipt",
|
|
810
|
+
"validate_hash_chain_migration_policy",
|
|
811
|
+
"validate_proof_integrity_receipt",
|
|
812
|
+
"validate_receipt_hash_chain",
|
|
813
|
+
"validate_root_cause_evidence",
|
|
814
|
+
]
|