causal-continuity-engine 0.1.0__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.
- causal_continuity_engine/__init__.py +21 -0
- causal_continuity_engine/api.py +1264 -0
- causal_continuity_engine/capabilities.py +548 -0
- causal_continuity_engine/capsule.py +996 -0
- causal_continuity_engine/cli.py +1338 -0
- causal_continuity_engine/core.py +468 -0
- causal_continuity_engine/engine.py +4354 -0
- causal_continuity_engine/evidence.py +971 -0
- causal_continuity_engine/extraction.py +263 -0
- causal_continuity_engine/github.py +657 -0
- causal_continuity_engine/graph.py +809 -0
- causal_continuity_engine/invalidation.py +1040 -0
- causal_continuity_engine/lamport.py +306 -0
- causal_continuity_engine/learning.py +466 -0
- causal_continuity_engine/memory.py +610 -0
- causal_continuity_engine/ontology.py +108 -0
- causal_continuity_engine/partial.py +309 -0
- causal_continuity_engine/policy.py +940 -0
- causal_continuity_engine/proof.py +920 -0
- causal_continuity_engine/py.typed +1 -0
- causal_continuity_engine/redaction.py +94 -0
- causal_continuity_engine/resume.py +653 -0
- causal_continuity_engine/store.py +1174 -0
- causal_continuity_engine/verifiers.py +1006 -0
- causal_continuity_engine-0.1.0.data/data/share/causal-continuity-engine/audit/SPEC.md +515 -0
- causal_continuity_engine-0.1.0.data/data/share/causal-continuity-engine/audit/benchmarks/__init__.py +0 -0
- causal_continuity_engine-0.1.0.data/data/share/causal-continuity-engine/audit/benchmarks/continuitybench/__init__.py +0 -0
- causal_continuity_engine-0.1.0.data/data/share/causal-continuity-engine/audit/benchmarks/continuitybench/run.py +146 -0
- causal_continuity_engine-0.1.0.data/data/share/causal-continuity-engine/audit/benchmarks/continuitybench/scenarios.py +544 -0
- causal_continuity_engine-0.1.0.data/data/share/causal-continuity-engine/audit/schemas/cce.anchor.v1.json +62 -0
- causal_continuity_engine-0.1.0.data/data/share/causal-continuity-engine/audit/schemas/cce.capsule.v1.json +112 -0
- causal_continuity_engine-0.1.0.data/data/share/causal-continuity-engine/audit/schemas/cce.continuity-receipt.v1.json +315 -0
- causal_continuity_engine-0.1.0.data/data/share/causal-continuity-engine/audit/schemas/cce.event.v1.json +73 -0
- causal_continuity_engine-0.1.0.data/data/share/causal-continuity-engine/audit/schemas/cce.proof-predicate.v1.json +35 -0
- causal_continuity_engine-0.1.0.data/data/share/causal-continuity-engine/audit/schemas/cce.proof.v1.json +242 -0
- causal_continuity_engine-0.1.0.data/data/share/causal-continuity-engine/audit/schemas/cce.recovery.v1.json +109 -0
- causal_continuity_engine-0.1.0.data/data/share/causal-continuity-engine/audit/schemas/cce.resume.v1.json +332 -0
- causal_continuity_engine-0.1.0.data/data/share/causal-continuity-engine/audit/tests/__init__.py +1 -0
- causal_continuity_engine-0.1.0.data/data/share/causal-continuity-engine/audit/tests/schema_validation.py +61 -0
- causal_continuity_engine-0.1.0.data/data/share/causal-continuity-engine/audit/tests/test_api_contract.py +1051 -0
- causal_continuity_engine-0.1.0.data/data/share/causal-continuity-engine/audit/tests/test_cli.py +664 -0
- causal_continuity_engine-0.1.0.data/data/share/causal-continuity-engine/audit/tests/test_conformance.py +758 -0
- causal_continuity_engine-0.1.0.data/data/share/causal-continuity-engine/audit/tests/test_distribution_environment.py +26 -0
- causal_continuity_engine-0.1.0.data/data/share/causal-continuity-engine/audit/tests/test_engine_e2e.py +286 -0
- causal_continuity_engine-0.1.0.data/data/share/causal-continuity-engine/audit/tests/test_github_redaction.py +193 -0
- causal_continuity_engine-0.1.0.data/data/share/causal-continuity-engine/audit/tests/test_instrument_validation.py +535 -0
- causal_continuity_engine-0.1.0.data/data/share/causal-continuity-engine/audit/tests/test_invalidation_resume.py +205 -0
- causal_continuity_engine-0.1.0.data/data/share/causal-continuity-engine/audit/tests/test_learning_partial_api.py +380 -0
- causal_continuity_engine-0.1.0.data/data/share/causal-continuity-engine/audit/tests/test_memory_extraction.py +151 -0
- causal_continuity_engine-0.1.0.data/data/share/causal-continuity-engine/audit/tests/test_regressions.py +406 -0
- causal_continuity_engine-0.1.0.data/data/share/causal-continuity-engine/audit/tests/test_regressions_round10_release.py +842 -0
- causal_continuity_engine-0.1.0.data/data/share/causal-continuity-engine/audit/tests/test_regressions_round11_deep_integrity.py +831 -0
- causal_continuity_engine-0.1.0.data/data/share/causal-continuity-engine/audit/tests/test_regressions_round12_release_blockers.py +206 -0
- causal_continuity_engine-0.1.0.data/data/share/causal-continuity-engine/audit/tests/test_regressions_round13_jcs.py +212 -0
- causal_continuity_engine-0.1.0.data/data/share/causal-continuity-engine/audit/tests/test_regressions_round14_state_integrity.py +1056 -0
- causal_continuity_engine-0.1.0.data/data/share/causal-continuity-engine/audit/tests/test_regressions_round15_capsule_invalidation_github.py +251 -0
- causal_continuity_engine-0.1.0.data/data/share/causal-continuity-engine/audit/tests/test_regressions_round15_public_boundaries.py +561 -0
- causal_continuity_engine-0.1.0.data/data/share/causal-continuity-engine/audit/tests/test_regressions_round15_storage_boundaries.py +707 -0
- causal_continuity_engine-0.1.0.data/data/share/causal-continuity-engine/audit/tests/test_regressions_round16_cli_api_core_capabilities.py +285 -0
- causal_continuity_engine-0.1.0.data/data/share/causal-continuity-engine/audit/tests/test_regressions_round2.py +388 -0
- causal_continuity_engine-0.1.0.data/data/share/causal-continuity-engine/audit/tests/test_regressions_round3.py +490 -0
- causal_continuity_engine-0.1.0.data/data/share/causal-continuity-engine/audit/tests/test_regressions_round4.py +557 -0
- causal_continuity_engine-0.1.0.data/data/share/causal-continuity-engine/audit/tests/test_regressions_round5.py +583 -0
- causal_continuity_engine-0.1.0.data/data/share/causal-continuity-engine/audit/tests/test_regressions_round6.py +559 -0
- causal_continuity_engine-0.1.0.data/data/share/causal-continuity-engine/audit/tests/test_regressions_round7.py +579 -0
- causal_continuity_engine-0.1.0.data/data/share/causal-continuity-engine/audit/tests/test_regressions_round8_boundaries.py +558 -0
- causal_continuity_engine-0.1.0.data/data/share/causal-continuity-engine/audit/tests/test_regressions_round8_engine.py +843 -0
- causal_continuity_engine-0.1.0.data/data/share/causal-continuity-engine/audit/tests/test_regressions_round8_portability.py +2250 -0
- causal_continuity_engine-0.1.0.data/data/share/causal-continuity-engine/audit/tests/test_regressions_round8_state.py +705 -0
- causal_continuity_engine-0.1.0.data/data/share/causal-continuity-engine/audit/tests/test_regressions_round9_integrity.py +885 -0
- causal_continuity_engine-0.1.0.data/data/share/causal-continuity-engine/audit/tests/test_regressions_round9_scope_freshness.py +179 -0
- causal_continuity_engine-0.1.0.data/data/share/causal-continuity-engine/audit/tests/test_release_boundary_validation.py +676 -0
- causal_continuity_engine-0.1.0.data/data/share/causal-continuity-engine/audit/tests/test_release_controls.py +840 -0
- causal_continuity_engine-0.1.0.data/data/share/causal-continuity-engine/audit/tests/test_sqlite_lifecycle.py +52 -0
- causal_continuity_engine-0.1.0.data/data/share/causal-continuity-engine/audit/tests/test_store_graph.py +172 -0
- causal_continuity_engine-0.1.0.data/data/share/causal-continuity-engine/audit/tests/test_trust.py +398 -0
- causal_continuity_engine-0.1.0.data/data/share/causal-continuity-engine/audit/vectors/action_intent_wrong_type.json +170 -0
- causal_continuity_engine-0.1.0.data/data/share/causal-continuity-engine/audit/vectors/actor_wrong_type.json +170 -0
- causal_continuity_engine-0.1.0.data/data/share/causal-continuity-engine/audit/vectors/created_at_invalid_calendar_date.json +172 -0
- causal_continuity_engine-0.1.0.data/data/share/causal-continuity-engine/audit/vectors/created_at_noncanonical_offset.json +172 -0
- causal_continuity_engine-0.1.0.data/data/share/causal-continuity-engine/audit/vectors/empty_required_authoritative_failure.json +114 -0
- causal_continuity_engine-0.1.0.data/data/share/causal-continuity-engine/audit/vectors/empty_required_authoritative_pass.json +107 -0
- causal_continuity_engine-0.1.0.data/data/share/causal-continuity-engine/audit/vectors/empty_required_no_results.json +99 -0
- causal_continuity_engine-0.1.0.data/data/share/causal-continuity-engine/audit/vectors/empty_required_self_asserted_pass.json +105 -0
- causal_continuity_engine-0.1.0.data/data/share/causal-continuity-engine/audit/vectors/generate.py +498 -0
- causal_continuity_engine-0.1.0.data/data/share/causal-continuity-engine/audit/vectors/incomplete_failed_check.json +168 -0
- causal_continuity_engine-0.1.0.data/data/share/causal-continuity-engine/audit/vectors/index.json +240 -0
- causal_continuity_engine-0.1.0.data/data/share/causal-continuity-engine/audit/vectors/input_item_wrong_type.json +163 -0
- causal_continuity_engine-0.1.0.data/data/share/causal-continuity-engine/audit/vectors/invalid_result_value.json +170 -0
- causal_continuity_engine-0.1.0.data/data/share/causal-continuity-engine/audit/vectors/lamport_fingerprint_spoof.json +1458 -0
- causal_continuity_engine-0.1.0.data/data/share/causal-continuity-engine/audit/vectors/lamport_no_registry.json +1454 -0
- causal_continuity_engine-0.1.0.data/data/share/causal-continuity-engine/audit/vectors/lamport_truncated_key.json +1454 -0
- causal_continuity_engine-0.1.0.data/data/share/causal-continuity-engine/audit/vectors/missing_required_field.json +157 -0
- causal_continuity_engine-0.1.0.data/data/share/causal-continuity-engine/audit/vectors/non_binary64_integer.json +174 -0
- causal_continuity_engine-0.1.0.data/data/share/causal-continuity-engine/audit/vectors/policy_decision_wrong_type.json +146 -0
- causal_continuity_engine-0.1.0.data/data/share/causal-continuity-engine/audit/vectors/retry_cannot_launder_a_failure.json +197 -0
- causal_continuity_engine-0.1.0.data/data/share/causal-continuity-engine/audit/vectors/self_asserted_cannot_satisfy.json +173 -0
- causal_continuity_engine-0.1.0.data/data/share/causal-continuity-engine/audit/vectors/self_assertion_cannot_poison_authoritative_pass.json +194 -0
- causal_continuity_engine-0.1.0.data/data/share/causal-continuity-engine/audit/vectors/status_contradicts_contents.json +173 -0
- causal_continuity_engine-0.1.0.data/data/share/causal-continuity-engine/audit/vectors/summary_contradicts_contents.json +170 -0
- causal_continuity_engine-0.1.0.data/data/share/causal-continuity-engine/audit/vectors/tamper_statement.json +172 -0
- causal_continuity_engine-0.1.0.data/data/share/causal-continuity-engine/audit/vectors/tamper_statement_resealed.json +172 -0
- causal_continuity_engine-0.1.0.data/data/share/causal-continuity-engine/audit/vectors/task_id_under_unrelated_field.json +172 -0
- causal_continuity_engine-0.1.0.data/data/share/causal-continuity-engine/audit/vectors/unbound_task.json +165 -0
- causal_continuity_engine-0.1.0.data/data/share/causal-continuity-engine/audit/vectors/unknown_action_intent_field.json +173 -0
- causal_continuity_engine-0.1.0.data/data/share/causal-continuity-engine/audit/vectors/unknown_algorithm.json +172 -0
- causal_continuity_engine-0.1.0.data/data/share/causal-continuity-engine/audit/vectors/unknown_schema_version.json +170 -0
- causal_continuity_engine-0.1.0.data/data/share/causal-continuity-engine/audit/vectors/unknown_top_level_field.json +175 -0
- causal_continuity_engine-0.1.0.data/data/share/causal-continuity-engine/audit/vectors/unverified_forged_body_no_key.json +170 -0
- causal_continuity_engine-0.1.0.data/data/share/causal-continuity-engine/audit/vectors/unverified_hmac_no_key.json +170 -0
- causal_continuity_engine-0.1.0.data/data/share/causal-continuity-engine/audit/vectors/valid_hmac.json +172 -0
- causal_continuity_engine-0.1.0.data/data/share/causal-continuity-engine/audit/vectors/valid_hmac_jcs_edges.json +186 -0
- causal_continuity_engine-0.1.0.data/data/share/causal-continuity-engine/audit/vectors/valid_hmac_unicode.json +172 -0
- causal_continuity_engine-0.1.0.data/data/share/causal-continuity-engine/audit/vectors/valid_lamport.json +1458 -0
- causal_continuity_engine-0.1.0.data/data/share/causal-continuity-engine/audit/vectors/wrong_project.json +173 -0
- causal_continuity_engine-0.1.0.data/data/share/causal-continuity-engine/audit/verifiers/__init__.py +0 -0
- causal_continuity_engine-0.1.0.data/data/share/causal-continuity-engine/audit/verifiers/verify_proof.py +1109 -0
- causal_continuity_engine-0.1.0.dist-info/METADATA +720 -0
- causal_continuity_engine-0.1.0.dist-info/RECORD +124 -0
- causal_continuity_engine-0.1.0.dist-info/WHEEL +5 -0
- causal_continuity_engine-0.1.0.dist-info/entry_points.txt +2 -0
- causal_continuity_engine-0.1.0.dist-info/licenses/LICENSE.txt +202 -0
- causal_continuity_engine-0.1.0.dist-info/licenses/NOTICE +6 -0
- causal_continuity_engine-0.1.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,1264 @@
|
|
|
1
|
+
"""Authenticated, fail-closed HTTP API for the PLT-001 endpoint contracts.
|
|
2
|
+
|
|
3
|
+
The production caveat remains explicit: ``http.server`` provides only basic
|
|
4
|
+
HTTP handling and is not a hosted-service boundary. The local entry point
|
|
5
|
+
binds an IP loopback literal, requires a minimum-length bearer token, verifies
|
|
6
|
+
GitHub's HMAC over the untouched request bytes, bounds request size, socket I/O
|
|
7
|
+
timeout, and worker count, and never accepts executable verifier definitions.
|
|
8
|
+
It does not provide TLS, OS-user isolation, distributed rate limiting, or a
|
|
9
|
+
browser security boundary; a hosted service still needs a real gateway
|
|
10
|
+
(PLT-001).
|
|
11
|
+
|
|
12
|
+
``make_handler`` requires a syntactically valid, minimum-length API token even
|
|
13
|
+
when it is used as an embedding seam. ``serve`` additionally requires a
|
|
14
|
+
minimum-length webhook secret. Route metadata below is also the sole source for
|
|
15
|
+
the checked public API document.
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
from __future__ import annotations
|
|
19
|
+
|
|
20
|
+
import hmac
|
|
21
|
+
import json
|
|
22
|
+
import math
|
|
23
|
+
import re
|
|
24
|
+
import threading
|
|
25
|
+
from dataclasses import dataclass
|
|
26
|
+
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
|
27
|
+
from urllib.parse import parse_qs, urlsplit
|
|
28
|
+
|
|
29
|
+
from .capsule import CapsuleError
|
|
30
|
+
from .core import strict_json_loads, validate_public_identifier
|
|
31
|
+
from .engine import AttestationInputError, GitHubDeliveryError
|
|
32
|
+
from .github import (
|
|
33
|
+
WebhookError,
|
|
34
|
+
WebhookPayloadError,
|
|
35
|
+
validate_webhook_secret,
|
|
36
|
+
verify_signature,
|
|
37
|
+
)
|
|
38
|
+
from .invalidation import ResolutionInputError
|
|
39
|
+
from .store import PayloadMismatchError
|
|
40
|
+
|
|
41
|
+
_MAX_BODY_BYTES = 1024 * 1024
|
|
42
|
+
_MAX_CONFIGURED_BODY_BYTES = 25 * 1024 * 1024
|
|
43
|
+
_MAX_TOKEN_BUDGET = 100_000
|
|
44
|
+
_DEFAULT_REQUEST_TIMEOUT_SECONDS = 15.0
|
|
45
|
+
_MAX_REQUEST_TIMEOUT_SECONDS = 300.0
|
|
46
|
+
_DEFAULT_MAX_WORKERS = 8
|
|
47
|
+
_MAX_WORKERS = 64
|
|
48
|
+
_MIN_SECRET_BYTES = 32
|
|
49
|
+
_MAX_SECRET_BYTES = 4096
|
|
50
|
+
_BEARER_TOKEN = re.compile(r"[A-Za-z0-9\-._~+/]+=*\Z")
|
|
51
|
+
_GITHUB_EVENT_HEADER = re.compile(r"[a-z][a-z_]{0,63}\Z", re.ASCII)
|
|
52
|
+
_ASSUMPTION_STATUSES = frozenset({
|
|
53
|
+
"active", "supported", "uncertain", "resolved", "invalidated", "superseded",
|
|
54
|
+
})
|
|
55
|
+
_CONTINUITY_RELATIONS = frozenset({
|
|
56
|
+
"task_ids", "requirement_ids", "decision_ids", "assumption_ids",
|
|
57
|
+
"artifact_ids", "evidence_ids", "action_ids",
|
|
58
|
+
})
|
|
59
|
+
_VERIFICATION_RESULTS = frozenset({
|
|
60
|
+
"passed", "failed", "skipped", "missing", "inconclusive", "stale",
|
|
61
|
+
})
|
|
62
|
+
_MISSING = object()
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
class RequestValidationError(Exception):
|
|
66
|
+
"""A caller-controlled request failed an explicit API contract check."""
|
|
67
|
+
|
|
68
|
+
def __init__(self, field: str, message: str, *, code: str = "invalid_request"):
|
|
69
|
+
super().__init__(message)
|
|
70
|
+
self.field = field
|
|
71
|
+
self.code = code
|
|
72
|
+
self.message = message
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
class UnsupportedMediaTypeError(RequestValidationError):
|
|
76
|
+
def __init__(self):
|
|
77
|
+
super().__init__(
|
|
78
|
+
"Content-Type", "Content-Type must be application/json",
|
|
79
|
+
code="unsupported_media_type")
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
class APIResourceNotFound(Exception):
|
|
83
|
+
"""A deliberately scoped lookup found neither local nor disclosable state."""
|
|
84
|
+
|
|
85
|
+
def __init__(self, resource: str):
|
|
86
|
+
super().__init__(resource)
|
|
87
|
+
self.resource = resource
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
class APIForbidden(Exception):
|
|
91
|
+
"""A request failed an API-owned tenant, project, or identity boundary."""
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
class RequestTooLarge(Exception):
|
|
95
|
+
def __init__(self, limit: int):
|
|
96
|
+
super().__init__(f"request body exceeds {limit}-byte limit")
|
|
97
|
+
self.limit = limit
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
@dataclass(frozen=True)
|
|
101
|
+
class RouteSpec:
|
|
102
|
+
method: str
|
|
103
|
+
template: str
|
|
104
|
+
pattern: re.Pattern[str]
|
|
105
|
+
name: str
|
|
106
|
+
auth: str
|
|
107
|
+
request: str
|
|
108
|
+
response: str
|
|
109
|
+
success: int
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def _route(method: str, template: str, pattern: str, name: str, auth: str,
|
|
113
|
+
request: str, response: str, success: int) -> RouteSpec:
|
|
114
|
+
return RouteSpec(
|
|
115
|
+
method, template, re.compile(pattern), name, auth, request, response, success)
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
API_ROUTES = (
|
|
119
|
+
_route("GET", "/v1/projects/{project_id}/assumptions",
|
|
120
|
+
r"^/v1/projects/([^/]+)/assumptions$", "assumptions", "bearer",
|
|
121
|
+
"Optional status query: comma-separated documented assumption statuses.",
|
|
122
|
+
"Array of assumption summaries.", 200),
|
|
123
|
+
_route("GET", "/v1/projects/{project_id}/invalidations",
|
|
124
|
+
r"^/v1/projects/([^/]+)/invalidations$", "invalidations", "bearer",
|
|
125
|
+
"No body.", "Array of open invalidation summaries.", 200),
|
|
126
|
+
_route("GET", "/v1/evaluations", r"^/v1/evaluations$", "evaluations", "bearer",
|
|
127
|
+
"No body.", "Array of evaluation summaries for the bound project.", 200),
|
|
128
|
+
_route("GET", "/v1/health", r"^/v1/health$", "health", "public",
|
|
129
|
+
"No body.", "Object with status=ok. This is the only unauthenticated route.", 200),
|
|
130
|
+
_route("POST", "/v1/events:ingest", r"^/v1/events:ingest$", "events_ingest",
|
|
131
|
+
"webhook", "Raw GitHub JSON payload; HMAC is checked before JSON parsing.",
|
|
132
|
+
"Ingestion report, duplicate status, or ping health response.", 202),
|
|
133
|
+
_route("POST", "/v1/traces:ingest", r"^/v1/traces:ingest$", "traces_ingest",
|
|
134
|
+
"bearer", "Object: span_id; optional project_id, session_id, payload object.",
|
|
135
|
+
"Ingestion report or duplicate status.", 202),
|
|
136
|
+
_route("POST", "/v1/projects/{project_id}/resume-packets:compose",
|
|
137
|
+
r"^/v1/projects/([^/]+)/resume-packets:compose$", "compose", "bearer",
|
|
138
|
+
"Object: optional token_budget integer and target object.",
|
|
139
|
+
"cce.resume.v1 object.", 200),
|
|
140
|
+
_route("POST", "/v1/assumptions/{assumption_id}:resolve",
|
|
141
|
+
r"^/v1/assumptions/([^/]+):resolve$", "resolve", "bearer",
|
|
142
|
+
"Object selecting direct resolution or a typed invalidation that "
|
|
143
|
+
"targets/affects the path resource.",
|
|
144
|
+
"Resolved node or invalidation summary.", 200),
|
|
145
|
+
_route("POST", "/v1/actions:attest", r"^/v1/actions:attest$", "attest", "bearer",
|
|
146
|
+
"Object: intent_type plus optional statement, actor, action_type, "
|
|
147
|
+
"verifications, continuity.",
|
|
148
|
+
"cce.proof.v1 object.", 200),
|
|
149
|
+
_route("POST", "/v1/verifications:run", r"^/v1/verifications:run$",
|
|
150
|
+
"verifications_run", "bearer",
|
|
151
|
+
"Object: optional intent fields and continuity; executable definitions are forbidden.",
|
|
152
|
+
"cce.proof.v1 object.", 200),
|
|
153
|
+
_route("POST", "/v1/projects/{project_id}/continuity-receipts:verify",
|
|
154
|
+
r"^/v1/projects/([^/]+)/continuity-receipts:verify$",
|
|
155
|
+
"continuity_receipt_verify", "bearer", "Object containing receipt object.",
|
|
156
|
+
"CURRENT, AUTHENTIC_HISTORICAL, or INVALID verdict object.", 200),
|
|
157
|
+
_route("POST", "/v1/migrations:prepare", r"^/v1/migrations:prepare$",
|
|
158
|
+
"migrations_prepare", "bearer",
|
|
159
|
+
"Object with optional scoped session and non-empty source/target strings.",
|
|
160
|
+
"cce.capsule.v1 object.", 200),
|
|
161
|
+
_route("POST", "/v1/migrations:validate", r"^/v1/migrations:validate$",
|
|
162
|
+
"migrations_validate", "bearer",
|
|
163
|
+
"Object containing capsule plus optional non-empty target model/runtime.",
|
|
164
|
+
"Imported session, challenge, and validation object.", 200),
|
|
165
|
+
_route("POST", "/v1/replays", r"^/v1/replays$", "replays", "bearer",
|
|
166
|
+
"Object: from_event_id plus optional project_id and object-valued "
|
|
167
|
+
"captured_inputs, mocks, fork.",
|
|
168
|
+
"Replay node and fidelity object.", 201),
|
|
169
|
+
)
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
def render_api_document() -> str:
|
|
173
|
+
"""Render the complete public API contract from the live route registry."""
|
|
174
|
+
auth_labels = {
|
|
175
|
+
"public": "Public health check",
|
|
176
|
+
"bearer": "Bearer token",
|
|
177
|
+
"webhook": "GitHub HMAC",
|
|
178
|
+
}
|
|
179
|
+
lines = [
|
|
180
|
+
"# Local HTTP API",
|
|
181
|
+
"",
|
|
182
|
+
"> Generated from `causal_continuity_engine.api.API_ROUTES`. Do not edit by hand.",
|
|
183
|
+
"> Run `python .github/scripts/render_api_docs.py --write` after changing routes.",
|
|
184
|
+
"",
|
|
185
|
+
"CCE's built-in HTTP server is a loopback integration endpoint. It binds only",
|
|
186
|
+
"`127.0.0.1`; it does not provide TLS, OS-user isolation, a browser security",
|
|
187
|
+
"boundary, or distributed rate limiting. Put a deployment-grade authenticated",
|
|
188
|
+
"gateway in front of it before any non-loopback exposure.",
|
|
189
|
+
"",
|
|
190
|
+
"## Authentication and requests",
|
|
191
|
+
"",
|
|
192
|
+
"Every route except `GET /v1/health` requires authentication. Ordinary routes",
|
|
193
|
+
"use `Authorization: Bearer <api.token>`. `POST /v1/events:ingest` instead",
|
|
194
|
+
"checks `X-Hub-Signature-256` over the untouched request bytes before parsing",
|
|
195
|
+
"JSON, then requires `X-GitHub-Event` and `X-GitHub-Delivery`.",
|
|
196
|
+
"",
|
|
197
|
+
"Every POST requires `Content-Type: application/json` (parameters such as",
|
|
198
|
+
"`charset=utf-8` are allowed). JSON must be an object, have no duplicate keys or",
|
|
199
|
+
"non-finite numbers, and satisfy the route's typed field contract. The default",
|
|
200
|
+
f"body limit is {_MAX_BODY_BYTES} bytes; configured limits must be between 1 and",
|
|
201
|
+
f"{_MAX_CONFIGURED_BODY_BYTES} bytes. The socket I/O timeout must be finite,",
|
|
202
|
+
"positive,",
|
|
203
|
+
f"and at most {_MAX_REQUEST_TIMEOUT_SECONDS:g} seconds. Worker count is bounded",
|
|
204
|
+
f"between 1 and {_MAX_WORKERS}.",
|
|
205
|
+
"",
|
|
206
|
+
"Every public resource identifier is one URI path segment: 1–128 ASCII",
|
|
207
|
+
"unreserved characters, beginning with a letter or digit and continuing with",
|
|
208
|
+
"letters, digits, `.`, `_`, `~`, or `-`. Slash, percent escapes, whitespace,",
|
|
209
|
+
"controls, Unicode, dot segments, and longer values are rejected as",
|
|
210
|
+
"`invalid_identifier`; clients must not encode an otherwise-invalid identity.",
|
|
211
|
+
"",
|
|
212
|
+
"GitHub sends `ping` when a webhook is created. A valid signed repository ping",
|
|
213
|
+
"must carry `hook`, positive `hook_id`, `zen`, and the bound positive numeric",
|
|
214
|
+
"`repository.id`; it returns 200 without persisting an event. GitHub's documented",
|
|
215
|
+
"ping object does not declare `installation`, so absence does not fail setup. If",
|
|
216
|
+
"installation is present it is checked, and every operational event still must",
|
|
217
|
+
"match a configured installation id. See GitHub's [webhook payload reference]",
|
|
218
|
+
"[github-webhook-payloads].",
|
|
219
|
+
"",
|
|
220
|
+
"## Routes",
|
|
221
|
+
"",
|
|
222
|
+
"| Method | Path | Authentication | Request | Success response | Status |",
|
|
223
|
+
"|---|---|---|---|---|---:|",
|
|
224
|
+
]
|
|
225
|
+
for route in API_ROUTES:
|
|
226
|
+
lines.append(
|
|
227
|
+
f"| {route.method} | `{route.template}` | {auth_labels[route.auth]} | "
|
|
228
|
+
f"{route.request} | {route.response} | {route.success} |")
|
|
229
|
+
lines.extend([
|
|
230
|
+
"",
|
|
231
|
+
"## Errors and method handling",
|
|
232
|
+
"",
|
|
233
|
+
"Every error uses the same JSON shape:",
|
|
234
|
+
"",
|
|
235
|
+
"```json",
|
|
236
|
+
'{"error":{"code":"invalid_request","message":"...","field":"token_budget"}}',
|
|
237
|
+
"```",
|
|
238
|
+
"",
|
|
239
|
+
"`field` is omitted when no request field applies. Explicit validation, including",
|
|
240
|
+
"an `invalid_identifier`, is 400;",
|
|
241
|
+
"missing/invalid bearer credentials are 401 with `WWW-Authenticate: Bearer`;",
|
|
242
|
+
"project-scope denial is 403; an explicitly scoped missing resource is 404; a",
|
|
243
|
+
"known route with the wrong method is 405 with exact `Allow`; idempotency-key",
|
|
244
|
+
"payload conflict is 409; oversized input is 413; wrong media type is 415; and an",
|
|
245
|
+
"authenticated unsupported GitHub event is 422. Unexpected implementation errors",
|
|
246
|
+
"are generic 500 responses and disclose no exception detail.",
|
|
247
|
+
"",
|
|
248
|
+
"Unknown paths return JSON 404. GET, POST, HEAD, PUT, PATCH, DELETE, OPTIONS,",
|
|
249
|
+
"TRACE, and CONNECT all pass through the same dispatcher; the standard-library",
|
|
250
|
+
"HTML error pages and Python version banner are not part of this API.",
|
|
251
|
+
"",
|
|
252
|
+
"[github-webhook-payloads]: "
|
|
253
|
+
"https://docs.github.com/en/webhooks/webhook-events-and-payloads#ping",
|
|
254
|
+
])
|
|
255
|
+
return "\n".join(lines) + "\n"
|
|
256
|
+
|
|
257
|
+
|
|
258
|
+
def _error_body(code: str, message: str, field: str | None = None) -> dict:
|
|
259
|
+
error = {"code": code, "message": message}
|
|
260
|
+
if field is not None:
|
|
261
|
+
error["field"] = field
|
|
262
|
+
return {"error": error}
|
|
263
|
+
|
|
264
|
+
|
|
265
|
+
def _reject_unknown(body: dict, allowed) -> None:
|
|
266
|
+
unknown = sorted(set(body) - set(allowed))
|
|
267
|
+
if unknown:
|
|
268
|
+
raise RequestValidationError(
|
|
269
|
+
"body", f"unknown request field(s): {', '.join(unknown)}",
|
|
270
|
+
code="unknown_field")
|
|
271
|
+
|
|
272
|
+
|
|
273
|
+
def _required_string(body: dict, field: str, *, allow_empty: bool = False) -> str:
|
|
274
|
+
value = body.get(field, _MISSING)
|
|
275
|
+
if value is _MISSING:
|
|
276
|
+
raise RequestValidationError(field, f"{field} is required", code="missing_field")
|
|
277
|
+
if not isinstance(value, str) or (not allow_empty and not value.strip()):
|
|
278
|
+
qualifier = "a string" if allow_empty else "a non-empty string"
|
|
279
|
+
raise RequestValidationError(field, f"{field} must be {qualifier}")
|
|
280
|
+
return value
|
|
281
|
+
|
|
282
|
+
|
|
283
|
+
def _optional_string(
|
|
284
|
+
body: dict,
|
|
285
|
+
field: str,
|
|
286
|
+
default: str | None = None,
|
|
287
|
+
*,
|
|
288
|
+
allow_none: bool = False,
|
|
289
|
+
allow_empty: bool = False,
|
|
290
|
+
) -> str | None:
|
|
291
|
+
if field not in body:
|
|
292
|
+
return default
|
|
293
|
+
if body[field] is None and allow_none:
|
|
294
|
+
return None
|
|
295
|
+
return _required_string(body, field, allow_empty=allow_empty)
|
|
296
|
+
|
|
297
|
+
|
|
298
|
+
def _identifier(value, field: str) -> str:
|
|
299
|
+
try:
|
|
300
|
+
return validate_public_identifier(value, field=field)
|
|
301
|
+
except ValueError as exc:
|
|
302
|
+
raise RequestValidationError(
|
|
303
|
+
field, str(exc), code="invalid_identifier") from None
|
|
304
|
+
|
|
305
|
+
|
|
306
|
+
def _required_identifier(body: dict, field: str) -> str:
|
|
307
|
+
return _identifier(_required_string(body, field), field)
|
|
308
|
+
|
|
309
|
+
|
|
310
|
+
def _optional_identifier(
|
|
311
|
+
body: dict,
|
|
312
|
+
field: str,
|
|
313
|
+
default: str | None = None,
|
|
314
|
+
*,
|
|
315
|
+
allow_none: bool = False,
|
|
316
|
+
) -> str | None:
|
|
317
|
+
value = _optional_string(
|
|
318
|
+
body, field, default, allow_none=allow_none)
|
|
319
|
+
if value is None:
|
|
320
|
+
return None
|
|
321
|
+
return _identifier(value, field)
|
|
322
|
+
|
|
323
|
+
|
|
324
|
+
def _required_object(body: dict, field: str) -> dict:
|
|
325
|
+
value = body.get(field, _MISSING)
|
|
326
|
+
if value is _MISSING:
|
|
327
|
+
raise RequestValidationError(field, f"{field} is required", code="missing_field")
|
|
328
|
+
if not isinstance(value, dict):
|
|
329
|
+
raise RequestValidationError(field, f"{field} must be a JSON object")
|
|
330
|
+
return value
|
|
331
|
+
|
|
332
|
+
|
|
333
|
+
def _optional_object(
|
|
334
|
+
body: dict,
|
|
335
|
+
field: str,
|
|
336
|
+
default=None,
|
|
337
|
+
*,
|
|
338
|
+
allow_none: bool = False,
|
|
339
|
+
) -> dict | None:
|
|
340
|
+
if field not in body:
|
|
341
|
+
return default
|
|
342
|
+
if body[field] is None and allow_none:
|
|
343
|
+
return None
|
|
344
|
+
return _required_object(body, field)
|
|
345
|
+
|
|
346
|
+
|
|
347
|
+
def _required_list(body: dict, field: str) -> list:
|
|
348
|
+
value = body.get(field, _MISSING)
|
|
349
|
+
if value is _MISSING:
|
|
350
|
+
raise RequestValidationError(field, f"{field} is required", code="missing_field")
|
|
351
|
+
if not isinstance(value, list):
|
|
352
|
+
raise RequestValidationError(field, f"{field} must be a JSON array")
|
|
353
|
+
return value
|
|
354
|
+
|
|
355
|
+
|
|
356
|
+
def _optional_list(body: dict, field: str, default=None, *, allow_none: bool = False):
|
|
357
|
+
if field not in body:
|
|
358
|
+
return default
|
|
359
|
+
if body[field] is None and allow_none:
|
|
360
|
+
return None
|
|
361
|
+
return _required_list(body, field)
|
|
362
|
+
|
|
363
|
+
|
|
364
|
+
def _required_bool(body: dict, field: str) -> bool:
|
|
365
|
+
value = body.get(field, _MISSING)
|
|
366
|
+
if value is _MISSING:
|
|
367
|
+
raise RequestValidationError(field, f"{field} is required", code="missing_field")
|
|
368
|
+
if not isinstance(value, bool):
|
|
369
|
+
raise RequestValidationError(field, f"{field} must be a boolean")
|
|
370
|
+
return value
|
|
371
|
+
|
|
372
|
+
|
|
373
|
+
def _optional_bool(body: dict, field: str, default: bool | None = None) -> bool | None:
|
|
374
|
+
if field not in body:
|
|
375
|
+
return default
|
|
376
|
+
return _required_bool(body, field)
|
|
377
|
+
|
|
378
|
+
|
|
379
|
+
def _required_int(
|
|
380
|
+
body: dict,
|
|
381
|
+
field: str,
|
|
382
|
+
*,
|
|
383
|
+
minimum: int | None = None,
|
|
384
|
+
maximum: int | None = None,
|
|
385
|
+
) -> int:
|
|
386
|
+
value = body.get(field, _MISSING)
|
|
387
|
+
if value is _MISSING:
|
|
388
|
+
raise RequestValidationError(field, f"{field} is required", code="missing_field")
|
|
389
|
+
if isinstance(value, bool) or not isinstance(value, int):
|
|
390
|
+
raise RequestValidationError(field, f"{field} must be an integer")
|
|
391
|
+
if ((minimum is not None and value < minimum)
|
|
392
|
+
or (maximum is not None and value > maximum)):
|
|
393
|
+
if minimum is not None and maximum is not None:
|
|
394
|
+
message = f"{field} must be between {minimum} and {maximum}"
|
|
395
|
+
elif minimum is not None:
|
|
396
|
+
message = f"{field} must be at least {minimum}"
|
|
397
|
+
else:
|
|
398
|
+
message = f"{field} must be at most {maximum}"
|
|
399
|
+
raise RequestValidationError(field, message)
|
|
400
|
+
return value
|
|
401
|
+
|
|
402
|
+
|
|
403
|
+
def _optional_int(
|
|
404
|
+
body: dict,
|
|
405
|
+
field: str,
|
|
406
|
+
default: int | None = None,
|
|
407
|
+
*,
|
|
408
|
+
minimum: int | None = None,
|
|
409
|
+
maximum: int | None = None,
|
|
410
|
+
) -> int | None:
|
|
411
|
+
if field not in body:
|
|
412
|
+
return default
|
|
413
|
+
return _required_int(body, field, minimum=minimum, maximum=maximum)
|
|
414
|
+
|
|
415
|
+
|
|
416
|
+
def _validate_continuity(body: dict, field: str = "continuity") -> dict | None:
|
|
417
|
+
continuity = _optional_object(body, field)
|
|
418
|
+
if continuity is None:
|
|
419
|
+
return None
|
|
420
|
+
unknown = sorted(set(continuity) - _CONTINUITY_RELATIONS)
|
|
421
|
+
if unknown:
|
|
422
|
+
raise RequestValidationError(
|
|
423
|
+
field, f"{field} has unknown relation(s): {', '.join(unknown)}")
|
|
424
|
+
for relation, targets in continuity.items():
|
|
425
|
+
if (not isinstance(targets, list)
|
|
426
|
+
or any(not isinstance(target, str) or not target for target in targets)):
|
|
427
|
+
raise RequestValidationError(
|
|
428
|
+
f"{field}.{relation}",
|
|
429
|
+
f"{field}.{relation} must be an array of non-empty node ids")
|
|
430
|
+
for index, target in enumerate(targets):
|
|
431
|
+
_identifier(target, f"{field}.{relation}[{index}]")
|
|
432
|
+
if len(targets) != len(set(targets)):
|
|
433
|
+
raise RequestValidationError(
|
|
434
|
+
f"{field}.{relation}", f"{field}.{relation} must not contain duplicates")
|
|
435
|
+
return continuity
|
|
436
|
+
|
|
437
|
+
|
|
438
|
+
def _validate_verifications(body: dict) -> list[dict] | None:
|
|
439
|
+
values = _optional_list(body, "verifications")
|
|
440
|
+
if values is None:
|
|
441
|
+
return None
|
|
442
|
+
for index, value in enumerate(values):
|
|
443
|
+
field = f"verifications[{index}]"
|
|
444
|
+
if not isinstance(value, dict):
|
|
445
|
+
raise RequestValidationError(field, f"{field} must be a JSON object")
|
|
446
|
+
verifier = value.get("verifier")
|
|
447
|
+
if not isinstance(verifier, str) or not verifier.strip():
|
|
448
|
+
raise RequestValidationError(
|
|
449
|
+
f"{field}.verifier", f"{field}.verifier must be a non-empty string")
|
|
450
|
+
result = value.get("result")
|
|
451
|
+
if (not isinstance(result, str)
|
|
452
|
+
or result not in _VERIFICATION_RESULTS):
|
|
453
|
+
raise RequestValidationError(
|
|
454
|
+
f"{field}.result", f"{field}.result is not recognized")
|
|
455
|
+
return values
|
|
456
|
+
|
|
457
|
+
|
|
458
|
+
def _bounded_int(name: str, value, minimum: int, maximum: int) -> int:
|
|
459
|
+
if isinstance(value, bool) or not isinstance(value, int):
|
|
460
|
+
raise ValueError(f"{name} must be an integer")
|
|
461
|
+
if not minimum <= value <= maximum:
|
|
462
|
+
raise ValueError(f"{name} must be between {minimum} and {maximum}")
|
|
463
|
+
return value
|
|
464
|
+
|
|
465
|
+
|
|
466
|
+
def _validated_timeout(value) -> float:
|
|
467
|
+
if (isinstance(value, bool) or not isinstance(value, (int, float))
|
|
468
|
+
or not math.isfinite(value) or value <= 0
|
|
469
|
+
or value > _MAX_REQUEST_TIMEOUT_SECONDS):
|
|
470
|
+
raise ValueError(
|
|
471
|
+
"request_timeout_seconds must be finite and between 0 and "
|
|
472
|
+
f"{_MAX_REQUEST_TIMEOUT_SECONDS}")
|
|
473
|
+
return float(value)
|
|
474
|
+
|
|
475
|
+
|
|
476
|
+
def _validated_project_id(project_id) -> str:
|
|
477
|
+
return validate_public_identifier(project_id, field="project_id")
|
|
478
|
+
|
|
479
|
+
|
|
480
|
+
def _validated_api_token(api_token) -> str:
|
|
481
|
+
if not isinstance(api_token, str):
|
|
482
|
+
raise ValueError("api_token must be an ASCII bearer-token string")
|
|
483
|
+
try:
|
|
484
|
+
encoded = api_token.encode("ascii")
|
|
485
|
+
except UnicodeEncodeError:
|
|
486
|
+
raise ValueError("api_token must contain only ASCII characters") from None
|
|
487
|
+
if not _MIN_SECRET_BYTES <= len(encoded) <= _MAX_SECRET_BYTES:
|
|
488
|
+
raise ValueError(
|
|
489
|
+
f"api_token must be between {_MIN_SECRET_BYTES} and "
|
|
490
|
+
f"{_MAX_SECRET_BYTES} ASCII bytes")
|
|
491
|
+
if not _BEARER_TOKEN.fullmatch(api_token):
|
|
492
|
+
raise ValueError("api_token contains characters forbidden in a bearer token")
|
|
493
|
+
return api_token
|
|
494
|
+
|
|
495
|
+
|
|
496
|
+
def _validated_webhook_secret(webhook_secret, *, required: bool) -> bytes | None:
|
|
497
|
+
return validate_webhook_secret(webhook_secret, required=required)
|
|
498
|
+
|
|
499
|
+
|
|
500
|
+
class BoundedThreadingHTTPServer(ThreadingHTTPServer):
|
|
501
|
+
"""ThreadingHTTPServer with a fixed in-flight request ceiling."""
|
|
502
|
+
|
|
503
|
+
daemon_threads = True
|
|
504
|
+
request_queue_size = 16
|
|
505
|
+
|
|
506
|
+
def __init__(
|
|
507
|
+
self, server_address, handler, *, max_workers: int = _DEFAULT_MAX_WORKERS):
|
|
508
|
+
max_workers = _bounded_int("max_workers", max_workers, 1, _MAX_WORKERS)
|
|
509
|
+
self._request_slots = threading.BoundedSemaphore(max_workers)
|
|
510
|
+
super().__init__(server_address, handler)
|
|
511
|
+
|
|
512
|
+
def process_request(self, request, client_address):
|
|
513
|
+
self._request_slots.acquire()
|
|
514
|
+
try:
|
|
515
|
+
super().process_request(request, client_address)
|
|
516
|
+
except BaseException:
|
|
517
|
+
self._request_slots.release()
|
|
518
|
+
raise
|
|
519
|
+
|
|
520
|
+
def process_request_thread(self, request, client_address):
|
|
521
|
+
try:
|
|
522
|
+
super().process_request_thread(request, client_address)
|
|
523
|
+
finally:
|
|
524
|
+
self._request_slots.release()
|
|
525
|
+
|
|
526
|
+
|
|
527
|
+
def make_handler(
|
|
528
|
+
engine,
|
|
529
|
+
project_id: str,
|
|
530
|
+
*,
|
|
531
|
+
api_token: str,
|
|
532
|
+
webhook_secret: bytes | str | None = None,
|
|
533
|
+
max_body_bytes: int = _MAX_BODY_BYTES,
|
|
534
|
+
request_timeout_seconds: float = _DEFAULT_REQUEST_TIMEOUT_SECONDS,
|
|
535
|
+
):
|
|
536
|
+
project_id = _validated_project_id(project_id)
|
|
537
|
+
api_token = _validated_api_token(api_token)
|
|
538
|
+
webhook_secret = _validated_webhook_secret(webhook_secret, required=False)
|
|
539
|
+
max_body_bytes = _bounded_int(
|
|
540
|
+
"max_body_bytes", max_body_bytes, 1, _MAX_CONFIGURED_BODY_BYTES)
|
|
541
|
+
request_timeout_seconds = _validated_timeout(request_timeout_seconds)
|
|
542
|
+
|
|
543
|
+
class Handler(BaseHTTPRequestHandler):
|
|
544
|
+
server_version = "CCE"
|
|
545
|
+
sys_version = ""
|
|
546
|
+
|
|
547
|
+
def setup(self):
|
|
548
|
+
super().setup()
|
|
549
|
+
self.request.settimeout(request_timeout_seconds)
|
|
550
|
+
|
|
551
|
+
def log_message(self, *a):
|
|
552
|
+
pass
|
|
553
|
+
|
|
554
|
+
def version_string(self):
|
|
555
|
+
return self.server_version
|
|
556
|
+
|
|
557
|
+
def _send(self, code: int, obj, *, headers: dict | None = None):
|
|
558
|
+
body = json.dumps(
|
|
559
|
+
obj, allow_nan=False, ensure_ascii=False,
|
|
560
|
+
separators=(",", ":")).encode("utf-8")
|
|
561
|
+
self.send_response(code)
|
|
562
|
+
self.send_header("Content-Type", "application/json; charset=utf-8")
|
|
563
|
+
self.send_header("Content-Length", str(len(body)))
|
|
564
|
+
self.send_header("Cache-Control", "no-store")
|
|
565
|
+
self.send_header("X-Content-Type-Options", "nosniff")
|
|
566
|
+
for name, value in (headers or {}).items():
|
|
567
|
+
self.send_header(name, value)
|
|
568
|
+
self.end_headers()
|
|
569
|
+
if self.command != "HEAD":
|
|
570
|
+
self.wfile.write(body)
|
|
571
|
+
|
|
572
|
+
def _send_error(
|
|
573
|
+
self, status: int, code: str, message: str, *,
|
|
574
|
+
field: str | None = None, headers: dict | None = None):
|
|
575
|
+
self._send(
|
|
576
|
+
status, _error_body(code, message, field), headers=headers)
|
|
577
|
+
|
|
578
|
+
def send_error(self, code, message=None, explain=None):
|
|
579
|
+
# BaseHTTPRequestHandler otherwise emits an HTML page containing
|
|
580
|
+
# its Python version. Keep even parser/fallback failures in the
|
|
581
|
+
# same non-cacheable JSON contract.
|
|
582
|
+
status = int(code)
|
|
583
|
+
error_code = "unsupported_method" if status == 501 else "http_error"
|
|
584
|
+
public = "unsupported HTTP method" if status == 501 else "HTTP request rejected"
|
|
585
|
+
self._send_error(status, error_code, public)
|
|
586
|
+
|
|
587
|
+
def _single_header(
|
|
588
|
+
self, name: str, *, required: bool = False) -> str | None:
|
|
589
|
+
values = self.headers.get_all(name) or []
|
|
590
|
+
if len(values) > 1:
|
|
591
|
+
raise RequestValidationError(
|
|
592
|
+
name, f"{name} must appear at most once")
|
|
593
|
+
if not values:
|
|
594
|
+
if required:
|
|
595
|
+
raise RequestValidationError(
|
|
596
|
+
name, f"{name} header is required",
|
|
597
|
+
code="missing_field")
|
|
598
|
+
return None
|
|
599
|
+
return values[0]
|
|
600
|
+
|
|
601
|
+
def _require_json_media_type(self):
|
|
602
|
+
media_type = (
|
|
603
|
+
self._single_header("Content-Type") or "").split(";", 1)[0]
|
|
604
|
+
if media_type.strip().lower() != "application/json":
|
|
605
|
+
raise UnsupportedMediaTypeError()
|
|
606
|
+
|
|
607
|
+
@staticmethod
|
|
608
|
+
def _parse_object(raw: bytes, *, field: str = "body") -> dict:
|
|
609
|
+
try:
|
|
610
|
+
parsed = strict_json_loads(raw)
|
|
611
|
+
except (ValueError, RecursionError):
|
|
612
|
+
raise RequestValidationError(
|
|
613
|
+
field, "request body must be valid unambiguous JSON",
|
|
614
|
+
code="invalid_json") from None
|
|
615
|
+
if not isinstance(parsed, dict):
|
|
616
|
+
raise RequestValidationError(
|
|
617
|
+
field, "JSON request body must be an object")
|
|
618
|
+
return parsed
|
|
619
|
+
|
|
620
|
+
def _body(self, *, parse_json: bool = True) -> dict | bytes:
|
|
621
|
+
if self.headers.get_all("Transfer-Encoding"):
|
|
622
|
+
self.close_connection = True
|
|
623
|
+
raise RequestValidationError(
|
|
624
|
+
"Transfer-Encoding",
|
|
625
|
+
"transfer encoding is unsupported; send Content-Length")
|
|
626
|
+
lengths = self.headers.get_all("Content-Length") or []
|
|
627
|
+
if len(lengths) != 1:
|
|
628
|
+
self.close_connection = True
|
|
629
|
+
raise RequestValidationError(
|
|
630
|
+
"Content-Length",
|
|
631
|
+
"POST requires exactly one Content-Length header")
|
|
632
|
+
encoded_length = lengths[0]
|
|
633
|
+
if re.fullmatch(r"(?:0|[1-9][0-9]*)", encoded_length) is None:
|
|
634
|
+
self.close_connection = True
|
|
635
|
+
raise RequestValidationError(
|
|
636
|
+
"Content-Length",
|
|
637
|
+
"Content-Length must be a canonical non-negative integer")
|
|
638
|
+
encoded_limit = str(max_body_bytes)
|
|
639
|
+
if (len(encoded_length) > len(encoded_limit)
|
|
640
|
+
or (len(encoded_length) == len(encoded_limit)
|
|
641
|
+
and encoded_length > encoded_limit)):
|
|
642
|
+
# Compare the canonical decimal strings before int(). Python
|
|
643
|
+
# deliberately rejects extremely long conversions; allowing
|
|
644
|
+
# that interpreter guard to fire turned caller input into a
|
|
645
|
+
# 500 instead of the documented request-size response.
|
|
646
|
+
self.close_connection = True
|
|
647
|
+
raise RequestTooLarge(max_body_bytes)
|
|
648
|
+
length = int(encoded_length)
|
|
649
|
+
if not length:
|
|
650
|
+
self._raw_request_body = b""
|
|
651
|
+
raise RequestValidationError(
|
|
652
|
+
"body", "request body must be a valid JSON object",
|
|
653
|
+
code="invalid_json")
|
|
654
|
+
raw = self.rfile.read(length)
|
|
655
|
+
if len(raw) != length:
|
|
656
|
+
raise RequestValidationError(
|
|
657
|
+
"body", "request body ended before Content-Length")
|
|
658
|
+
self._raw_request_body = raw
|
|
659
|
+
if not parse_json:
|
|
660
|
+
return raw
|
|
661
|
+
return self._parse_object(raw)
|
|
662
|
+
|
|
663
|
+
def _authorized(self) -> bool:
|
|
664
|
+
header = self._single_header("Authorization") or ""
|
|
665
|
+
scheme, _, supplied = header.partition(" ")
|
|
666
|
+
return (
|
|
667
|
+
scheme.lower() == "bearer"
|
|
668
|
+
and _MIN_SECRET_BYTES <= len(supplied) <= _MAX_SECRET_BYTES
|
|
669
|
+
and supplied.isascii()
|
|
670
|
+
and _BEARER_TOKEN.fullmatch(supplied) is not None
|
|
671
|
+
and hmac.compare_digest(supplied, api_token)
|
|
672
|
+
)
|
|
673
|
+
|
|
674
|
+
def _require_auth(self) -> bool:
|
|
675
|
+
try:
|
|
676
|
+
if self._authorized():
|
|
677
|
+
return True
|
|
678
|
+
except RequestValidationError as exc:
|
|
679
|
+
self._send_error(
|
|
680
|
+
400, exc.code, exc.message, field=exc.field)
|
|
681
|
+
return False
|
|
682
|
+
self._send_error(
|
|
683
|
+
401, "unauthorized", "valid bearer token required",
|
|
684
|
+
headers={"WWW-Authenticate": "Bearer"})
|
|
685
|
+
return False
|
|
686
|
+
|
|
687
|
+
@staticmethod
|
|
688
|
+
def _project(pid: str) -> str:
|
|
689
|
+
pid = _identifier(pid, "project_id")
|
|
690
|
+
if pid != project_id:
|
|
691
|
+
raise APIForbidden(
|
|
692
|
+
f"API is bound to project {project_id}, not {pid}")
|
|
693
|
+
return pid
|
|
694
|
+
|
|
695
|
+
@staticmethod
|
|
696
|
+
def _require_session_in_project(session_id: str | None):
|
|
697
|
+
"""Validate a local session without probing outside API scope."""
|
|
698
|
+
if session_id is None:
|
|
699
|
+
return
|
|
700
|
+
session_id = _identifier(session_id, "session_id")
|
|
701
|
+
try:
|
|
702
|
+
engine.graph.get(
|
|
703
|
+
session_id, tenant_id=engine.tenant_id,
|
|
704
|
+
project_id=project_id, entity_type="session")
|
|
705
|
+
except KeyError:
|
|
706
|
+
raise RequestValidationError(
|
|
707
|
+
"session_id",
|
|
708
|
+
"session_id is not a session in the bound project") from None
|
|
709
|
+
|
|
710
|
+
@staticmethod
|
|
711
|
+
def _parse_request_target(raw_target: str):
|
|
712
|
+
message = (
|
|
713
|
+
"request target must be an origin-form path without an "
|
|
714
|
+
"authority or fragment")
|
|
715
|
+
if (not isinstance(raw_target, str)
|
|
716
|
+
or not raw_target.startswith("/")
|
|
717
|
+
or "#" in raw_target
|
|
718
|
+
or any(ord(char) < 0x20 or ord(char) == 0x7F
|
|
719
|
+
for char in raw_target)):
|
|
720
|
+
raise RequestValidationError(
|
|
721
|
+
"request_target", message)
|
|
722
|
+
try:
|
|
723
|
+
parsed = urlsplit(raw_target)
|
|
724
|
+
except ValueError:
|
|
725
|
+
raise RequestValidationError(
|
|
726
|
+
"request_target", message) from None
|
|
727
|
+
if (parsed.scheme or parsed.netloc or parsed.fragment
|
|
728
|
+
or not parsed.path.startswith("/")):
|
|
729
|
+
raise RequestValidationError(
|
|
730
|
+
"request_target", message)
|
|
731
|
+
return parsed
|
|
732
|
+
|
|
733
|
+
def _dispatch(self):
|
|
734
|
+
try:
|
|
735
|
+
target = self._parse_request_target(self.path)
|
|
736
|
+
self._parsed_request_target = target
|
|
737
|
+
path = target.path
|
|
738
|
+
matches = [
|
|
739
|
+
(route, match)
|
|
740
|
+
for route in API_ROUTES
|
|
741
|
+
if (match := route.pattern.fullmatch(path)) is not None
|
|
742
|
+
]
|
|
743
|
+
if not matches:
|
|
744
|
+
return self._send_error(
|
|
745
|
+
404, "not_found", "route not found")
|
|
746
|
+
method_matches = [
|
|
747
|
+
item for item in matches if item[0].method == self.command]
|
|
748
|
+
if not method_matches:
|
|
749
|
+
allow = ", ".join(sorted({
|
|
750
|
+
route.method for route, _ in matches}))
|
|
751
|
+
return self._send_error(
|
|
752
|
+
405, "method_not_allowed",
|
|
753
|
+
"method is not allowed for this route",
|
|
754
|
+
headers={"Allow": allow})
|
|
755
|
+
|
|
756
|
+
route, match = method_matches[0]
|
|
757
|
+
signed_webhook = (
|
|
758
|
+
route.auth == "webhook" and webhook_secret is not None)
|
|
759
|
+
if (route.auth == "bearer"
|
|
760
|
+
or route.auth == "webhook" and not signed_webhook):
|
|
761
|
+
if not self._require_auth():
|
|
762
|
+
return None
|
|
763
|
+
if target.query and route.name != "assumptions":
|
|
764
|
+
raise RequestValidationError(
|
|
765
|
+
"query", "query parameters are not supported for this route",
|
|
766
|
+
code="unknown_field")
|
|
767
|
+
if route.method == "POST":
|
|
768
|
+
self._require_json_media_type()
|
|
769
|
+
body = self._body(parse_json=not signed_webhook)
|
|
770
|
+
return getattr(self, route.name)(body, *match.groups())
|
|
771
|
+
return getattr(self, route.name)(*match.groups())
|
|
772
|
+
except UnsupportedMediaTypeError as exc:
|
|
773
|
+
return self._send_error(
|
|
774
|
+
415, exc.code, exc.message, field=exc.field)
|
|
775
|
+
except RequestTooLarge as exc:
|
|
776
|
+
return self._send_error(
|
|
777
|
+
413, "request_too_large",
|
|
778
|
+
f"request body exceeds {exc.limit}-byte limit", field="body")
|
|
779
|
+
except RequestValidationError as exc:
|
|
780
|
+
return self._send_error(
|
|
781
|
+
400, exc.code, exc.message, field=exc.field)
|
|
782
|
+
except APIResourceNotFound as exc:
|
|
783
|
+
return self._send_error(
|
|
784
|
+
404, "not_found", f"{exc.resource} was not found")
|
|
785
|
+
except PayloadMismatchError:
|
|
786
|
+
return self._send_error(
|
|
787
|
+
409, "idempotency_conflict",
|
|
788
|
+
"idempotency key was reused with different content")
|
|
789
|
+
except WebhookPayloadError as exc:
|
|
790
|
+
return self._send_error(
|
|
791
|
+
400, "invalid_webhook_payload", str(exc), field="body")
|
|
792
|
+
except WebhookError as exc:
|
|
793
|
+
return self._send_error(
|
|
794
|
+
422, "unsupported_webhook_event", str(exc), field="X-GitHub-Event")
|
|
795
|
+
except CapsuleError as exc:
|
|
796
|
+
return self._send_error(
|
|
797
|
+
400, "invalid_capsule", str(exc), field="capsule")
|
|
798
|
+
except AttestationInputError as exc:
|
|
799
|
+
return self._send_error(
|
|
800
|
+
400, "invalid_attestation", str(exc), field="attestation")
|
|
801
|
+
except ResolutionInputError as exc:
|
|
802
|
+
return self._send_error(
|
|
803
|
+
400, "invalid_resolution", str(exc), field="resolution")
|
|
804
|
+
except (APIForbidden, GitHubDeliveryError):
|
|
805
|
+
return self._send_error(
|
|
806
|
+
403, "forbidden", "request is outside the authorized project scope")
|
|
807
|
+
except Exception:
|
|
808
|
+
return self._send_error(
|
|
809
|
+
500, "internal_error", "internal server error")
|
|
810
|
+
|
|
811
|
+
def do_GET(self):
|
|
812
|
+
return self._dispatch()
|
|
813
|
+
|
|
814
|
+
def do_POST(self):
|
|
815
|
+
return self._dispatch()
|
|
816
|
+
|
|
817
|
+
def do_HEAD(self):
|
|
818
|
+
return self._dispatch()
|
|
819
|
+
|
|
820
|
+
def do_PUT(self):
|
|
821
|
+
return self._dispatch()
|
|
822
|
+
|
|
823
|
+
def do_PATCH(self):
|
|
824
|
+
return self._dispatch()
|
|
825
|
+
|
|
826
|
+
def do_DELETE(self):
|
|
827
|
+
return self._dispatch()
|
|
828
|
+
|
|
829
|
+
def do_OPTIONS(self):
|
|
830
|
+
return self._dispatch()
|
|
831
|
+
|
|
832
|
+
def do_TRACE(self):
|
|
833
|
+
return self._dispatch()
|
|
834
|
+
|
|
835
|
+
def do_CONNECT(self):
|
|
836
|
+
return self._dispatch()
|
|
837
|
+
|
|
838
|
+
# ------------------------------------------------------------- GET
|
|
839
|
+
|
|
840
|
+
def health(self):
|
|
841
|
+
self._send(200, {"status": "ok"})
|
|
842
|
+
|
|
843
|
+
def assumptions(self, pid):
|
|
844
|
+
pid = self._project(pid)
|
|
845
|
+
try:
|
|
846
|
+
query = parse_qs(
|
|
847
|
+
self._parsed_request_target.query,
|
|
848
|
+
keep_blank_values=True, strict_parsing=True,
|
|
849
|
+
max_num_fields=2, separator="&")
|
|
850
|
+
except ValueError:
|
|
851
|
+
raise RequestValidationError(
|
|
852
|
+
"query", "query string is malformed") from None
|
|
853
|
+
unknown = sorted(set(query) - {"status"})
|
|
854
|
+
if unknown:
|
|
855
|
+
raise RequestValidationError(
|
|
856
|
+
"query", f"unknown query parameter(s): {', '.join(unknown)}",
|
|
857
|
+
code="unknown_field")
|
|
858
|
+
statuses = None
|
|
859
|
+
if "status" in query:
|
|
860
|
+
if len(query["status"]) != 1:
|
|
861
|
+
raise RequestValidationError(
|
|
862
|
+
"status", "status may be supplied only once")
|
|
863
|
+
statuses = query["status"][0].split(",")
|
|
864
|
+
if (not statuses or any(not status for status in statuses)
|
|
865
|
+
or len(statuses) != len(set(statuses))
|
|
866
|
+
or any(status not in _ASSUMPTION_STATUSES
|
|
867
|
+
for status in statuses)):
|
|
868
|
+
raise RequestValidationError(
|
|
869
|
+
"status", "status must be a comma-separated list of: "
|
|
870
|
+
+ ", ".join(sorted(_ASSUMPTION_STATUSES)))
|
|
871
|
+
nodes = engine.graph.current(
|
|
872
|
+
pid, "assumption", status=statuses,
|
|
873
|
+
tenant_id=engine.tenant_id)
|
|
874
|
+
self._send(200, [
|
|
875
|
+
{"node_id": n["node_id"], "status": n["status"],
|
|
876
|
+
"criticality": n["criticality"], "confidence": n["confidence"],
|
|
877
|
+
"statement": n["data"].get("statement")} for n in nodes])
|
|
878
|
+
|
|
879
|
+
def invalidations(self, pid):
|
|
880
|
+
pid = self._project(pid)
|
|
881
|
+
invs = engine.invalidation.open_invalidations(pid)
|
|
882
|
+
self._send(200, [
|
|
883
|
+
{"node_id": i["node_id"], "status": i["status"], **i["data"]}
|
|
884
|
+
for i in invs])
|
|
885
|
+
|
|
886
|
+
def evaluations(self):
|
|
887
|
+
evals = engine.graph.current(
|
|
888
|
+
project_id, "evaluation", tenant_id=engine.tenant_id)
|
|
889
|
+
self._send(200, [
|
|
890
|
+
{"node_id": e["node_id"], "status": e["status"],
|
|
891
|
+
"kind": e["data"].get("kind")} for e in evals])
|
|
892
|
+
|
|
893
|
+
# ------------------------------------------------------------ POST
|
|
894
|
+
|
|
895
|
+
@staticmethod
|
|
896
|
+
def _validate_ping(payload: dict):
|
|
897
|
+
# GitHub's ping schema has repository for repository-scoped hooks,
|
|
898
|
+
# but does not declare installation. Require the immutable repo id;
|
|
899
|
+
# if an installation is present, bind it too. Operational events
|
|
900
|
+
# still require an installation whenever project policy pins one.
|
|
901
|
+
_required_object(payload, "hook")
|
|
902
|
+
_required_int(payload, "hook_id", minimum=1)
|
|
903
|
+
_required_string(payload, "zen")
|
|
904
|
+
repository = _required_object(payload, "repository")
|
|
905
|
+
delivered_id = _required_int(repository, "id", minimum=1)
|
|
906
|
+
try:
|
|
907
|
+
project = engine.graph.get(
|
|
908
|
+
project_id, tenant_id=engine.tenant_id,
|
|
909
|
+
project_id=project_id, entity_type="project")
|
|
910
|
+
except KeyError:
|
|
911
|
+
raise APIForbidden("bound project does not exist") from None
|
|
912
|
+
configured_id = project["data"].get("repository_id")
|
|
913
|
+
if (isinstance(configured_id, bool)
|
|
914
|
+
or not isinstance(configured_id, int)
|
|
915
|
+
or configured_id <= 0 or delivered_id != configured_id):
|
|
916
|
+
raise APIForbidden("ping repository does not match the bound project")
|
|
917
|
+
installation = payload.get("installation")
|
|
918
|
+
configured_installation = project["data"].get(
|
|
919
|
+
"github_installation_id")
|
|
920
|
+
if (configured_installation is not None
|
|
921
|
+
and (isinstance(configured_installation, bool)
|
|
922
|
+
or not isinstance(configured_installation, int)
|
|
923
|
+
or configured_installation <= 0)):
|
|
924
|
+
raise APIForbidden(
|
|
925
|
+
"bound project has an invalid installation identity")
|
|
926
|
+
if installation is not None:
|
|
927
|
+
if not isinstance(installation, dict):
|
|
928
|
+
raise RequestValidationError(
|
|
929
|
+
"installation", "installation must be a JSON object")
|
|
930
|
+
delivered_installation = _required_int(
|
|
931
|
+
installation, "id", minimum=1)
|
|
932
|
+
if (configured_installation is not None
|
|
933
|
+
and delivered_installation != configured_installation):
|
|
934
|
+
raise APIForbidden(
|
|
935
|
+
"ping installation does not match the bound project")
|
|
936
|
+
|
|
937
|
+
def events_ingest(self, body):
|
|
938
|
+
if webhook_secret is not None:
|
|
939
|
+
raw = body
|
|
940
|
+
signature = self._single_header("X-Hub-Signature-256")
|
|
941
|
+
if (signature is None or not verify_signature(
|
|
942
|
+
webhook_secret, raw, signature)):
|
|
943
|
+
raise APIForbidden("GitHub webhook signature invalid")
|
|
944
|
+
event_name = self._single_header(
|
|
945
|
+
"X-GitHub-Event", required=True)
|
|
946
|
+
delivery_id = self._single_header(
|
|
947
|
+
"X-GitHub-Delivery", required=True)
|
|
948
|
+
if (_GITHUB_EVENT_HEADER.fullmatch(event_name or "") is None):
|
|
949
|
+
raise RequestValidationError(
|
|
950
|
+
"X-GitHub-Event",
|
|
951
|
+
"X-GitHub-Event must be a bounded lowercase event name")
|
|
952
|
+
try:
|
|
953
|
+
validate_public_identifier(
|
|
954
|
+
delivery_id, field="X-GitHub-Delivery")
|
|
955
|
+
except ValueError as exc:
|
|
956
|
+
raise RequestValidationError(
|
|
957
|
+
"X-GitHub-Delivery", str(exc)) from None
|
|
958
|
+
payload = self._parse_object(raw)
|
|
959
|
+
pid = project_id
|
|
960
|
+
if event_name == "ping":
|
|
961
|
+
self._validate_ping(payload)
|
|
962
|
+
return self._send(200, {"status": "ok", "event": "ping"})
|
|
963
|
+
else:
|
|
964
|
+
# Bearer-authenticated embedding compatibility. The shipped
|
|
965
|
+
# server always configures a webhook secret.
|
|
966
|
+
_reject_unknown(
|
|
967
|
+
body, {"project_id", "event_name", "delivery_id", "payload"})
|
|
968
|
+
pid = self._project(
|
|
969
|
+
_optional_string(body, "project_id", project_id))
|
|
970
|
+
event_name = _required_string(body, "event_name")
|
|
971
|
+
delivery_id = _required_identifier(body, "delivery_id")
|
|
972
|
+
payload = _required_object(body, "payload")
|
|
973
|
+
signed_arguments = (
|
|
974
|
+
{
|
|
975
|
+
"raw_body": raw,
|
|
976
|
+
"signature_header": signature,
|
|
977
|
+
"webhook_secret": webhook_secret,
|
|
978
|
+
}
|
|
979
|
+
if webhook_secret is not None else {}
|
|
980
|
+
)
|
|
981
|
+
report = engine.ingest_github(
|
|
982
|
+
pid, event_name, delivery_id, payload, **signed_arguments)
|
|
983
|
+
self._send(202, report or {"status": "duplicate"})
|
|
984
|
+
|
|
985
|
+
def traces_ingest(self, body):
|
|
986
|
+
_reject_unknown(body, {"project_id", "session_id", "span_id", "payload"})
|
|
987
|
+
pid = self._project(_optional_identifier(
|
|
988
|
+
body, "project_id", project_id))
|
|
989
|
+
session_id = _optional_identifier(
|
|
990
|
+
body, "session_id", allow_none=True)
|
|
991
|
+
span_id = _required_identifier(body, "span_id")
|
|
992
|
+
payload = _optional_object(body, "payload", {})
|
|
993
|
+
if "message" in payload and not isinstance(payload["message"], str):
|
|
994
|
+
raise RequestValidationError(
|
|
995
|
+
"payload.message", "payload.message must be a string")
|
|
996
|
+
self._require_session_in_project(session_id)
|
|
997
|
+
report = engine.ingest_agent_trace(
|
|
998
|
+
pid, session_id=session_id, span_id=span_id, payload=payload)
|
|
999
|
+
self._send(202, report or {"status": "duplicate"})
|
|
1000
|
+
|
|
1001
|
+
def compose(self, body, pid):
|
|
1002
|
+
_reject_unknown(body, {"token_budget", "target"})
|
|
1003
|
+
pid = self._project(pid)
|
|
1004
|
+
budget = _optional_int(
|
|
1005
|
+
body, "token_budget", 4000,
|
|
1006
|
+
minimum=1, maximum=_MAX_TOKEN_BUDGET)
|
|
1007
|
+
target = _optional_object(body, "target")
|
|
1008
|
+
self._send(200, engine.resume_packet(
|
|
1009
|
+
pid, target=target, token_budget=budget))
|
|
1010
|
+
|
|
1011
|
+
def resolve(self, body, assumption_id):
|
|
1012
|
+
assumption_id = _identifier(assumption_id, "assumption_id")
|
|
1013
|
+
try:
|
|
1014
|
+
node = engine.graph.get(
|
|
1015
|
+
assumption_id, tenant_id=engine.tenant_id,
|
|
1016
|
+
project_id=project_id)
|
|
1017
|
+
except KeyError:
|
|
1018
|
+
raise APIResourceNotFound("assumption") from None
|
|
1019
|
+
if node["project_id"] != project_id:
|
|
1020
|
+
raise APIForbidden("assumption belongs to another project")
|
|
1021
|
+
if node["entity_type"] not in ("assumption", "claim", "requirement",
|
|
1022
|
+
"constraint", "decision"):
|
|
1023
|
+
raise RequestValidationError(
|
|
1024
|
+
"assumption_id",
|
|
1025
|
+
f"cannot resolve a {node['entity_type']} through this endpoint",
|
|
1026
|
+
code="invalid_resource_type")
|
|
1027
|
+
if "invalidation_id" in body:
|
|
1028
|
+
_reject_unknown(body, {
|
|
1029
|
+
"invalidation_id", "mode", "actor", "replacement_node_id",
|
|
1030
|
+
"narrowed_scope", "note",
|
|
1031
|
+
})
|
|
1032
|
+
inv_id = _required_identifier(body, "invalidation_id")
|
|
1033
|
+
mode = _optional_string(
|
|
1034
|
+
body, "mode", "replacement_evidence")
|
|
1035
|
+
if mode not in {
|
|
1036
|
+
"replacement_evidence", "narrowed_scope",
|
|
1037
|
+
"superseding_decision"}:
|
|
1038
|
+
raise RequestValidationError(
|
|
1039
|
+
"mode", "mode must be replacement_evidence, "
|
|
1040
|
+
"narrowed_scope, or superseding_decision")
|
|
1041
|
+
actor = _optional_string(body, "actor", "api")
|
|
1042
|
+
replacement = _optional_identifier(
|
|
1043
|
+
body, "replacement_node_id", allow_none=True)
|
|
1044
|
+
narrowed_scope = _optional_object(
|
|
1045
|
+
body, "narrowed_scope", allow_none=True)
|
|
1046
|
+
note = _optional_string(
|
|
1047
|
+
body, "note", "", allow_empty=True)
|
|
1048
|
+
try:
|
|
1049
|
+
invalidation = engine.graph.get(
|
|
1050
|
+
inv_id, tenant_id=engine.tenant_id,
|
|
1051
|
+
project_id=project_id, entity_type="invalidation")
|
|
1052
|
+
except KeyError:
|
|
1053
|
+
raise APIResourceNotFound("invalidation") from None
|
|
1054
|
+
affected_ids = {
|
|
1055
|
+
affected.get("node_id")
|
|
1056
|
+
for affected in invalidation["data"].get("affected", [])
|
|
1057
|
+
if isinstance(affected, dict)
|
|
1058
|
+
}
|
|
1059
|
+
if (invalidation["data"].get("target_node_id") != assumption_id
|
|
1060
|
+
and assumption_id not in affected_ids):
|
|
1061
|
+
raise RequestValidationError(
|
|
1062
|
+
"invalidation_id",
|
|
1063
|
+
"invalidation_id does not target or affect assumption_id",
|
|
1064
|
+
code="resource_mismatch")
|
|
1065
|
+
if replacement is not None:
|
|
1066
|
+
try:
|
|
1067
|
+
engine.graph.get(
|
|
1068
|
+
replacement, tenant_id=engine.tenant_id,
|
|
1069
|
+
project_id=project_id)
|
|
1070
|
+
except KeyError:
|
|
1071
|
+
raise APIResourceNotFound("replacement node") from None
|
|
1072
|
+
out = engine.invalidation.resolve(
|
|
1073
|
+
inv_id, mode=mode, actor=actor,
|
|
1074
|
+
replacement_node_id=replacement,
|
|
1075
|
+
narrowed_scope=narrowed_scope, note=note)
|
|
1076
|
+
return self._send(200, {"invalidation": out["node_id"],
|
|
1077
|
+
"status": out["status"]})
|
|
1078
|
+
_reject_unknown(body, {"action", "data"})
|
|
1079
|
+
# Scope the mutation. Without these two checks the endpoint
|
|
1080
|
+
# rewrites any node of any type in any project — a task, a proof
|
|
1081
|
+
# record, a project — because it reads the target's own
|
|
1082
|
+
# entity_type and never compares its project (ADR-052).
|
|
1083
|
+
action = _optional_string(body, "action", "accept")
|
|
1084
|
+
statuses = {"accept": "resolved", "reject": "invalidated",
|
|
1085
|
+
"narrow": "active", "supersede": "superseded"}
|
|
1086
|
+
if action not in statuses:
|
|
1087
|
+
raise RequestValidationError(
|
|
1088
|
+
"action",
|
|
1089
|
+
"action must be accept, reject, narrow, or supersede")
|
|
1090
|
+
status = statuses[action]
|
|
1091
|
+
patch = _optional_object(body, "data")
|
|
1092
|
+
data = dict(node["data"])
|
|
1093
|
+
if patch is not None:
|
|
1094
|
+
data.update(patch)
|
|
1095
|
+
out = engine.graph.put_node(
|
|
1096
|
+
entity_type=node["entity_type"], tenant_id=node["tenant_id"],
|
|
1097
|
+
project_id=node["project_id"], node_id=assumption_id,
|
|
1098
|
+
data=data, status=status,
|
|
1099
|
+
authority="human_decision")
|
|
1100
|
+
self._send(200, {"node_id": out["node_id"], "status": out["status"]})
|
|
1101
|
+
|
|
1102
|
+
def attest(self, body):
|
|
1103
|
+
_reject_unknown(body, {
|
|
1104
|
+
"project_id", "intent_type", "intent_statement", "actor",
|
|
1105
|
+
"action_type", "verifications", "continuity",
|
|
1106
|
+
})
|
|
1107
|
+
pid = self._project(
|
|
1108
|
+
_optional_identifier(body, "project_id", project_id))
|
|
1109
|
+
intent_type = _required_string(body, "intent_type")
|
|
1110
|
+
intent_statement = _optional_string(
|
|
1111
|
+
body, "intent_statement", "", allow_empty=True)
|
|
1112
|
+
actor = _optional_object(body, "actor", {"agent": "api"})
|
|
1113
|
+
action_type = _optional_string(
|
|
1114
|
+
body, "action_type", "run_verifier")
|
|
1115
|
+
verifications = _validate_verifications(body)
|
|
1116
|
+
continuity = _validate_continuity(body)
|
|
1117
|
+
proof = engine.attest_action(
|
|
1118
|
+
pid, intent_type=intent_type,
|
|
1119
|
+
intent_statement=intent_statement, actor=actor,
|
|
1120
|
+
action_type=action_type,
|
|
1121
|
+
verification_outcomes=verifications,
|
|
1122
|
+
continuity=continuity)
|
|
1123
|
+
self._send(200, proof)
|
|
1124
|
+
|
|
1125
|
+
def verifications_run(self, body):
|
|
1126
|
+
if "verifiers" in body or "command" in body:
|
|
1127
|
+
raise RequestValidationError(
|
|
1128
|
+
"verifiers",
|
|
1129
|
+
"caller-supplied verifier definitions are forbidden; "
|
|
1130
|
+
"configure pinned required_verifiers in project policy",
|
|
1131
|
+
code="caller_verifier_forbidden")
|
|
1132
|
+
_reject_unknown(body, {
|
|
1133
|
+
"project_id", "intent_type", "intent_statement", "continuity",
|
|
1134
|
+
})
|
|
1135
|
+
pid = self._project(
|
|
1136
|
+
_optional_identifier(body, "project_id", project_id))
|
|
1137
|
+
intent_type = _optional_string(
|
|
1138
|
+
body, "intent_type", "verification_run")
|
|
1139
|
+
intent_statement = _optional_string(
|
|
1140
|
+
body, "intent_statement", "run policy-owned verifiers",
|
|
1141
|
+
allow_empty=True)
|
|
1142
|
+
continuity = _validate_continuity(body)
|
|
1143
|
+
proof = engine.attest_action(
|
|
1144
|
+
pid, intent_type=intent_type,
|
|
1145
|
+
intent_statement=intent_statement,
|
|
1146
|
+
actor={"agent": "api", "model": "n/a"},
|
|
1147
|
+
action_type="run_verifier",
|
|
1148
|
+
continuity=continuity)
|
|
1149
|
+
self._send(200, proof)
|
|
1150
|
+
|
|
1151
|
+
def continuity_receipt_verify(self, body, pid):
|
|
1152
|
+
_reject_unknown(body, {"receipt"})
|
|
1153
|
+
pid = self._project(pid)
|
|
1154
|
+
receipt = _required_object(body, "receipt")
|
|
1155
|
+
self._send(
|
|
1156
|
+
200, engine.verify_continuity_receipt(pid, receipt))
|
|
1157
|
+
|
|
1158
|
+
def migrations_prepare(self, body):
|
|
1159
|
+
_reject_unknown(body, {
|
|
1160
|
+
"project_id", "session_id", "source_model",
|
|
1161
|
+
"source_runtime", "target_adapter",
|
|
1162
|
+
})
|
|
1163
|
+
pid = self._project(
|
|
1164
|
+
_optional_identifier(body, "project_id", project_id))
|
|
1165
|
+
session_id = _optional_identifier(
|
|
1166
|
+
body, "session_id", allow_none=True)
|
|
1167
|
+
source_model = _optional_string(body, "source_model", "unknown")
|
|
1168
|
+
source_runtime = _optional_string(
|
|
1169
|
+
body, "source_runtime", "unknown")
|
|
1170
|
+
target_adapter = _optional_string(
|
|
1171
|
+
body, "target_adapter", "generic")
|
|
1172
|
+
self._require_session_in_project(session_id)
|
|
1173
|
+
capsule = engine.capsules.export(
|
|
1174
|
+
tenant_id=engine.tenant_id,
|
|
1175
|
+
project_id=pid, session_id=session_id,
|
|
1176
|
+
source_model=source_model,
|
|
1177
|
+
source_runtime=source_runtime,
|
|
1178
|
+
target_adapter=target_adapter,
|
|
1179
|
+
signer=engine.signer)
|
|
1180
|
+
self._send(200, capsule)
|
|
1181
|
+
|
|
1182
|
+
def migrations_validate(self, body):
|
|
1183
|
+
_reject_unknown(body, {"capsule", "target_model", "target_runtime"})
|
|
1184
|
+
capsule = _required_object(body, "capsule")
|
|
1185
|
+
capsule_tenant = _identifier(
|
|
1186
|
+
_required_string(capsule, "tenant_id"), "capsule.tenant_id")
|
|
1187
|
+
capsule_project = _identifier(
|
|
1188
|
+
_required_string(capsule, "project_id"), "capsule.project_id")
|
|
1189
|
+
target_model = _optional_string(body, "target_model", "generic")
|
|
1190
|
+
target_runtime = _optional_string(
|
|
1191
|
+
body, "target_runtime", "generic")
|
|
1192
|
+
if capsule_tenant != engine.tenant_id:
|
|
1193
|
+
raise APIForbidden("migration capsule belongs to another tenant")
|
|
1194
|
+
self._project(capsule_project)
|
|
1195
|
+
result = engine.capsules.import_capsule(
|
|
1196
|
+
capsule, signer=engine.signer,
|
|
1197
|
+
target_model=target_model,
|
|
1198
|
+
target_runtime=target_runtime,
|
|
1199
|
+
expected_tenant_id=engine.tenant_id,
|
|
1200
|
+
expected_project_id=project_id)
|
|
1201
|
+
self._send(200, {"session_id": result["session"]["node_id"],
|
|
1202
|
+
"challenge": result["challenge"],
|
|
1203
|
+
"validation": result["validation"]})
|
|
1204
|
+
|
|
1205
|
+
def replays(self, body):
|
|
1206
|
+
_reject_unknown(body, {
|
|
1207
|
+
"project_id", "from_event_id", "captured_inputs", "mocks", "fork",
|
|
1208
|
+
})
|
|
1209
|
+
pid = self._project(
|
|
1210
|
+
_optional_identifier(body, "project_id", project_id))
|
|
1211
|
+
from_event_id = _required_identifier(body, "from_event_id")
|
|
1212
|
+
captured_inputs = _optional_object(
|
|
1213
|
+
body, "captured_inputs", allow_none=True)
|
|
1214
|
+
mocks = _optional_object(body, "mocks", allow_none=True)
|
|
1215
|
+
fork = _optional_object(body, "fork", allow_none=True)
|
|
1216
|
+
try:
|
|
1217
|
+
engine.store.get_event(
|
|
1218
|
+
from_event_id, tenant_id=engine.tenant_id, project_id=pid)
|
|
1219
|
+
except KeyError:
|
|
1220
|
+
raise APIForbidden(
|
|
1221
|
+
"replay origin is outside the authorized project scope") from None
|
|
1222
|
+
node = engine.replay.start(
|
|
1223
|
+
tenant_id=engine.tenant_id,
|
|
1224
|
+
project_id=pid, from_event_id=from_event_id,
|
|
1225
|
+
captured_inputs=captured_inputs,
|
|
1226
|
+
mocks=mocks, fork=fork)
|
|
1227
|
+
self._send(201, {"replay_node": node["node_id"],
|
|
1228
|
+
"fidelity": node["data"]["fidelity"]})
|
|
1229
|
+
|
|
1230
|
+
return Handler
|
|
1231
|
+
|
|
1232
|
+
|
|
1233
|
+
def serve(
|
|
1234
|
+
engine,
|
|
1235
|
+
project_id: str,
|
|
1236
|
+
port: int = 8199,
|
|
1237
|
+
*,
|
|
1238
|
+
api_token: str,
|
|
1239
|
+
webhook_secret: bytes | str,
|
|
1240
|
+
max_body_bytes: int = _MAX_BODY_BYTES,
|
|
1241
|
+
max_workers: int = _DEFAULT_MAX_WORKERS,
|
|
1242
|
+
request_timeout_seconds: float = _DEFAULT_REQUEST_TIMEOUT_SECONDS,
|
|
1243
|
+
):
|
|
1244
|
+
project_id = _validated_project_id(project_id)
|
|
1245
|
+
port = _bounded_int("port", port, 1, 65_535)
|
|
1246
|
+
api_token = _validated_api_token(api_token)
|
|
1247
|
+
webhook_secret = _validated_webhook_secret(webhook_secret, required=True)
|
|
1248
|
+
max_body_bytes = _bounded_int(
|
|
1249
|
+
"max_body_bytes", max_body_bytes, 1, _MAX_CONFIGURED_BODY_BYTES)
|
|
1250
|
+
max_workers = _bounded_int("max_workers", max_workers, 1, _MAX_WORKERS)
|
|
1251
|
+
request_timeout_seconds = _validated_timeout(request_timeout_seconds)
|
|
1252
|
+
server = BoundedThreadingHTTPServer(
|
|
1253
|
+
("127.0.0.1", port),
|
|
1254
|
+
make_handler(
|
|
1255
|
+
engine, project_id, api_token=api_token,
|
|
1256
|
+
webhook_secret=webhook_secret, max_body_bytes=max_body_bytes,
|
|
1257
|
+
request_timeout_seconds=request_timeout_seconds),
|
|
1258
|
+
max_workers=max_workers)
|
|
1259
|
+
try:
|
|
1260
|
+
server.serve_forever()
|
|
1261
|
+
except KeyboardInterrupt:
|
|
1262
|
+
pass
|
|
1263
|
+
finally:
|
|
1264
|
+
server.server_close()
|