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.
Files changed (118) hide show
  1. spring_runtime_mcp/__init__.py +3 -0
  2. spring_runtime_mcp/adapters/__init__.py +1 -0
  3. spring_runtime_mcp/adapters/beans.py +159 -0
  4. spring_runtime_mcp/adapters/conditions.py +333 -0
  5. spring_runtime_mcp/adapters/config.py +295 -0
  6. spring_runtime_mcp/adapters/drift.py +790 -0
  7. spring_runtime_mcp/adapters/health.py +164 -0
  8. spring_runtime_mcp/adapters/mapping_predicates.py +201 -0
  9. spring_runtime_mcp/adapters/mappings.py +294 -0
  10. spring_runtime_mcp/adapters/migrations.py +373 -0
  11. spring_runtime_mcp/adapters/openapi.py +683 -0
  12. spring_runtime_mcp/adapters/source_catalog.py +119 -0
  13. spring_runtime_mcp/adapters/startup.py +202 -0
  14. spring_runtime_mcp/analyzers/__init__.py +1 -0
  15. spring_runtime_mcp/analyzers/bean_graph.py +172 -0
  16. spring_runtime_mcp/analyzers/condition_failures.py +359 -0
  17. spring_runtime_mcp/cli.py +248 -0
  18. spring_runtime_mcp/contracts/__init__.py +1 -0
  19. spring_runtime_mcp/contracts/definitions.py +425 -0
  20. spring_runtime_mcp/diagnostics.py +254 -0
  21. spring_runtime_mcp/models/__init__.py +1 -0
  22. spring_runtime_mcp/models/bean_graph.py +77 -0
  23. spring_runtime_mcp/models/common.py +99 -0
  24. spring_runtime_mcp/models/conditions.py +56 -0
  25. spring_runtime_mcp/models/queries.py +140 -0
  26. spring_runtime_mcp/models/source_catalog.py +35 -0
  27. spring_runtime_mcp/models/timestamps.py +71 -0
  28. spring_runtime_mcp/policy/__init__.py +1 -0
  29. spring_runtime_mcp/policy/configuration.py +32 -0
  30. spring_runtime_mcp/policy/output_guard.py +117 -0
  31. spring_runtime_mcp/policy/sanitization.py +616 -0
  32. spring_runtime_mcp/py.typed +0 -0
  33. spring_runtime_mcp/replay.py +220 -0
  34. spring_runtime_mcp/replay_data/bean_graph/README.md +26 -0
  35. spring_runtime_mcp/replay_data/bean_graph/factory-method/conditions.json +43 -0
  36. spring_runtime_mcp/replay_data/bean_graph/factory-method/source-catalog.json +54 -0
  37. spring_runtime_mcp/replay_data/bean_graph/nested-conditions/beans.json +99 -0
  38. spring_runtime_mcp/replay_data/bean_graph/nested-conditions/conditions.json +25 -0
  39. spring_runtime_mcp/replay_data/bean_graph/parent-child/beans.json +92 -0
  40. spring_runtime_mcp/replay_data/bean_graph/shape-metadata.json +32 -0
  41. spring_runtime_mcp/replay_data/bean_graph/stripe-missing/conditions.json +43 -0
  42. spring_runtime_mcp/replay_data/bean_graph/stripe-missing/source-catalog.json +54 -0
  43. spring_runtime_mcp/replay_data/conformance/README.md +41 -0
  44. spring_runtime_mcp/replay_data/conformance/boot-2.7-servlet/beans.json +92 -0
  45. spring_runtime_mcp/replay_data/conformance/boot-2.7-servlet/conditions.json +52 -0
  46. spring_runtime_mcp/replay_data/conformance/boot-2.7-servlet/configprops.json +20 -0
  47. spring_runtime_mcp/replay_data/conformance/boot-2.7-servlet/env.json +16 -0
  48. spring_runtime_mcp/replay_data/conformance/boot-2.7-servlet/flyway.json +40 -0
  49. spring_runtime_mcp/replay_data/conformance/boot-2.7-servlet/health-v2.json +14 -0
  50. spring_runtime_mcp/replay_data/conformance/boot-2.7-servlet/health-v3.json +38 -0
  51. spring_runtime_mcp/replay_data/conformance/boot-2.7-servlet/liquibase.json +30 -0
  52. spring_runtime_mcp/replay_data/conformance/boot-2.7-servlet/mappings.json +40 -0
  53. spring_runtime_mcp/replay_data/conformance/boot-2.7-servlet/openapi.json +54 -0
  54. spring_runtime_mcp/replay_data/conformance/boot-2.7-servlet/source-catalog.json +90 -0
  55. spring_runtime_mcp/replay_data/conformance/boot-2.7-servlet/startup.json +45 -0
  56. spring_runtime_mcp/replay_data/conformance/boot-3.4-servlet/beans.json +92 -0
  57. spring_runtime_mcp/replay_data/conformance/boot-3.4-servlet/conditions.json +52 -0
  58. spring_runtime_mcp/replay_data/conformance/boot-3.4-servlet/configprops.json +20 -0
  59. spring_runtime_mcp/replay_data/conformance/boot-3.4-servlet/env.json +16 -0
  60. spring_runtime_mcp/replay_data/conformance/boot-3.4-servlet/flyway.json +40 -0
  61. spring_runtime_mcp/replay_data/conformance/boot-3.4-servlet/health-v3.json +38 -0
  62. spring_runtime_mcp/replay_data/conformance/boot-3.4-servlet/liquibase.json +30 -0
  63. spring_runtime_mcp/replay_data/conformance/boot-3.4-servlet/mappings.json +40 -0
  64. spring_runtime_mcp/replay_data/conformance/boot-3.4-servlet/openapi.json +54 -0
  65. spring_runtime_mcp/replay_data/conformance/boot-3.4-servlet/source-catalog.json +90 -0
  66. spring_runtime_mcp/replay_data/conformance/boot-3.4-servlet/startup.json +45 -0
  67. spring_runtime_mcp/replay_data/conformance/boot-4.1-webflux/beans.json +92 -0
  68. spring_runtime_mcp/replay_data/conformance/boot-4.1-webflux/conditions.json +52 -0
  69. spring_runtime_mcp/replay_data/conformance/boot-4.1-webflux/configprops.json +20 -0
  70. spring_runtime_mcp/replay_data/conformance/boot-4.1-webflux/env.json +16 -0
  71. spring_runtime_mcp/replay_data/conformance/boot-4.1-webflux/flyway.json +40 -0
  72. spring_runtime_mcp/replay_data/conformance/boot-4.1-webflux/health-v3.json +38 -0
  73. spring_runtime_mcp/replay_data/conformance/boot-4.1-webflux/liquibase.json +30 -0
  74. spring_runtime_mcp/replay_data/conformance/boot-4.1-webflux/mappings.json +40 -0
  75. spring_runtime_mcp/replay_data/conformance/boot-4.1-webflux/openapi.json +54 -0
  76. spring_runtime_mcp/replay_data/conformance/boot-4.1-webflux/servlet-namespace-comparison.json +4 -0
  77. spring_runtime_mcp/replay_data/conformance/boot-4.1-webflux/source-catalog.json +90 -0
  78. spring_runtime_mcp/replay_data/conformance/boot-4.1-webflux/startup.json +45 -0
  79. spring_runtime_mcp/replay_data/conformance/contracts/springboot_analyze_startup.json +62 -0
  80. spring_runtime_mcp/replay_data/conformance/contracts/springboot_check_migrations.json +63 -0
  81. spring_runtime_mcp/replay_data/conformance/contracts/springboot_detect_drift.json +64 -0
  82. spring_runtime_mcp/replay_data/conformance/contracts/springboot_diagnose_health.json +59 -0
  83. spring_runtime_mcp/replay_data/conformance/contracts/springboot_inspect_beans.json +56 -0
  84. spring_runtime_mcp/replay_data/conformance/contracts/springboot_inspect_config.json +62 -0
  85. spring_runtime_mcp/replay_data/conformance/contracts/springboot_search_openapi.json +80 -0
  86. spring_runtime_mcp/replay_data/conformance/contracts/springboot_verify_mappings.json +58 -0
  87. spring_runtime_mcp/replay_data/conformance/faults/cases.json +63 -0
  88. spring_runtime_mcp/replay_data/conformance/faults/health-hidden.expected.json +54 -0
  89. spring_runtime_mcp/replay_data/conformance/faults/health-hidden.json +3 -0
  90. spring_runtime_mcp/replay_data/conformance/large/beans-2500.json +22506 -0
  91. spring_runtime_mcp/replay_data/conformance/manifest.json +46 -0
  92. spring_runtime_mcp/schemas/tools-contract.json +1902 -0
  93. spring_runtime_mcp/server.py +322 -0
  94. spring_runtime_mcp/services.py +44 -0
  95. spring_runtime_mcp/snapshots.py +340 -0
  96. spring_runtime_mcp/tools/__init__.py +1 -0
  97. spring_runtime_mcp/tools/analyze_startup.py +79 -0
  98. spring_runtime_mcp/tools/check_migrations.py +99 -0
  99. spring_runtime_mcp/tools/detect_drift.py +323 -0
  100. spring_runtime_mcp/tools/diagnose_health.py +88 -0
  101. spring_runtime_mcp/tools/inspect_beans.py +479 -0
  102. spring_runtime_mcp/tools/inspect_config.py +102 -0
  103. spring_runtime_mcp/tools/registry.py +48 -0
  104. spring_runtime_mcp/tools/search_openapi.py +79 -0
  105. spring_runtime_mcp/tools/support.py +166 -0
  106. spring_runtime_mcp/tools/verify_mappings.py +62 -0
  107. spring_runtime_mcp/transport/__init__.py +1 -0
  108. spring_runtime_mcp/transport/actuator_http.py +459 -0
  109. spring_runtime_mcp/transport/bounded_json.py +144 -0
  110. spring_runtime_mcp/transport/replay_files.py +71 -0
  111. spring_runtime_mcp/transport/response_gate.py +186 -0
  112. spring_runtime_mcp/transport/responses.py +82 -0
  113. spring_runtime_mcp/transport/stdio.py +44 -0
  114. spring_runtime_mcp-0.2.0.dist-info/METADATA +320 -0
  115. spring_runtime_mcp-0.2.0.dist-info/RECORD +118 -0
  116. spring_runtime_mcp-0.2.0.dist-info/WHEEL +4 -0
  117. spring_runtime_mcp-0.2.0.dist-info/entry_points.txt +2 -0
  118. 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))