spring-runtime-mcp 0.2.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.
- spring_runtime_mcp/__init__.py +3 -0
- spring_runtime_mcp/adapters/__init__.py +1 -0
- spring_runtime_mcp/adapters/beans.py +159 -0
- spring_runtime_mcp/adapters/conditions.py +333 -0
- spring_runtime_mcp/adapters/config.py +295 -0
- spring_runtime_mcp/adapters/drift.py +790 -0
- spring_runtime_mcp/adapters/health.py +164 -0
- spring_runtime_mcp/adapters/mapping_predicates.py +201 -0
- spring_runtime_mcp/adapters/mappings.py +294 -0
- spring_runtime_mcp/adapters/migrations.py +373 -0
- spring_runtime_mcp/adapters/openapi.py +683 -0
- spring_runtime_mcp/adapters/source_catalog.py +119 -0
- spring_runtime_mcp/adapters/startup.py +202 -0
- spring_runtime_mcp/analyzers/__init__.py +1 -0
- spring_runtime_mcp/analyzers/bean_graph.py +172 -0
- spring_runtime_mcp/analyzers/condition_failures.py +359 -0
- spring_runtime_mcp/cli.py +248 -0
- spring_runtime_mcp/contracts/__init__.py +1 -0
- spring_runtime_mcp/contracts/definitions.py +425 -0
- spring_runtime_mcp/diagnostics.py +254 -0
- spring_runtime_mcp/models/__init__.py +1 -0
- spring_runtime_mcp/models/bean_graph.py +77 -0
- spring_runtime_mcp/models/common.py +99 -0
- spring_runtime_mcp/models/conditions.py +56 -0
- spring_runtime_mcp/models/queries.py +140 -0
- spring_runtime_mcp/models/source_catalog.py +35 -0
- spring_runtime_mcp/models/timestamps.py +71 -0
- spring_runtime_mcp/policy/__init__.py +1 -0
- spring_runtime_mcp/policy/configuration.py +32 -0
- spring_runtime_mcp/policy/output_guard.py +117 -0
- spring_runtime_mcp/policy/sanitization.py +616 -0
- spring_runtime_mcp/py.typed +0 -0
- spring_runtime_mcp/replay.py +220 -0
- spring_runtime_mcp/replay_data/bean_graph/README.md +26 -0
- spring_runtime_mcp/replay_data/bean_graph/factory-method/conditions.json +43 -0
- spring_runtime_mcp/replay_data/bean_graph/factory-method/source-catalog.json +54 -0
- spring_runtime_mcp/replay_data/bean_graph/nested-conditions/beans.json +99 -0
- spring_runtime_mcp/replay_data/bean_graph/nested-conditions/conditions.json +25 -0
- spring_runtime_mcp/replay_data/bean_graph/parent-child/beans.json +92 -0
- spring_runtime_mcp/replay_data/bean_graph/shape-metadata.json +32 -0
- spring_runtime_mcp/replay_data/bean_graph/stripe-missing/conditions.json +43 -0
- spring_runtime_mcp/replay_data/bean_graph/stripe-missing/source-catalog.json +54 -0
- spring_runtime_mcp/replay_data/conformance/README.md +41 -0
- spring_runtime_mcp/replay_data/conformance/boot-2.7-servlet/beans.json +92 -0
- spring_runtime_mcp/replay_data/conformance/boot-2.7-servlet/conditions.json +52 -0
- spring_runtime_mcp/replay_data/conformance/boot-2.7-servlet/configprops.json +20 -0
- spring_runtime_mcp/replay_data/conformance/boot-2.7-servlet/env.json +16 -0
- spring_runtime_mcp/replay_data/conformance/boot-2.7-servlet/flyway.json +40 -0
- spring_runtime_mcp/replay_data/conformance/boot-2.7-servlet/health-v2.json +14 -0
- spring_runtime_mcp/replay_data/conformance/boot-2.7-servlet/health-v3.json +38 -0
- spring_runtime_mcp/replay_data/conformance/boot-2.7-servlet/liquibase.json +30 -0
- spring_runtime_mcp/replay_data/conformance/boot-2.7-servlet/mappings.json +40 -0
- spring_runtime_mcp/replay_data/conformance/boot-2.7-servlet/openapi.json +54 -0
- spring_runtime_mcp/replay_data/conformance/boot-2.7-servlet/source-catalog.json +90 -0
- spring_runtime_mcp/replay_data/conformance/boot-2.7-servlet/startup.json +45 -0
- spring_runtime_mcp/replay_data/conformance/boot-3.4-servlet/beans.json +92 -0
- spring_runtime_mcp/replay_data/conformance/boot-3.4-servlet/conditions.json +52 -0
- spring_runtime_mcp/replay_data/conformance/boot-3.4-servlet/configprops.json +20 -0
- spring_runtime_mcp/replay_data/conformance/boot-3.4-servlet/env.json +16 -0
- spring_runtime_mcp/replay_data/conformance/boot-3.4-servlet/flyway.json +40 -0
- spring_runtime_mcp/replay_data/conformance/boot-3.4-servlet/health-v3.json +38 -0
- spring_runtime_mcp/replay_data/conformance/boot-3.4-servlet/liquibase.json +30 -0
- spring_runtime_mcp/replay_data/conformance/boot-3.4-servlet/mappings.json +40 -0
- spring_runtime_mcp/replay_data/conformance/boot-3.4-servlet/openapi.json +54 -0
- spring_runtime_mcp/replay_data/conformance/boot-3.4-servlet/source-catalog.json +90 -0
- spring_runtime_mcp/replay_data/conformance/boot-3.4-servlet/startup.json +45 -0
- spring_runtime_mcp/replay_data/conformance/boot-4.1-webflux/beans.json +92 -0
- spring_runtime_mcp/replay_data/conformance/boot-4.1-webflux/conditions.json +52 -0
- spring_runtime_mcp/replay_data/conformance/boot-4.1-webflux/configprops.json +20 -0
- spring_runtime_mcp/replay_data/conformance/boot-4.1-webflux/env.json +16 -0
- spring_runtime_mcp/replay_data/conformance/boot-4.1-webflux/flyway.json +40 -0
- spring_runtime_mcp/replay_data/conformance/boot-4.1-webflux/health-v3.json +38 -0
- spring_runtime_mcp/replay_data/conformance/boot-4.1-webflux/liquibase.json +30 -0
- spring_runtime_mcp/replay_data/conformance/boot-4.1-webflux/mappings.json +40 -0
- spring_runtime_mcp/replay_data/conformance/boot-4.1-webflux/openapi.json +54 -0
- spring_runtime_mcp/replay_data/conformance/boot-4.1-webflux/servlet-namespace-comparison.json +4 -0
- spring_runtime_mcp/replay_data/conformance/boot-4.1-webflux/source-catalog.json +90 -0
- spring_runtime_mcp/replay_data/conformance/boot-4.1-webflux/startup.json +45 -0
- spring_runtime_mcp/replay_data/conformance/contracts/springboot_analyze_startup.json +62 -0
- spring_runtime_mcp/replay_data/conformance/contracts/springboot_check_migrations.json +63 -0
- spring_runtime_mcp/replay_data/conformance/contracts/springboot_detect_drift.json +64 -0
- spring_runtime_mcp/replay_data/conformance/contracts/springboot_diagnose_health.json +59 -0
- spring_runtime_mcp/replay_data/conformance/contracts/springboot_inspect_beans.json +56 -0
- spring_runtime_mcp/replay_data/conformance/contracts/springboot_inspect_config.json +62 -0
- spring_runtime_mcp/replay_data/conformance/contracts/springboot_search_openapi.json +80 -0
- spring_runtime_mcp/replay_data/conformance/contracts/springboot_verify_mappings.json +58 -0
- spring_runtime_mcp/replay_data/conformance/faults/cases.json +63 -0
- spring_runtime_mcp/replay_data/conformance/faults/health-hidden.expected.json +54 -0
- spring_runtime_mcp/replay_data/conformance/faults/health-hidden.json +3 -0
- spring_runtime_mcp/replay_data/conformance/large/beans-2500.json +22506 -0
- spring_runtime_mcp/replay_data/conformance/manifest.json +46 -0
- spring_runtime_mcp/schemas/tools-contract.json +1902 -0
- spring_runtime_mcp/server.py +322 -0
- spring_runtime_mcp/services.py +44 -0
- spring_runtime_mcp/snapshots.py +340 -0
- spring_runtime_mcp/tools/__init__.py +1 -0
- spring_runtime_mcp/tools/analyze_startup.py +79 -0
- spring_runtime_mcp/tools/check_migrations.py +99 -0
- spring_runtime_mcp/tools/detect_drift.py +323 -0
- spring_runtime_mcp/tools/diagnose_health.py +88 -0
- spring_runtime_mcp/tools/inspect_beans.py +479 -0
- spring_runtime_mcp/tools/inspect_config.py +102 -0
- spring_runtime_mcp/tools/registry.py +48 -0
- spring_runtime_mcp/tools/search_openapi.py +79 -0
- spring_runtime_mcp/tools/support.py +166 -0
- spring_runtime_mcp/tools/verify_mappings.py +62 -0
- spring_runtime_mcp/transport/__init__.py +1 -0
- spring_runtime_mcp/transport/actuator_http.py +459 -0
- spring_runtime_mcp/transport/bounded_json.py +144 -0
- spring_runtime_mcp/transport/replay_files.py +71 -0
- spring_runtime_mcp/transport/response_gate.py +186 -0
- spring_runtime_mcp/transport/responses.py +82 -0
- spring_runtime_mcp/transport/stdio.py +44 -0
- spring_runtime_mcp-0.2.0.dist-info/METADATA +320 -0
- spring_runtime_mcp-0.2.0.dist-info/RECORD +118 -0
- spring_runtime_mcp-0.2.0.dist-info/WHEEL +4 -0
- spring_runtime_mcp-0.2.0.dist-info/entry_points.txt +2 -0
- spring_runtime_mcp-0.2.0.dist-info/licenses/LICENSE +201 -0
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
"""Immutable, target-scoped identities and dependency graph facts."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from collections.abc import Mapping
|
|
6
|
+
from dataclasses import dataclass
|
|
7
|
+
from typing import Literal
|
|
8
|
+
|
|
9
|
+
from .common import Fact
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
@dataclass(frozen=True, order=True)
|
|
13
|
+
class ContextKey:
|
|
14
|
+
target_id: str
|
|
15
|
+
snapshot_id: str
|
|
16
|
+
context_id: str
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
@dataclass(frozen=True, order=True)
|
|
20
|
+
class BeanKey:
|
|
21
|
+
context: ContextKey
|
|
22
|
+
canonical_name: str
|
|
23
|
+
bean_kind: Literal["bean", "factory", "product", "unknown"] = "unknown"
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
@dataclass(frozen=True)
|
|
27
|
+
class Context:
|
|
28
|
+
key: ContextKey
|
|
29
|
+
parent: Fact[ContextKey | None]
|
|
30
|
+
complete: bool
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
@dataclass(frozen=True)
|
|
34
|
+
class Bean:
|
|
35
|
+
key: BeanKey
|
|
36
|
+
id: str
|
|
37
|
+
aliases: tuple[str, ...]
|
|
38
|
+
reported_type: Fact[str]
|
|
39
|
+
scope: Fact[str]
|
|
40
|
+
resource: Fact[str]
|
|
41
|
+
requested_dependencies: tuple[str, ...]
|
|
42
|
+
evidence_ids: tuple[str, ...]
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
@dataclass(frozen=True)
|
|
46
|
+
class Resolution:
|
|
47
|
+
state: Literal["resolved", "ambiguous", "unresolved"]
|
|
48
|
+
candidates: tuple[BeanKey, ...]
|
|
49
|
+
reason_code: str | None
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
@dataclass(frozen=True)
|
|
53
|
+
class DependencyEdge:
|
|
54
|
+
from_key: BeanKey
|
|
55
|
+
requested_name: str
|
|
56
|
+
resolution: Resolution
|
|
57
|
+
evidence_ids: tuple[str, ...]
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
@dataclass(frozen=True)
|
|
61
|
+
class BeanGraph:
|
|
62
|
+
contexts: Mapping[ContextKey, Context]
|
|
63
|
+
beans: Mapping[BeanKey, Bean]
|
|
64
|
+
names: Mapping[ContextKey, Mapping[str, tuple[BeanKey, ...]]]
|
|
65
|
+
canonical_to_names: Mapping[BeanKey, tuple[str, ...]]
|
|
66
|
+
forward: Mapping[BeanKey, tuple[DependencyEdge, ...]]
|
|
67
|
+
reverse: Mapping[BeanKey, tuple[DependencyEdge, ...]]
|
|
68
|
+
coverage: Literal["complete", "partial", "unavailable"]
|
|
69
|
+
gaps: tuple[str, ...]
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
@dataclass(frozen=True)
|
|
73
|
+
class GraphSlice:
|
|
74
|
+
roots: tuple[BeanKey, ...]
|
|
75
|
+
edges: tuple[tuple[DependencyEdge, int], ...]
|
|
76
|
+
truncated: bool
|
|
77
|
+
gaps: tuple[str, ...]
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
"""Shared observation, uncertainty and request budget models."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from collections.abc import Callable, Mapping
|
|
6
|
+
from contextvars import ContextVar
|
|
7
|
+
from dataclasses import dataclass
|
|
8
|
+
import time
|
|
9
|
+
from typing import Any, Generic, Literal, TypeVar
|
|
10
|
+
|
|
11
|
+
T = TypeVar("T")
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class NormalizationError(ValueError):
|
|
15
|
+
"""An expected, safe diagnostic failure; never attach upstream text."""
|
|
16
|
+
|
|
17
|
+
def __init__(self, code: str) -> None:
|
|
18
|
+
self.code = code
|
|
19
|
+
super().__init__(code)
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class AnalysisLimit(NormalizationError):
|
|
23
|
+
"""The shared request analysis budget has been exhausted."""
|
|
24
|
+
|
|
25
|
+
def __init__(self, code: str = "GRAPH_LIMIT_REACHED") -> None:
|
|
26
|
+
super().__init__(code)
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
@dataclass
|
|
30
|
+
class CallBudget:
|
|
31
|
+
"""One absolute deadline and visit allowance shared by an entire tool call."""
|
|
32
|
+
|
|
33
|
+
deadline: float
|
|
34
|
+
visits: int = 0
|
|
35
|
+
|
|
36
|
+
def check(self) -> float:
|
|
37
|
+
remaining = self.deadline - time.monotonic()
|
|
38
|
+
if remaining <= 0:
|
|
39
|
+
raise AnalysisLimit("TARGET_TIMEOUT")
|
|
40
|
+
return remaining
|
|
41
|
+
|
|
42
|
+
def consume(self, visits: int) -> None:
|
|
43
|
+
self.check()
|
|
44
|
+
self.visits += visits
|
|
45
|
+
if self.visits > 20_000:
|
|
46
|
+
raise AnalysisLimit()
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
CALL_BUDGET: ContextVar[CallBudget | None] = ContextVar("call_budget", default=None)
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def remaining_seconds(limit: float = 15.0) -> float:
|
|
53
|
+
scope = CALL_BUDGET.get()
|
|
54
|
+
return min(limit, scope.check()) if scope is not None else limit
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
@dataclass(frozen=True)
|
|
58
|
+
class Fact(Generic[T]):
|
|
59
|
+
state: Literal["known", "unknown", "notReported", "redacted"]
|
|
60
|
+
value: T | None
|
|
61
|
+
reason_code: str | None = None
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
@dataclass
|
|
65
|
+
class AnalysisBudget:
|
|
66
|
+
max_visits: int = 20_000
|
|
67
|
+
max_depth: int = 16
|
|
68
|
+
deadline_monotonic: float | None = None
|
|
69
|
+
monotonic: Callable[[], float] | None = None
|
|
70
|
+
consumed_visits: int = 0
|
|
71
|
+
|
|
72
|
+
def consume(self, visits: int = 1) -> None:
|
|
73
|
+
if visits < 0:
|
|
74
|
+
raise ValueError("INVALID_BUDGET_INCREMENT")
|
|
75
|
+
self.consumed_visits += visits
|
|
76
|
+
scope = CALL_BUDGET.get()
|
|
77
|
+
if scope is not None:
|
|
78
|
+
scope.consume(visits)
|
|
79
|
+
if self.consumed_visits > self.max_visits:
|
|
80
|
+
raise AnalysisLimit()
|
|
81
|
+
if (
|
|
82
|
+
self.deadline_monotonic is not None
|
|
83
|
+
and self.monotonic is not None
|
|
84
|
+
and self.monotonic() >= self.deadline_monotonic
|
|
85
|
+
):
|
|
86
|
+
raise AnalysisLimit("TARGET_TIMEOUT")
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
@dataclass(frozen=True)
|
|
90
|
+
class ParsedObservation:
|
|
91
|
+
target_id: str
|
|
92
|
+
snapshot_id: str
|
|
93
|
+
observation_id: str
|
|
94
|
+
observed_at: str
|
|
95
|
+
endpoint_id: str
|
|
96
|
+
body: Mapping[str, Any] | None
|
|
97
|
+
http_status: int | None = 200
|
|
98
|
+
error_code: str | None = None
|
|
99
|
+
representation: str = "actuatorV3"
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
"""Condition facts exclude the untrusted diagnostic message."""
|
|
2
|
+
|
|
3
|
+
from collections.abc import Mapping
|
|
4
|
+
from dataclasses import dataclass
|
|
5
|
+
from typing import Literal
|
|
6
|
+
|
|
7
|
+
from .bean_graph import ContextKey
|
|
8
|
+
from .common import Fact
|
|
9
|
+
|
|
10
|
+
Outcome = Literal["matched", "notMatched", "unknown"]
|
|
11
|
+
Search = Literal["CURRENT", "PARENTS", "ALL", "unknown"]
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
@dataclass(frozen=True)
|
|
15
|
+
class Condition:
|
|
16
|
+
id: str
|
|
17
|
+
context: ContextKey
|
|
18
|
+
source: str
|
|
19
|
+
condition_type: str | None
|
|
20
|
+
operator: Literal["leaf", "any", "all", "none", "unknown"]
|
|
21
|
+
reported_outcome: Outcome
|
|
22
|
+
derived_outcome: Outcome
|
|
23
|
+
search: Search
|
|
24
|
+
required_types: tuple[str, ...]
|
|
25
|
+
required_names: tuple[str, ...]
|
|
26
|
+
property_key: Fact[str]
|
|
27
|
+
child_ids: tuple[str, ...]
|
|
28
|
+
children_complete: bool
|
|
29
|
+
reason_code: str
|
|
30
|
+
evidence_ids: tuple[str, ...]
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
@dataclass(frozen=True)
|
|
34
|
+
class ConditionReport:
|
|
35
|
+
nodes: Mapping[str, Condition]
|
|
36
|
+
roots_by_source: Mapping[tuple[ContextKey, str], tuple[str, ...]]
|
|
37
|
+
coverage: Literal["complete", "partial", "unavailable"]
|
|
38
|
+
gaps: tuple[str, ...]
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
@dataclass(frozen=True)
|
|
42
|
+
class BlockingEdge:
|
|
43
|
+
from_id: str
|
|
44
|
+
to_id: str
|
|
45
|
+
reason_code: str
|
|
46
|
+
evidence_ids: tuple[str, ...]
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
@dataclass(frozen=True)
|
|
50
|
+
class FailureAnalysis:
|
|
51
|
+
state: Literal["notNeeded", "explained", "partial", "unknown"]
|
|
52
|
+
conditions: tuple[Condition, ...]
|
|
53
|
+
blocking_edges: tuple[BlockingEdge, ...]
|
|
54
|
+
blocking_paths: tuple[tuple[str, ...], ...]
|
|
55
|
+
truncated: bool
|
|
56
|
+
gaps: tuple[str, ...]
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
"""Validated immutable tool arguments."""
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass
|
|
4
|
+
from typing import Literal
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
@dataclass(frozen=True)
|
|
8
|
+
class InspectBeansQuery:
|
|
9
|
+
target_id: str = "local"
|
|
10
|
+
max_results: int = 50
|
|
11
|
+
cursor: str | None = None
|
|
12
|
+
bean_name_pattern: str | None = None
|
|
13
|
+
type_pattern: str | None = None
|
|
14
|
+
package_prefix: str | None = None
|
|
15
|
+
context_id: str | None = None
|
|
16
|
+
direction: Literal["dependencies", "dependents", "both"] = "both"
|
|
17
|
+
max_depth: int = 2
|
|
18
|
+
condition_depth: int = 4
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
@dataclass(frozen=True)
|
|
22
|
+
class HealthQuery:
|
|
23
|
+
target_id: str = "local"
|
|
24
|
+
max_results: int = 50
|
|
25
|
+
cursor: str | None = None
|
|
26
|
+
component_path: tuple[str, ...] = ()
|
|
27
|
+
group: Literal["application", "liveness", "readiness"] = "application"
|
|
28
|
+
include_details: bool = False
|
|
29
|
+
include_healthy: bool = False
|
|
30
|
+
max_depth: int = 4
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
@dataclass(frozen=True)
|
|
34
|
+
class MappingQuery:
|
|
35
|
+
target_id: str = "local"
|
|
36
|
+
max_results: int = 50
|
|
37
|
+
cursor: str | None = None
|
|
38
|
+
path_pattern: str | None = None
|
|
39
|
+
http_method: str | None = None
|
|
40
|
+
controller_class_pattern: str | None = None
|
|
41
|
+
context_id: str | None = None
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
@dataclass(frozen=True)
|
|
45
|
+
class ExpectedMigration:
|
|
46
|
+
engine: str
|
|
47
|
+
context_id: str
|
|
48
|
+
migration_bean: str
|
|
49
|
+
identity: str
|
|
50
|
+
version: str | None = None
|
|
51
|
+
checksum: str | None = None
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
@dataclass(frozen=True)
|
|
55
|
+
class StartupQuery:
|
|
56
|
+
target_id: str = "local"
|
|
57
|
+
max_results: int = 50
|
|
58
|
+
cursor: str | None = None
|
|
59
|
+
step_name_pattern: str | None = None
|
|
60
|
+
bean_name_pattern: str | None = None
|
|
61
|
+
min_duration_ms: float = 0
|
|
62
|
+
max_depth: int = 4
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
@dataclass(frozen=True)
|
|
66
|
+
class ConfigQuery:
|
|
67
|
+
target_id: str = "local"
|
|
68
|
+
max_results: int = 50
|
|
69
|
+
cursor: str | None = None
|
|
70
|
+
prefix: str | None = None
|
|
71
|
+
property_key_pattern: str | None = None
|
|
72
|
+
context_id: str | None = None
|
|
73
|
+
include_values: bool = False
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
@dataclass(frozen=True)
|
|
77
|
+
class OpenApiQuery:
|
|
78
|
+
target_id: str = "local"
|
|
79
|
+
max_results: int = 50
|
|
80
|
+
cursor: str | None = None
|
|
81
|
+
path_pattern: str | None = None
|
|
82
|
+
http_method: str | None = None
|
|
83
|
+
operation_id: str | None = None
|
|
84
|
+
schema_name: str | None = None
|
|
85
|
+
max_ref_depth: int = 2
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
@dataclass(frozen=True)
|
|
89
|
+
class MigrationQuery:
|
|
90
|
+
target_id: str = "local"
|
|
91
|
+
max_results: int = 50
|
|
92
|
+
cursor: str | None = None
|
|
93
|
+
engine: Literal["auto", "flyway", "liquibase"] = "auto"
|
|
94
|
+
context_id: str | None = None
|
|
95
|
+
migration_bean: str | None = None
|
|
96
|
+
states: tuple[str, ...] = ()
|
|
97
|
+
version: str | None = None
|
|
98
|
+
expected: tuple[ExpectedMigration, ...] = ()
|
|
99
|
+
expected_set_complete: bool = False
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
@dataclass(frozen=True)
|
|
103
|
+
class ExpectedBean:
|
|
104
|
+
name: str | None = None
|
|
105
|
+
type_name: str | None = None
|
|
106
|
+
context_id: str | None = None
|
|
107
|
+
aliases: tuple[str, ...] = ()
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
@dataclass(frozen=True)
|
|
111
|
+
class ExpectedRoute:
|
|
112
|
+
path: str
|
|
113
|
+
http_method: str
|
|
114
|
+
controller_class: str | None = None
|
|
115
|
+
context_id: str | None = None
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
@dataclass(frozen=True)
|
|
119
|
+
class ExpectedMigrationVersion:
|
|
120
|
+
engine: Literal["flyway", "liquibase"]
|
|
121
|
+
version: str
|
|
122
|
+
context_id: str | None = None
|
|
123
|
+
migration_bean: str | None = None
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
@dataclass(frozen=True)
|
|
127
|
+
class DriftQuery:
|
|
128
|
+
"""Omitted expectation fields stay None; empty tuples are explicit scopes."""
|
|
129
|
+
|
|
130
|
+
target_id: str = "local"
|
|
131
|
+
max_results: int = 50
|
|
132
|
+
cursor: str | None = None
|
|
133
|
+
expected_beans: tuple[ExpectedBean, ...] | None = None
|
|
134
|
+
expected_routes: tuple[ExpectedRoute, ...] | None = None
|
|
135
|
+
expected_migration: ExpectedMigrationVersion | None = None
|
|
136
|
+
expected_profiles: tuple[str, ...] | None = None
|
|
137
|
+
expected_scope_complete: bool = False
|
|
138
|
+
source_revision: str | None = None
|
|
139
|
+
source_modified_at: str | None = None
|
|
140
|
+
max_depth: int = 2
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
"""Explicit, target-bound workspace facts; never inferred from Bean names."""
|
|
2
|
+
|
|
3
|
+
from collections.abc import Mapping
|
|
4
|
+
from dataclasses import dataclass
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
@dataclass(frozen=True)
|
|
8
|
+
class TypeFact:
|
|
9
|
+
name: str
|
|
10
|
+
direct_supertypes: tuple[str, ...]
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
@dataclass(frozen=True)
|
|
14
|
+
class SourceProducer:
|
|
15
|
+
id: str
|
|
16
|
+
context_id: str
|
|
17
|
+
bean_name: str
|
|
18
|
+
aliases: tuple[str, ...]
|
|
19
|
+
declared_type: str
|
|
20
|
+
implementation_type: str | None
|
|
21
|
+
configuration_class: str
|
|
22
|
+
factory_method: str | None
|
|
23
|
+
condition_sources: tuple[str, ...]
|
|
24
|
+
source_file: str
|
|
25
|
+
source_line: int
|
|
26
|
+
evidence_ids: tuple[str, ...]
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
@dataclass(frozen=True)
|
|
30
|
+
class SourceCatalog:
|
|
31
|
+
target_id: str
|
|
32
|
+
source_revision: str | None
|
|
33
|
+
types: Mapping[str, TypeFact]
|
|
34
|
+
producers: tuple[SourceProducer, ...]
|
|
35
|
+
gaps: tuple[str, ...]
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
"""Bounded RFC3339 parsing shared by contracts and runtime evidence.
|
|
2
|
+
|
|
3
|
+
Keep fractional precision and a leap-second ordering slot. Duration arithmetic
|
|
4
|
+
involving an explicit leap second remains unknown without a trusted history.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import calendar
|
|
10
|
+
import re
|
|
11
|
+
from dataclasses import dataclass
|
|
12
|
+
from datetime import UTC, datetime, timedelta, timezone
|
|
13
|
+
from decimal import Decimal
|
|
14
|
+
|
|
15
|
+
_TIMESTAMP = re.compile(
|
|
16
|
+
r"([0-9]{4})-([0-9]{2})-([0-9]{2})[Tt]"
|
|
17
|
+
r"([0-9]{2}):([0-9]{2}):([0-9]{2})(\.[0-9]+)?"
|
|
18
|
+
r"([Zz]|[+-][0-9]{2}:[0-9]{2})"
|
|
19
|
+
)
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
@dataclass(frozen=True, order=True)
|
|
23
|
+
class Timestamp:
|
|
24
|
+
second: datetime
|
|
25
|
+
leap: bool
|
|
26
|
+
fraction: Decimal
|
|
27
|
+
|
|
28
|
+
def milliseconds_since(self, earlier: Timestamp) -> float | None:
|
|
29
|
+
if self.leap or earlier.leap:
|
|
30
|
+
return None
|
|
31
|
+
delta = self.second - earlier.second
|
|
32
|
+
seconds = Decimal(delta.days * 86400 + delta.seconds)
|
|
33
|
+
return float((seconds + self.fraction - earlier.fraction) * 1000)
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def parse_rfc3339(value: object) -> Timestamp | None:
|
|
37
|
+
if not isinstance(value, str) or len(value) > 64:
|
|
38
|
+
return None
|
|
39
|
+
match = _TIMESTAMP.fullmatch(value)
|
|
40
|
+
if match is None:
|
|
41
|
+
return None
|
|
42
|
+
year, month, day, hour, minute, second = map(int, match.groups()[:6])
|
|
43
|
+
offset = match[8]
|
|
44
|
+
try:
|
|
45
|
+
zone = UTC
|
|
46
|
+
if offset not in ("Z", "z"):
|
|
47
|
+
hours, minutes = int(offset[1:3]), int(offset[4:6])
|
|
48
|
+
if hours > 23 or minutes > 59:
|
|
49
|
+
return None
|
|
50
|
+
zone = timezone(
|
|
51
|
+
timedelta(
|
|
52
|
+
minutes=(hours * 60 + minutes) * (1 if offset[0] == "+" else -1)
|
|
53
|
+
)
|
|
54
|
+
)
|
|
55
|
+
if second > 60:
|
|
56
|
+
return None
|
|
57
|
+
instant = datetime(
|
|
58
|
+
year, month, day, hour, minute, min(second, 59), tzinfo=zone
|
|
59
|
+
).astimezone(UTC)
|
|
60
|
+
except (ValueError, OverflowError):
|
|
61
|
+
return None
|
|
62
|
+
leap = second == 60
|
|
63
|
+
# RFC3339 permits insertions at UTC month ends. Validate the placement;
|
|
64
|
+
# do not claim knowledge of a historical or future insertion schedule.
|
|
65
|
+
if leap and (
|
|
66
|
+
instant.hour != 23
|
|
67
|
+
or instant.minute != 59
|
|
68
|
+
or instant.day != calendar.monthrange(instant.year, instant.month)[1]
|
|
69
|
+
):
|
|
70
|
+
return None
|
|
71
|
+
return Timestamp(instant, leap, Decimal("0" + (match[7] or "")))
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Mandatory output safety controls."""
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
"""Immutable operator registration for config reads, never tool arguments."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import re
|
|
6
|
+
from dataclasses import dataclass
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
@dataclass(frozen=True)
|
|
10
|
+
class ConfigReadPolicy:
|
|
11
|
+
properties: frozenset[str] = frozenset()
|
|
12
|
+
prefixes: frozenset[str] = frozenset()
|
|
13
|
+
allow_values: bool = False
|
|
14
|
+
profiles: bool = False
|
|
15
|
+
|
|
16
|
+
def __post_init__(self) -> None:
|
|
17
|
+
for name in ("properties", "prefixes"):
|
|
18
|
+
values = frozenset(getattr(self, name))
|
|
19
|
+
if len(values) > 100 or any(
|
|
20
|
+
not isinstance(value, str)
|
|
21
|
+
or re.fullmatch(r"[A-Za-z_][A-Za-z0-9_.-]{0,255}", value) is None
|
|
22
|
+
for value in values
|
|
23
|
+
):
|
|
24
|
+
raise ValueError("INVALID_CONFIG_REGISTRATION")
|
|
25
|
+
object.__setattr__(self, name, values)
|
|
26
|
+
if type(self.allow_values) is not bool or type(self.profiles) is not bool:
|
|
27
|
+
raise ValueError("INVALID_CONFIG_REGISTRATION")
|
|
28
|
+
|
|
29
|
+
def allows(self, key: str) -> bool:
|
|
30
|
+
return key in self.properties or any(
|
|
31
|
+
key == prefix or key.startswith(prefix + ".") for prefix in self.prefixes
|
|
32
|
+
)
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
"""Independent rejection gate for cache and wire sinks; never repair raw JSON."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
from ..models.common import NormalizationError
|
|
8
|
+
from ..transport.bounded_json import loads_bounded, validate_tree
|
|
9
|
+
from .sanitization import (
|
|
10
|
+
MARKER,
|
|
11
|
+
OMITTED_TEXT_FIELDS,
|
|
12
|
+
SanitizerPipeline,
|
|
13
|
+
sensitive_key,
|
|
14
|
+
untrusted_text_descriptor,
|
|
15
|
+
)
|
|
16
|
+
|
|
17
|
+
DIAGNOSTIC_MESSAGE = "Diagnostic evidence is unavailable; review the registered target and access policy."
|
|
18
|
+
REVIEW_TARGET = (
|
|
19
|
+
"Review target registration, endpoint exposure and authorization locally."
|
|
20
|
+
)
|
|
21
|
+
REVIEW_AUTHORIZATION = "Review the existing Spring Security rule and diagnostic credentials; do not disable security or follow login redirects."
|
|
22
|
+
REVIEW_STARTUP = "Review startup endpoint exposure; if collection is absent, configure SpringApplication.setApplicationStartup(new BufferingApplicationStartup(2048)) before run; restart requires approval."
|
|
23
|
+
LOCAL_PROSE = frozenset(
|
|
24
|
+
{
|
|
25
|
+
DIAGNOSTIC_MESSAGE,
|
|
26
|
+
REVIEW_TARGET,
|
|
27
|
+
REVIEW_AUTHORIZATION,
|
|
28
|
+
REVIEW_STARTUP,
|
|
29
|
+
"registered target Bean contexts",
|
|
30
|
+
"registered target health",
|
|
31
|
+
"registered target mappings",
|
|
32
|
+
"registered target migrations",
|
|
33
|
+
"registered target startup",
|
|
34
|
+
"registered target config",
|
|
35
|
+
"registered target openapi",
|
|
36
|
+
}
|
|
37
|
+
)
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def ensure_safe_output(
|
|
41
|
+
value: Any, *, sanitizer: SanitizerPipeline | None = None
|
|
42
|
+
) -> None:
|
|
43
|
+
"""Reject recognizable secrets, raw prose and unsafe secondary JSON at sinks.
|
|
44
|
+
|
|
45
|
+
This complements typed projection. Opaque unknown secrets have no universal
|
|
46
|
+
recognizer; producers must still allowlist facts and register credentials.
|
|
47
|
+
"""
|
|
48
|
+
validate_tree(value, max_bytes=32 * 1024 * 1024)
|
|
49
|
+
pipeline = sanitizer or SanitizerPipeline()
|
|
50
|
+
pending = [(value, "", 0)]
|
|
51
|
+
while pending:
|
|
52
|
+
item, field, depth = pending.pop()
|
|
53
|
+
if depth > 64:
|
|
54
|
+
raise NormalizationError("UNSAFE_OUTPUT")
|
|
55
|
+
if isinstance(item, dict):
|
|
56
|
+
descriptor_tainted = untrusted_text_descriptor(item)
|
|
57
|
+
unsafe_descriptor = False
|
|
58
|
+
for key in ("name", "key", "propertyName", "propertyKey"):
|
|
59
|
+
name = item.get(key)
|
|
60
|
+
if isinstance(name, str) and pipeline.identifier(name) == MARKER:
|
|
61
|
+
descriptor_tainted = True
|
|
62
|
+
unsafe_descriptor |= sensitive_key(name) or pipeline.high_risk(name)
|
|
63
|
+
if descriptor_tainted and any(
|
|
64
|
+
item.get(key) not in (None, MARKER)
|
|
65
|
+
for key in ("value", "bindingValue", "effectiveValue", "inputs")
|
|
66
|
+
):
|
|
67
|
+
raise NormalizationError(
|
|
68
|
+
"UNSAFE_OUTPUT" if unsafe_descriptor else "UNTRUSTED_OUTPUT"
|
|
69
|
+
)
|
|
70
|
+
if (
|
|
71
|
+
descriptor_tainted
|
|
72
|
+
and isinstance(item.get("origin"), str)
|
|
73
|
+
and item["origin"] != MARKER
|
|
74
|
+
):
|
|
75
|
+
raise NormalizationError("UNTRUSTED_OUTPUT")
|
|
76
|
+
for key, child in item.items():
|
|
77
|
+
if pipeline.high_risk(key) or (
|
|
78
|
+
sensitive_key(key) and child not in (MARKER, None)
|
|
79
|
+
):
|
|
80
|
+
raise NormalizationError("UNSAFE_OUTPUT")
|
|
81
|
+
if field == "properties":
|
|
82
|
+
# Property names may coincide with omitted prose fields;
|
|
83
|
+
# only Schema objects are allowed, and their contents still
|
|
84
|
+
# pass every ordinary output check.
|
|
85
|
+
if not isinstance(child, dict):
|
|
86
|
+
raise NormalizationError("UNTRUSTED_OUTPUT")
|
|
87
|
+
pending.append((child, "", depth + 1))
|
|
88
|
+
continue
|
|
89
|
+
if key in OMITTED_TEXT_FIELDS and child not in (MARKER, None, [], {}):
|
|
90
|
+
raise NormalizationError("UNTRUSTED_OUTPUT")
|
|
91
|
+
pending.append((child, key, depth + 1))
|
|
92
|
+
elif isinstance(item, list):
|
|
93
|
+
pending.extend((child, field, depth + 1) for child in item)
|
|
94
|
+
elif isinstance(item, str) and item != MARKER:
|
|
95
|
+
if pipeline.high_risk(item):
|
|
96
|
+
raise NormalizationError("UNSAFE_OUTPUT")
|
|
97
|
+
if (
|
|
98
|
+
any(c.isspace() for c in item)
|
|
99
|
+
and item not in LOCAL_PROSE
|
|
100
|
+
and not item.lstrip().startswith(("{", "["))
|
|
101
|
+
):
|
|
102
|
+
raise NormalizationError("UNTRUSTED_OUTPUT")
|
|
103
|
+
if field in ("message", "steps") and item not in LOCAL_PROSE:
|
|
104
|
+
raise NormalizationError("UNTRUSTED_OUTPUT")
|
|
105
|
+
if field == "nextAction":
|
|
106
|
+
raise NormalizationError("UNTRUSTED_OUTPUT")
|
|
107
|
+
if item.lstrip().startswith(("{", "[")):
|
|
108
|
+
try:
|
|
109
|
+
nested = loads_bounded(
|
|
110
|
+
('{"value":' + item.strip() + "}").encode(),
|
|
111
|
+
max_bytes=65_550,
|
|
112
|
+
max_depth=32,
|
|
113
|
+
max_nodes=10_000,
|
|
114
|
+
)["value"]
|
|
115
|
+
except NormalizationError:
|
|
116
|
+
raise NormalizationError("UNSAFE_OUTPUT") from None
|
|
117
|
+
pending.append((nested, field, depth + 1))
|