Initial curated ITWorx_MCP_Hub source from 368fba691ea8d23403b4d56b53b5ec670d7c5020
This commit is contained in:
commit
cb5be898ea
663 files changed
+82674
No files matched your search
@@ -0,0 +1,4 @@
|
||||
# Packages
|
||||
|
||||
Reserved for shared contracts, domain, policy, connector-kit, and test-kit
|
||||
packages. Dependency direction is checked by `scripts/check_boundaries.py`.
|
||||
@@ -0,0 +1 @@
|
||||
"""Shared Python packages for the ITWorx MCP Hub."""
|
||||
@@ -0,0 +1,105 @@
|
||||
"""Shared connector SDK primitives."""
|
||||
|
||||
from .contracts import (
|
||||
ConnectorContext,
|
||||
HealthSnapshot,
|
||||
PageRequest,
|
||||
ResourcePublication,
|
||||
ResultEnvelope,
|
||||
ResultMeta,
|
||||
ToolPublication,
|
||||
)
|
||||
from .gitea import (
|
||||
FileTokenProvider,
|
||||
GiteaError,
|
||||
GiteaRepositoryRegistry,
|
||||
GiteaSettings,
|
||||
RegisteredRepository,
|
||||
StaticTokenProvider,
|
||||
decode_file_payload,
|
||||
)
|
||||
from .gitea_resilient import GiteaClient
|
||||
from .mobilityops import (
|
||||
DEFAULT_KNOWLEDGE_LOCALE,
|
||||
KNOWLEDGE_LOCALES,
|
||||
MobilityOpsClient,
|
||||
MobilityOpsError,
|
||||
MobilityOpsSettings,
|
||||
)
|
||||
from .mobilityops import FileTokenProvider as MobilityOpsFileTokenProvider
|
||||
from .mobilityops import StaticTokenProvider as MobilityOpsStaticTokenProvider
|
||||
from .pagination import Cursor, InvalidCursor, decode_cursor, encode_cursor
|
||||
from .redaction import (
|
||||
is_secret_like,
|
||||
normalize_text,
|
||||
redact_sensitive_text,
|
||||
safe_content,
|
||||
safe_snippet,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
"ConnectorContext",
|
||||
"SourceType",
|
||||
"SourceStatus",
|
||||
"SearchPage",
|
||||
"SearchHit",
|
||||
"RegisteredSource",
|
||||
"PathEscapeError",
|
||||
"ParserRegistry",
|
||||
"KnowledgeRefreshJob",
|
||||
"InMemoryKnowledgeIndex",
|
||||
"IndexStatus",
|
||||
"IndexReport",
|
||||
"DocumentRecord",
|
||||
"DocumentNotFound",
|
||||
"DeterministicChunker",
|
||||
"ChunkRecord",
|
||||
"Cursor",
|
||||
"HealthSnapshot",
|
||||
"InvalidCursor",
|
||||
"PageRequest",
|
||||
"ResourcePublication",
|
||||
"ResultEnvelope",
|
||||
"ResultMeta",
|
||||
"ToolPublication",
|
||||
"decode_cursor",
|
||||
"encode_cursor",
|
||||
"is_secret_like",
|
||||
"normalize_text",
|
||||
"redact_sensitive_text",
|
||||
"safe_content",
|
||||
"safe_snippet",
|
||||
"FileTokenProvider",
|
||||
"GiteaClient",
|
||||
"GiteaError",
|
||||
"GiteaRepositoryRegistry",
|
||||
"GiteaSettings",
|
||||
"RegisteredRepository",
|
||||
"StaticTokenProvider",
|
||||
"decode_file_payload",
|
||||
"MobilityOpsClient",
|
||||
"MobilityOpsError",
|
||||
"MobilityOpsFileTokenProvider",
|
||||
"MobilityOpsSettings",
|
||||
"MobilityOpsStaticTokenProvider",
|
||||
"KNOWLEDGE_LOCALES",
|
||||
"DEFAULT_KNOWLEDGE_LOCALE",
|
||||
]
|
||||
|
||||
from .knowledge import (
|
||||
ChunkRecord,
|
||||
DeterministicChunker,
|
||||
DocumentNotFound,
|
||||
DocumentRecord,
|
||||
IndexReport,
|
||||
IndexStatus,
|
||||
InMemoryKnowledgeIndex,
|
||||
KnowledgeRefreshJob,
|
||||
ParserRegistry,
|
||||
PathEscapeError,
|
||||
RegisteredSource,
|
||||
SearchHit,
|
||||
SearchPage,
|
||||
SourceStatus,
|
||||
SourceType,
|
||||
)
|
||||
@@ -0,0 +1,80 @@
|
||||
"""Shared bounded connector contracts used by all first-party connectors."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import UTC, datetime
|
||||
from typing import Any
|
||||
from uuid import UUID
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, Field
|
||||
|
||||
|
||||
class ConnectorContext(BaseModel):
|
||||
"""Validated invocation scope supplied by the Hub, never by target content."""
|
||||
|
||||
model_config = ConfigDict(extra="forbid", frozen=True)
|
||||
|
||||
workspace_id: UUID
|
||||
project_id: UUID
|
||||
project_slug: str = Field(pattern=r"^[a-z0-9][a-z0-9-]{0,62}$")
|
||||
environment: str = Field(pattern=r"^[a-z0-9][a-z0-9-]{0,31}$")
|
||||
client_id: UUID | None = None
|
||||
correlation_id: str | None = Field(default=None, pattern=r"^[A-Za-z0-9._:-]{1,128}$")
|
||||
|
||||
|
||||
class PageRequest(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid", frozen=True)
|
||||
|
||||
limit: int = Field(default=20, ge=1, le=100)
|
||||
cursor: str | None = Field(default=None, max_length=500)
|
||||
|
||||
|
||||
class ResultMeta(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid", frozen=True)
|
||||
|
||||
project: str
|
||||
environment: str
|
||||
source: str
|
||||
truncated: bool = False
|
||||
next_cursor: str | None = None
|
||||
generated_at: datetime = Field(default_factory=lambda: datetime.now(UTC))
|
||||
|
||||
|
||||
class ResultEnvelope[T](BaseModel):
|
||||
model_config = ConfigDict(extra="forbid", frozen=True)
|
||||
|
||||
data: T
|
||||
meta: ResultMeta
|
||||
warnings: tuple[str, ...] = ()
|
||||
|
||||
|
||||
class HealthSnapshot(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid", frozen=True)
|
||||
|
||||
status: str = Field(pattern=r"^(healthy|ready|degraded|failed)$")
|
||||
component: str = Field(min_length=1, max_length=100)
|
||||
details: dict[str, Any] = Field(default_factory=dict)
|
||||
|
||||
|
||||
class ToolPublication(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid", frozen=True)
|
||||
|
||||
canonical_name: str = Field(pattern=r"^[a-z0-9]+(?:\.[a-z0-9-]+){2,}$")
|
||||
version: str = Field(pattern=r"^\d+\.\d+\.\d+$")
|
||||
connector_type: str = Field(min_length=1, max_length=64)
|
||||
read_only: bool = True
|
||||
risk_class: str = Field(pattern=r"^R[0-9]+$")
|
||||
input_schema: dict[str, Any]
|
||||
output_schema: dict[str, Any]
|
||||
required_scopes: tuple[str, ...] = ()
|
||||
|
||||
|
||||
class ResourcePublication(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid", frozen=True)
|
||||
|
||||
uri_template: str = Field(min_length=1, max_length=2048)
|
||||
connector_type: str = Field(min_length=1, max_length=64)
|
||||
risk_class: str = Field(pattern=r"^R[0-9]+$")
|
||||
mime_types: tuple[str, ...] = ()
|
||||
max_bytes: int = Field(ge=1, le=4 * 1024 * 1024)
|
||||
required_scopes: tuple[str, ...] = ()
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,114 @@
|
||||
"""Compatibility hardening for Gitea validation discovery and request latency.
|
||||
|
||||
Some Gitea versions expose pull-request check results as commit statuses while
|
||||
returning an empty branch-filtered Actions run wrapper. The base client treats
|
||||
collections strictly; this subclass preserves that strictness while translating
|
||||
a fully-green exact-head status set into one synthetic completed validation run.
|
||||
|
||||
Large repositories can require longer than the historical 15-second client
|
||||
budget to commit files, apply changesets, or materialize a server-side squash
|
||||
merge. The resilient client therefore uses the already allowlisted 60-second
|
||||
maximum from ``GiteaSettings``. All HTTP method allowlisting, redirect handling,
|
||||
response budgets, access checks and non-forcing merge behavior remain in the
|
||||
base client and are not duplicated here.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
import httpx
|
||||
|
||||
from .gitea import GiteaClient as BaseGiteaClient
|
||||
from .gitea import GiteaError, GiteaSettings, RegisteredRepository, TokenProvider
|
||||
|
||||
GITEA_REQUEST_TIMEOUT_SECONDS = 60.0
|
||||
|
||||
|
||||
class GiteaClient(BaseGiteaClient):
|
||||
"""Gitea client with exact-head validation fallback and bounded latency."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
settings: GiteaSettings,
|
||||
token_provider: TokenProvider,
|
||||
*,
|
||||
transport: httpx.BaseTransport | None = None,
|
||||
) -> None:
|
||||
resilient_settings = settings.model_copy(
|
||||
update={"timeout_seconds": GITEA_REQUEST_TIMEOUT_SECONDS}
|
||||
)
|
||||
super().__init__(resilient_settings, token_provider, transport=transport)
|
||||
|
||||
def action_runs(
|
||||
self,
|
||||
repository: RegisteredRepository,
|
||||
*,
|
||||
branch: str,
|
||||
page: int,
|
||||
limit: int,
|
||||
) -> list[dict[str, Any]]:
|
||||
payload = self._request(
|
||||
self._path("repos", repository.owner, repository.name, "actions", "runs"),
|
||||
{"branch": branch, "page": page, "limit": limit},
|
||||
)
|
||||
if isinstance(payload, dict):
|
||||
if "workflow_runs" in payload:
|
||||
payload = payload.get("workflow_runs")
|
||||
elif "runs" in payload:
|
||||
payload = payload.get("runs")
|
||||
else:
|
||||
payload = None
|
||||
runs = self._as_list(payload)
|
||||
if runs:
|
||||
return runs
|
||||
|
||||
# Only an actually empty Actions result can fall back. Malformed
|
||||
# collections still fail above, preserving strict response validation.
|
||||
try:
|
||||
branch_payload = self.branch(repository, branch)
|
||||
except GiteaError:
|
||||
return []
|
||||
commit = branch_payload.get("commit")
|
||||
if not isinstance(commit, dict):
|
||||
return []
|
||||
head_sha = str(commit.get("id") or commit.get("sha") or "")
|
||||
if len(head_sha) != 40 or any(
|
||||
character not in "0123456789abcdefABCDEF" for character in head_sha
|
||||
):
|
||||
return []
|
||||
|
||||
statuses = self.commit_statuses(repository, head_sha, 1, 100)
|
||||
if not statuses:
|
||||
return []
|
||||
latest_by_context: dict[str, dict[str, Any]] = {}
|
||||
for item in statuses:
|
||||
status_context = str(item.get("context") or "__default__")
|
||||
previous = latest_by_context.get(status_context)
|
||||
if previous is None or str(item.get("created_at") or "") > str(
|
||||
previous.get("created_at") or ""
|
||||
):
|
||||
latest_by_context[status_context] = item
|
||||
latest_states = {
|
||||
str(item.get("status") or "").casefold() for item in latest_by_context.values()
|
||||
}
|
||||
# Gitea can omit ``head_branch`` for pull_request runs, causing the
|
||||
# branch-filtered run list above to be empty even though the exact-head
|
||||
# commit statuses are available. Skipped jobs are neutral evidence;
|
||||
# require at least one success and reject every non-terminal state.
|
||||
if "success" not in latest_states or not latest_states <= {"success", "skipped"}:
|
||||
return []
|
||||
|
||||
return [
|
||||
{
|
||||
"id": 0,
|
||||
"name": "Exact-head commit status validation",
|
||||
"status": "completed",
|
||||
"conclusion": "success",
|
||||
"event": "commit_status_fallback",
|
||||
"head_branch": branch,
|
||||
"head_sha": head_sha,
|
||||
"html_url": "",
|
||||
"validation_evidence": "commit-statuses",
|
||||
}
|
||||
]
|
||||
@@ -0,0 +1,580 @@
|
||||
"""Registered-root project knowledge index with deterministic lexical search."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import mimetypes
|
||||
import re
|
||||
from collections.abc import Iterable
|
||||
from datetime import UTC, datetime
|
||||
from enum import StrEnum
|
||||
from pathlib import Path
|
||||
from typing import Literal
|
||||
from urllib.parse import unquote, urlsplit
|
||||
from uuid import UUID
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, Field, model_validator
|
||||
|
||||
from .pagination import decode_cursor, encode_cursor
|
||||
from .redaction import safe_content, safe_snippet
|
||||
|
||||
|
||||
class SourceType(StrEnum):
|
||||
DOCUMENTATION = "documentation"
|
||||
ADR = "adr"
|
||||
RUNBOOK = "runbook"
|
||||
README = "readme"
|
||||
CURRENT_STATE = "current-state"
|
||||
RELEASE_NOTE = "release-note"
|
||||
|
||||
|
||||
class PathEscapeError(ValueError):
|
||||
"""A registered root or symlink would escape its configured containment."""
|
||||
|
||||
|
||||
class DocumentNotFound(LookupError):
|
||||
"""The requested source-backed document is not indexed for this project."""
|
||||
|
||||
|
||||
class RegisteredSource(BaseModel):
|
||||
"""A control-plane registered root; MCP callers never provide this path."""
|
||||
|
||||
model_config = ConfigDict(extra="forbid", frozen=True, arbitrary_types_allowed=True)
|
||||
|
||||
source_id: UUID
|
||||
project_id: UUID
|
||||
project_slug: str = Field(pattern=r"^[a-z0-9][a-z0-9-]{0,62}$")
|
||||
source_ref: str = Field(pattern=r"^[a-z0-9][a-z0-9-]{0,127}$")
|
||||
source_type: SourceType
|
||||
root: Path
|
||||
repository_ref: str | None = Field(default=None, max_length=200)
|
||||
repository_commit: str | None = Field(default=None, max_length=200)
|
||||
allowed_extensions: frozenset[str] = frozenset(
|
||||
{".md", ".markdown", ".txt", ".rst", ".adoc", ".json", ".yaml", ".yml"}
|
||||
)
|
||||
excluded_dirs: frozenset[str] = frozenset(
|
||||
{
|
||||
".git",
|
||||
".hg",
|
||||
".svn",
|
||||
"node_modules",
|
||||
"dist",
|
||||
"build",
|
||||
"coverage",
|
||||
"__pycache__",
|
||||
".venv",
|
||||
"venv",
|
||||
"backups",
|
||||
"secrets",
|
||||
}
|
||||
)
|
||||
max_file_bytes: int = Field(default=1_048_576, ge=1024, le=4 * 1024 * 1024)
|
||||
enabled: bool = True
|
||||
revision: str = Field(default="registered", max_length=200)
|
||||
|
||||
@model_validator(mode="after")
|
||||
def validate_root(self) -> RegisteredSource:
|
||||
if not self.root.is_absolute():
|
||||
raise ValueError("registered source roots must be absolute")
|
||||
if any(not extension.startswith(".") for extension in self.allowed_extensions):
|
||||
raise ValueError("allowed extensions must start with a dot")
|
||||
return self
|
||||
|
||||
@property
|
||||
def uri_prefix(self) -> str:
|
||||
return f"itworx-docs://{self.project_slug}/{self.source_ref}"
|
||||
|
||||
def canonical_path(self, relative_path: str | Path) -> Path:
|
||||
relative = Path(relative_path)
|
||||
if relative.is_absolute() or ".." in relative.parts:
|
||||
raise PathEscapeError("source path must remain relative to the registered root")
|
||||
root = self.root.resolve(strict=True)
|
||||
candidate = (root / relative).resolve(strict=False)
|
||||
if candidate != root and root not in candidate.parents:
|
||||
raise PathEscapeError("source path escapes the registered root")
|
||||
return candidate
|
||||
|
||||
def allows(self, relative_path: Path) -> bool:
|
||||
if not relative_path.parts or any(
|
||||
part in self.excluded_dirs for part in relative_path.parts
|
||||
):
|
||||
return False
|
||||
lowered = relative_path.name.lower()
|
||||
if lowered in {
|
||||
".env",
|
||||
".env.local",
|
||||
".env.production",
|
||||
"id_rsa",
|
||||
"id_ed25519",
|
||||
"credentials.json",
|
||||
"secrets.yaml",
|
||||
}:
|
||||
return False
|
||||
if any(token in lowered for token in ("secret", "password", "token", "private-key")):
|
||||
return False
|
||||
return relative_path.suffix.lower() in self.allowed_extensions
|
||||
|
||||
|
||||
class ChunkRecord(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid", frozen=True)
|
||||
|
||||
index: int = Field(ge=0)
|
||||
text: str = Field(min_length=1, max_length=200_000)
|
||||
content_hash: str = Field(pattern=r"^[0-9a-f]{64}$")
|
||||
|
||||
|
||||
class DocumentRecord(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid", frozen=True)
|
||||
|
||||
document_id: str = Field(pattern=r"^[0-9a-f]{64}$")
|
||||
source_id: UUID
|
||||
project_id: UUID
|
||||
project_slug: str
|
||||
source_ref: str
|
||||
source_type: SourceType
|
||||
external_ref: str = Field(min_length=1, max_length=1024)
|
||||
title: str = Field(min_length=1, max_length=500)
|
||||
uri: str = Field(min_length=1, max_length=2048)
|
||||
mime_type: str = Field(min_length=1, max_length=100)
|
||||
content_hash: str = Field(pattern=r"^[0-9a-f]{64}$")
|
||||
repository_ref: str | None = None
|
||||
repository_commit: str | None = None
|
||||
source_revision: str = Field(max_length=200)
|
||||
modified_at: datetime
|
||||
content_bytes: int = Field(ge=0, le=4 * 1024 * 1024)
|
||||
version: int = Field(ge=1)
|
||||
state: Literal["registered", "deleted"] = "registered"
|
||||
content: str = ""
|
||||
chunks: tuple[ChunkRecord, ...] = ()
|
||||
|
||||
|
||||
class SearchHit(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid", frozen=True)
|
||||
|
||||
uri: str
|
||||
title: str
|
||||
path: str
|
||||
source_type: SourceType
|
||||
source_revision: str
|
||||
repository_ref: str | None = None
|
||||
repository_commit: str | None = None
|
||||
content_hash: str
|
||||
modified_at: datetime
|
||||
snippet: str
|
||||
score: float = Field(ge=0)
|
||||
truncated: bool = False
|
||||
|
||||
|
||||
class SearchPage(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid", frozen=True)
|
||||
|
||||
items: tuple[SearchHit, ...]
|
||||
next_cursor: str | None
|
||||
truncated: bool
|
||||
|
||||
|
||||
class SourceStatus(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid", frozen=True)
|
||||
|
||||
source_ref: str
|
||||
source_type: SourceType
|
||||
uri: str
|
||||
status: Literal["ready", "indexing", "stale", "failed", "empty", "disabled"]
|
||||
source_revision: str | None
|
||||
last_indexed_at: datetime | None
|
||||
document_count: int = Field(ge=0)
|
||||
|
||||
|
||||
class IndexStatus(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid", frozen=True)
|
||||
|
||||
status: Literal["ready", "indexing", "stale", "failed", "empty"]
|
||||
source_count: int = Field(ge=0)
|
||||
document_count: int = Field(ge=0)
|
||||
last_completed_at: datetime | None
|
||||
last_error: str | None = Field(default=None, max_length=1000)
|
||||
|
||||
|
||||
class IndexReport(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid", frozen=True)
|
||||
|
||||
source_ref: str
|
||||
indexed: int = Field(ge=0)
|
||||
skipped: int = Field(ge=0)
|
||||
tombstoned: int = Field(ge=0)
|
||||
completed_at: datetime
|
||||
|
||||
|
||||
class ParserRegistry:
|
||||
def __init__(self) -> None:
|
||||
self._extensions: dict[str, str] = {}
|
||||
|
||||
def register(self, extension: str, parser_name: str) -> None:
|
||||
normalized = extension.lower()
|
||||
if not normalized.startswith(".") or not parser_name:
|
||||
raise ValueError("parser registration is invalid")
|
||||
self._extensions[normalized] = parser_name
|
||||
|
||||
def supports(self, path: Path) -> bool:
|
||||
return path.suffix.lower() in self._extensions
|
||||
|
||||
def title(self, path: Path, content: str) -> str:
|
||||
for line in content.splitlines()[:20]:
|
||||
match = re.match(r"^#{1,6}\s+(.+?)\s*#*$", line.strip())
|
||||
if match:
|
||||
return match.group(1)[:500]
|
||||
return path.stem.replace("_", " ").replace("-", " ").strip()[:500] or path.name
|
||||
|
||||
@classmethod
|
||||
def default(cls) -> ParserRegistry:
|
||||
registry = cls()
|
||||
for extension in (".md", ".markdown", ".txt", ".rst", ".adoc", ".json", ".yaml", ".yml"):
|
||||
registry.register(extension, "plain-text")
|
||||
return registry
|
||||
|
||||
|
||||
class DeterministicChunker:
|
||||
def __init__(self, *, max_chars: int = 4000, overlap_chars: int = 200) -> None:
|
||||
if not 256 <= max_chars <= 20_000 or not 0 <= overlap_chars < max_chars // 2:
|
||||
raise ValueError("chunk bounds are invalid")
|
||||
self.max_chars = max_chars
|
||||
self.overlap_chars = overlap_chars
|
||||
|
||||
def chunk(self, content: str) -> tuple[ChunkRecord, ...]:
|
||||
if not content:
|
||||
return ()
|
||||
chunks: list[ChunkRecord] = []
|
||||
start = 0
|
||||
index = 0
|
||||
while start < len(content):
|
||||
end = min(len(content), start + self.max_chars)
|
||||
if end < len(content):
|
||||
boundary = content.rfind("\n", start + self.max_chars // 2, end)
|
||||
if boundary > start:
|
||||
end = boundary
|
||||
text = content[start:end].strip()
|
||||
if text:
|
||||
chunks.append(
|
||||
ChunkRecord(
|
||||
index=index,
|
||||
text=text,
|
||||
content_hash=hashlib.sha256(text.encode("utf-8")).hexdigest(),
|
||||
)
|
||||
)
|
||||
index += 1
|
||||
if end >= len(content):
|
||||
break
|
||||
start = max(end - self.overlap_chars, start + 1)
|
||||
return tuple(chunks)
|
||||
|
||||
|
||||
def _document_key(source: RegisteredSource, relative: str) -> str:
|
||||
return hashlib.sha256(f"{source.source_id}:{relative}".encode()).hexdigest()
|
||||
|
||||
|
||||
def _content_hash(content: str) -> str:
|
||||
return hashlib.sha256(content.encode("utf-8")).hexdigest()
|
||||
|
||||
|
||||
def _score_text(query: str, title: str, content: str) -> float:
|
||||
terms = [term for term in re.findall(r"[\w-]{2,}", query.casefold())]
|
||||
if not terms:
|
||||
return 0.0
|
||||
folded_title = title.casefold()
|
||||
folded_content = content.casefold()
|
||||
score = sum(folded_title.count(term) * 4 for term in terms)
|
||||
score += sum(folded_content.count(term) for term in terms)
|
||||
if query.casefold() in folded_content:
|
||||
score += 2
|
||||
return float(score)
|
||||
|
||||
|
||||
class InMemoryKnowledgeIndex:
|
||||
"""Deterministic index used by the connector and as a fake target for tests."""
|
||||
|
||||
def __init__(
|
||||
self, *, parser: ParserRegistry | None = None, chunker: DeterministicChunker | None = None
|
||||
) -> None:
|
||||
self.parser = parser or ParserRegistry.default()
|
||||
self.chunker = chunker or DeterministicChunker()
|
||||
self.sources: dict[UUID, RegisteredSource] = {}
|
||||
self.documents: dict[str, DocumentRecord] = {}
|
||||
self._last_completed_at: datetime | None = None
|
||||
self._last_error: str | None = None
|
||||
self._indexing = False
|
||||
|
||||
def register_source(self, source: RegisteredSource) -> None:
|
||||
if not source.enabled:
|
||||
self.sources[source.source_id] = source
|
||||
return
|
||||
canonical_root = source.root.resolve(strict=True)
|
||||
if not canonical_root.is_dir():
|
||||
raise ValueError("registered source root must be a directory")
|
||||
self.sources[source.source_id] = source.model_copy(update={"root": canonical_root})
|
||||
|
||||
def index_source(self, source_id: UUID) -> IndexReport:
|
||||
source = self.sources.get(source_id)
|
||||
if source is None:
|
||||
raise KeyError("source is not registered")
|
||||
if not source.enabled:
|
||||
return IndexReport(
|
||||
source_ref=source.source_ref,
|
||||
indexed=0,
|
||||
skipped=0,
|
||||
tombstoned=0,
|
||||
completed_at=datetime.now(UTC),
|
||||
)
|
||||
self._indexing = True
|
||||
self._last_error = None
|
||||
previous = {
|
||||
doc.external_ref: doc
|
||||
for doc in self.documents.values()
|
||||
if doc.source_id == source.source_id and doc.state == "registered"
|
||||
}
|
||||
seen: set[str] = set()
|
||||
indexed = 0
|
||||
skipped = 0
|
||||
try:
|
||||
for path in self._iter_files(source):
|
||||
relative = path.relative_to(source.root.resolve(strict=True)).as_posix()
|
||||
seen.add(relative)
|
||||
try:
|
||||
text = path.read_text(encoding="utf-8")
|
||||
if len(text.encode("utf-8")) > source.max_file_bytes:
|
||||
skipped += 1
|
||||
continue
|
||||
parsed = safe_content(text)
|
||||
if parsed is None or not self.parser.supports(path):
|
||||
skipped += 1
|
||||
continue
|
||||
key = _document_key(source, relative)
|
||||
old = previous.get(relative)
|
||||
content_hash = _content_hash(parsed)
|
||||
if old is not None and old.content_hash == content_hash:
|
||||
continue
|
||||
modified = datetime.fromtimestamp(path.stat().st_mtime, tz=UTC)
|
||||
document = DocumentRecord(
|
||||
document_id=key,
|
||||
source_id=source.source_id,
|
||||
project_id=source.project_id,
|
||||
project_slug=source.project_slug,
|
||||
source_ref=source.source_ref,
|
||||
source_type=source.source_type,
|
||||
external_ref=relative,
|
||||
title=self.parser.title(path, parsed),
|
||||
uri=f"{source.uri_prefix}/{key}",
|
||||
mime_type=mimetypes.guess_type(path.name)[0] or "text/plain",
|
||||
content_hash=content_hash,
|
||||
repository_ref=source.repository_ref,
|
||||
repository_commit=source.repository_commit,
|
||||
source_revision=source.revision,
|
||||
modified_at=modified,
|
||||
content_bytes=len(parsed.encode("utf-8")),
|
||||
version=(old.version + 1 if old is not None else 1),
|
||||
content=parsed,
|
||||
chunks=self.chunker.chunk(parsed),
|
||||
)
|
||||
self.documents[key] = document
|
||||
indexed += 1
|
||||
except (OSError, UnicodeDecodeError):
|
||||
skipped += 1
|
||||
tombstoned = 0
|
||||
for relative, old in previous.items():
|
||||
if relative not in seen:
|
||||
self.documents[old.document_id] = old.model_copy(
|
||||
update={
|
||||
"state": "deleted",
|
||||
"content": "",
|
||||
"chunks": (),
|
||||
"content_bytes": 0,
|
||||
"version": old.version + 1,
|
||||
}
|
||||
)
|
||||
tombstoned += 1
|
||||
self._last_completed_at = datetime.now(UTC)
|
||||
return IndexReport(
|
||||
source_ref=source.source_ref,
|
||||
indexed=indexed,
|
||||
skipped=skipped,
|
||||
tombstoned=tombstoned,
|
||||
completed_at=self._last_completed_at,
|
||||
)
|
||||
except Exception as exc:
|
||||
self._last_error = type(exc).__name__
|
||||
raise
|
||||
finally:
|
||||
self._indexing = False
|
||||
|
||||
def _iter_files(self, source: RegisteredSource) -> Iterable[Path]:
|
||||
root = source.root.resolve(strict=True)
|
||||
for path in sorted(root.rglob("*"), key=lambda item: item.as_posix().casefold()):
|
||||
if not path.is_file():
|
||||
continue
|
||||
relative = path.relative_to(root)
|
||||
if not source.allows(relative):
|
||||
continue
|
||||
canonical = source.canonical_path(relative)
|
||||
if canonical != path.resolve(strict=False) and not path.is_symlink():
|
||||
continue
|
||||
if not canonical.is_file():
|
||||
continue
|
||||
yield canonical
|
||||
|
||||
def get_document(self, project_id: UUID, uri: str) -> DocumentRecord:
|
||||
parts = urlsplit(uri)
|
||||
if parts.scheme != "itworx-docs" or parts.query or parts.fragment:
|
||||
raise DocumentNotFound("document URI is invalid")
|
||||
project = unquote(parts.netloc)
|
||||
path_parts = [unquote(part) for part in parts.path.split("/") if part]
|
||||
if len(path_parts) != 2 or any(
|
||||
part in {".", ".."} or "/" in part or "\\" in part for part in path_parts
|
||||
):
|
||||
raise DocumentNotFound("document URI is invalid")
|
||||
if project != next(
|
||||
(
|
||||
source.project_slug
|
||||
for source in self.sources.values()
|
||||
if source.project_id == project_id
|
||||
),
|
||||
"",
|
||||
):
|
||||
raise DocumentNotFound("document is outside the caller project")
|
||||
document = self.documents.get(path_parts[1])
|
||||
if (
|
||||
document is None
|
||||
or document.project_id != project_id
|
||||
or document.state != "registered"
|
||||
or document.source_ref != path_parts[0]
|
||||
):
|
||||
raise DocumentNotFound("document is not indexed")
|
||||
return document
|
||||
|
||||
def search(
|
||||
self,
|
||||
project_id: UUID,
|
||||
query: str,
|
||||
*,
|
||||
source_types: Iterable[SourceType] = (),
|
||||
limit: int = 20,
|
||||
cursor: str | None = None,
|
||||
) -> SearchPage:
|
||||
if not query.strip() or len(query) > 1000:
|
||||
raise ValueError("query must be between 1 and 1000 characters")
|
||||
if not 1 <= limit <= 20:
|
||||
raise ValueError("limit must be between 1 and 20")
|
||||
selected = tuple(sorted(str(value) for value in source_types))
|
||||
page_cursor = decode_cursor(cursor, str(project_id), query, *selected)
|
||||
hits: list[SearchHit] = []
|
||||
for document in self.documents.values():
|
||||
if (
|
||||
document.project_id != project_id
|
||||
or document.state != "registered"
|
||||
or (selected and str(document.source_type) not in selected)
|
||||
):
|
||||
continue
|
||||
for chunk in document.chunks:
|
||||
score = _score_text(query, document.title, chunk.text)
|
||||
if score <= 0:
|
||||
continue
|
||||
hits.append(
|
||||
SearchHit(
|
||||
uri=document.uri,
|
||||
title=document.title,
|
||||
path=document.external_ref,
|
||||
source_type=document.source_type,
|
||||
source_revision=document.source_revision,
|
||||
repository_ref=document.repository_ref,
|
||||
repository_commit=document.repository_commit,
|
||||
content_hash=document.content_hash,
|
||||
modified_at=document.modified_at,
|
||||
snippet=safe_snippet(chunk.text),
|
||||
score=score,
|
||||
truncated=len(chunk.text.encode("utf-8")) > 2000,
|
||||
)
|
||||
)
|
||||
hits.sort(key=lambda hit: (-hit.score, hit.uri, hit.path))
|
||||
page = hits[page_cursor.offset : page_cursor.offset + limit]
|
||||
next_offset = page_cursor.offset + len(page)
|
||||
next_cursor = (
|
||||
encode_cursor(next_offset, str(project_id), query, *selected)
|
||||
if next_offset < len(hits)
|
||||
else None
|
||||
)
|
||||
return SearchPage(
|
||||
items=tuple(page), next_cursor=next_cursor, truncated=next_cursor is not None
|
||||
)
|
||||
|
||||
def list_sources(self, project_id: UUID) -> tuple[SourceStatus, ...]:
|
||||
return tuple(
|
||||
SourceStatus(
|
||||
source_ref=source.source_ref,
|
||||
source_type=source.source_type,
|
||||
uri=source.uri_prefix,
|
||||
status="disabled"
|
||||
if not source.enabled
|
||||
else (
|
||||
"ready"
|
||||
if any(
|
||||
doc.project_id == project_id
|
||||
and doc.source_id == source.source_id
|
||||
and doc.state == "registered"
|
||||
for doc in self.documents.values()
|
||||
)
|
||||
else "empty"
|
||||
),
|
||||
source_revision=source.revision,
|
||||
last_indexed_at=self._last_completed_at,
|
||||
document_count=sum(
|
||||
1
|
||||
for doc in self.documents.values()
|
||||
if doc.project_id == project_id
|
||||
and doc.source_id == source.source_id
|
||||
and doc.state == "registered"
|
||||
),
|
||||
)
|
||||
for source in sorted(self.sources.values(), key=lambda item: item.source_ref)
|
||||
if source.project_id == project_id
|
||||
)
|
||||
|
||||
def status(self, project_id: UUID) -> IndexStatus:
|
||||
sources = self.list_sources(project_id)
|
||||
count = sum(source.document_count for source in sources)
|
||||
status: Literal["ready", "indexing", "stale", "failed", "empty"] = (
|
||||
"indexing"
|
||||
if self._indexing
|
||||
else ("failed" if self._last_error else ("empty" if count == 0 else "ready"))
|
||||
)
|
||||
return IndexStatus(
|
||||
status=status,
|
||||
source_count=len(sources),
|
||||
document_count=count,
|
||||
last_completed_at=self._last_completed_at,
|
||||
last_error=self._last_error,
|
||||
)
|
||||
|
||||
def documents_for_source(
|
||||
self, project_id: UUID, source_type: SourceType | None = None
|
||||
) -> tuple[DocumentRecord, ...]:
|
||||
return tuple(
|
||||
sorted(
|
||||
(
|
||||
doc
|
||||
for doc in self.documents.values()
|
||||
if doc.project_id == project_id
|
||||
and doc.state == "registered"
|
||||
and (source_type is None or doc.source_type == source_type)
|
||||
),
|
||||
key=lambda doc: (doc.external_ref, doc.document_id),
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
class KnowledgeRefreshJob:
|
||||
"""Single bounded refresh operation; scheduling is owned by the control plane."""
|
||||
|
||||
def __init__(self, index: InMemoryKnowledgeIndex, source_ids: Iterable[UUID]) -> None:
|
||||
self.index = index
|
||||
self.source_ids = tuple(source_ids)
|
||||
|
||||
def run_once(self) -> tuple[IndexReport, ...]:
|
||||
return tuple(self.index.index_source(source_id) for source_id in self.source_ids)
|
||||
@@ -0,0 +1,356 @@
|
||||
"""Typed, read-only MobilityOps (Fleet Ops) API client primitives.
|
||||
|
||||
MobilityOps has exactly one configured target per environment (unlike Gitea's
|
||||
per-project repository registry) -- shape follows the simpler `unraid` broker
|
||||
client pattern, with Gitea's stricter settings/transport validation reused
|
||||
since this, like Gitea, is an external HTTPS origin authenticated by a
|
||||
long-lived shared secret rather than an internally-issued short-lived token.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import ipaddress
|
||||
import json
|
||||
import re
|
||||
from collections.abc import Mapping
|
||||
from pathlib import Path
|
||||
from typing import Any, Protocol, cast
|
||||
from urllib.parse import urljoin, urlparse
|
||||
|
||||
import httpx
|
||||
from pydantic import BaseModel, ConfigDict, Field, field_validator, model_validator
|
||||
|
||||
KNOWLEDGE_LOCALES: tuple[str, ...] = ("nl-BE", "en-GB", "fr-BE")
|
||||
"""Locales MobilityOps (Fleet Ops) accepts on `POST /search-knowledge`."""
|
||||
|
||||
DEFAULT_KNOWLEDGE_LOCALE = "en-GB"
|
||||
"""Compatible default for older clients that send no locale at all."""
|
||||
|
||||
CORRELATION_ID = re.compile(r"^[A-Za-z0-9._:-]{1,128}$")
|
||||
|
||||
|
||||
class MobilityOpsError(RuntimeError):
|
||||
"""Stable connector error that never contains credentials or raw headers."""
|
||||
|
||||
def __init__(self, code: str, message: str) -> None:
|
||||
self.code = code
|
||||
super().__init__(message)
|
||||
|
||||
|
||||
class MobilityOpsSettings(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid", frozen=True)
|
||||
|
||||
base_url: str = Field(min_length=1, max_length=2048)
|
||||
token_reference: str = Field(min_length=1, max_length=512)
|
||||
timeout_seconds: float = Field(default=15.0, gt=0, le=60)
|
||||
max_response_bytes: int = Field(default=262144, ge=1024, le=4 * 1024 * 1024)
|
||||
allow_insecure_http_for_tests: bool = False
|
||||
|
||||
@field_validator("base_url")
|
||||
@classmethod
|
||||
def validate_base_url(cls, value: str) -> str:
|
||||
parsed = urlparse(value.strip().rstrip("/"))
|
||||
if parsed.scheme not in {"https", "http"} or not parsed.hostname:
|
||||
raise ValueError("MobilityOps base_url must be an absolute HTTP(S) URL")
|
||||
if parsed.username or parsed.password or parsed.query or parsed.fragment:
|
||||
raise ValueError("MobilityOps base_url must not contain credentials or query data")
|
||||
hostname = (parsed.hostname or "").casefold()
|
||||
if hostname in {"localhost", "metadata", "metadata.google.internal"}:
|
||||
raise ValueError("MobilityOps base_url host is not allowed")
|
||||
try:
|
||||
address = ipaddress.ip_address(hostname)
|
||||
except ValueError:
|
||||
address = None
|
||||
if address is not None and (
|
||||
address.is_private
|
||||
or address.is_loopback
|
||||
or address.is_link_local
|
||||
or address.is_reserved
|
||||
or address.is_unspecified
|
||||
or address.is_multicast
|
||||
):
|
||||
raise ValueError("MobilityOps base_url host is not allowed")
|
||||
return value.strip().rstrip("/")
|
||||
|
||||
@model_validator(mode="after")
|
||||
def require_tls(self) -> MobilityOpsSettings:
|
||||
if urlparse(self.base_url).scheme != "https" and not self.allow_insecure_http_for_tests:
|
||||
raise ValueError("MobilityOps base_url must use HTTPS")
|
||||
if not self.token_reference.startswith(("docker-secret://", "/run/secrets/")):
|
||||
raise ValueError("token_reference must name a mounted secret, not contain a token")
|
||||
return self
|
||||
|
||||
|
||||
class TokenProvider(Protocol):
|
||||
def get_token(self, reference: str) -> str: ...
|
||||
|
||||
|
||||
class FileTokenProvider:
|
||||
"""Read-only provider for Docker secrets mounted beneath /run/secrets."""
|
||||
|
||||
def get_token(self, reference: str) -> str:
|
||||
if reference.startswith("docker-secret://"):
|
||||
name = reference.removeprefix("docker-secret://")
|
||||
path = Path("/run/secrets") / name
|
||||
else:
|
||||
path = Path(reference)
|
||||
root = Path("/run/secrets").resolve()
|
||||
try:
|
||||
resolved = path.resolve()
|
||||
resolved.relative_to(root)
|
||||
except (OSError, ValueError) as exc:
|
||||
raise MobilityOpsError(
|
||||
"MOBILITYOPS_SECRET_REFERENCE_INVALID", "secret reference is outside /run/secrets"
|
||||
) from exc
|
||||
try:
|
||||
token = resolved.read_text(encoding="utf-8").strip()
|
||||
except OSError as exc:
|
||||
raise MobilityOpsError(
|
||||
"MOBILITYOPS_SECRET_UNAVAILABLE", "configured MobilityOps secret is unavailable"
|
||||
) from exc
|
||||
if not token or len(token) > 4096 or any(ord(char) < 0x20 for char in token):
|
||||
raise MobilityOpsError(
|
||||
"MOBILITYOPS_SECRET_INVALID", "configured MobilityOps secret is invalid"
|
||||
)
|
||||
return token
|
||||
|
||||
|
||||
class StaticTokenProvider:
|
||||
"""Deterministic fake-only provider; never serializes the token."""
|
||||
|
||||
def __init__(self, token: str) -> None:
|
||||
self._token = token
|
||||
|
||||
def get_token(self, reference: str) -> str:
|
||||
if not reference.startswith(("docker-secret://", "/run/secrets/")):
|
||||
raise MobilityOpsError(
|
||||
"MOBILITYOPS_SECRET_REFERENCE_INVALID", "invalid secret reference"
|
||||
)
|
||||
return self._token
|
||||
|
||||
|
||||
class MobilityOpsClient:
|
||||
"""Small MobilityOps API client with same-origin redirect enforcement.
|
||||
|
||||
Auth is a shared-secret `X-Service-Token` header (MobilityOps's own
|
||||
`require_mcp_service_token` dependency), not a per-repo bearer token --
|
||||
there is exactly one configured MobilityOps target per environment.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
settings: MobilityOpsSettings,
|
||||
token_provider: TokenProvider,
|
||||
*,
|
||||
transport: httpx.BaseTransport | None = None,
|
||||
) -> None:
|
||||
self.settings = settings
|
||||
self._token_provider = token_provider
|
||||
self._client = httpx.Client(
|
||||
base_url=f"{settings.base_url}/api/v1/integrations/mcp",
|
||||
timeout=settings.timeout_seconds,
|
||||
follow_redirects=False,
|
||||
transport=transport,
|
||||
)
|
||||
|
||||
def close(self) -> None:
|
||||
self._client.close()
|
||||
|
||||
def _request(
|
||||
self,
|
||||
client_id: str,
|
||||
method: str,
|
||||
path: str,
|
||||
*,
|
||||
params: Mapping[str, str | int] | None = None,
|
||||
json_body: Mapping[str, Any] | None = None,
|
||||
correlation_id: str | None = None,
|
||||
timeout: float | None = None,
|
||||
) -> Any:
|
||||
token = self._token_provider.get_token(self.settings.token_reference)
|
||||
headers = {
|
||||
"X-Service-Token": token,
|
||||
"X-Client-Id": client_id,
|
||||
"Accept": "application/json",
|
||||
}
|
||||
if correlation_id is not None:
|
||||
# The Hub already has a trace id for this invocation; MobilityOps preserves
|
||||
# whatever valid correlation id it receives, so never mint a second one here.
|
||||
if not CORRELATION_ID.fullmatch(correlation_id):
|
||||
raise MobilityOpsError(
|
||||
"MOBILITYOPS_CORRELATION_ID_INVALID",
|
||||
"inbound correlation id is not a safe header value",
|
||||
)
|
||||
headers["X-Correlation-Id"] = correlation_id
|
||||
request_timeout = self.settings.timeout_seconds if timeout is None else timeout
|
||||
try:
|
||||
response = self._client.request(
|
||||
method,
|
||||
path,
|
||||
params=params,
|
||||
json=json_body,
|
||||
headers=headers,
|
||||
timeout=request_timeout,
|
||||
)
|
||||
if 300 <= response.status_code < 400:
|
||||
location = response.headers.get("location")
|
||||
if not location:
|
||||
raise MobilityOpsError(
|
||||
"MOBILITYOPS_REDIRECT_DENIED",
|
||||
"MobilityOps redirect did not provide a location",
|
||||
)
|
||||
target = urlparse(urljoin(str(response.url), location))
|
||||
base = urlparse(self.settings.base_url)
|
||||
if (target.scheme, target.hostname, target.port) != (
|
||||
base.scheme,
|
||||
base.hostname,
|
||||
base.port,
|
||||
):
|
||||
raise MobilityOpsError(
|
||||
"MOBILITYOPS_REDIRECT_DENIED",
|
||||
"MobilityOps redirect leaves the configured origin",
|
||||
)
|
||||
response = self._client.request(
|
||||
method, target.geturl(), headers=headers, timeout=request_timeout
|
||||
)
|
||||
if 300 <= response.status_code < 400:
|
||||
raise MobilityOpsError(
|
||||
"MOBILITYOPS_REDIRECT_DENIED", "MobilityOps returned multiple redirects"
|
||||
)
|
||||
except MobilityOpsError:
|
||||
raise
|
||||
except (httpx.TimeoutException, httpx.NetworkError) as exc:
|
||||
raise MobilityOpsError(
|
||||
"MOBILITYOPS_UNAVAILABLE", "MobilityOps target is unavailable"
|
||||
) from exc
|
||||
if len(response.content) > self.settings.max_response_bytes:
|
||||
raise MobilityOpsError(
|
||||
"MOBILITYOPS_RESPONSE_TOO_LARGE",
|
||||
"MobilityOps response exceeds the connector budget",
|
||||
)
|
||||
if response.status_code == 404:
|
||||
raise MobilityOpsError("MOBILITYOPS_NOT_FOUND", "MobilityOps resource was not found")
|
||||
if response.status_code in {401, 403}:
|
||||
raise MobilityOpsError(
|
||||
"MOBILITYOPS_ACCESS_DENIED", "MobilityOps denied the read request"
|
||||
)
|
||||
if response.status_code == 422:
|
||||
raise MobilityOpsError(
|
||||
"MOBILITYOPS_REQUEST_INVALID", "MobilityOps rejected the request shape"
|
||||
)
|
||||
if response.status_code >= 400:
|
||||
raise MobilityOpsError(
|
||||
"MOBILITYOPS_REQUEST_FAILED", "MobilityOps returned an unsuccessful response"
|
||||
)
|
||||
try:
|
||||
return response.json()
|
||||
except (ValueError, json.JSONDecodeError) as exc:
|
||||
raise MobilityOpsError(
|
||||
"MOBILITYOPS_INVALID_RESPONSE", "MobilityOps returned invalid JSON"
|
||||
) from exc
|
||||
|
||||
def operations_summary(
|
||||
self, client_id: str, *, correlation_id: str | None = None
|
||||
) -> dict[str, Any]:
|
||||
return cast(
|
||||
dict[str, Any],
|
||||
self._request(client_id, "GET", "/operations-summary", correlation_id=correlation_id),
|
||||
)
|
||||
|
||||
READINESS_TIMEOUT_SECONDS = 5.0
|
||||
|
||||
def readiness_probe(self, client_id: str, *, correlation_id: str | None = None) -> None:
|
||||
"""Short, bounded, authenticated read used as the connector's readiness signal.
|
||||
|
||||
MobilityOps exposes no authenticated readiness route on this origin -- its only
|
||||
health route (`GET /health`) is unauthenticated and lives outside the
|
||||
`/api/v1/integrations/mcp` prefix this client is bound to -- so the narrowest
|
||||
authenticated read, `operations-summary`, is the readiness probe instead.
|
||||
Raises `MobilityOpsError` with a stable code; never leaks the token or a raw
|
||||
transport exception.
|
||||
"""
|
||||
payload = self._request(
|
||||
client_id,
|
||||
"GET",
|
||||
"/operations-summary",
|
||||
correlation_id=correlation_id,
|
||||
timeout=min(self.settings.timeout_seconds, self.READINESS_TIMEOUT_SECONDS),
|
||||
)
|
||||
if not isinstance(payload, dict) or not isinstance(payload.get("metrics"), dict):
|
||||
raise MobilityOpsError(
|
||||
"MOBILITYOPS_INVALID_RESPONSE",
|
||||
"MobilityOps readiness response did not carry operational metrics",
|
||||
)
|
||||
|
||||
def attention_vehicles(
|
||||
self,
|
||||
client_id: str,
|
||||
*,
|
||||
minimum_severity: str,
|
||||
date: str | None,
|
||||
limit: int,
|
||||
correlation_id: str | None = None,
|
||||
) -> list[dict[str, Any]]:
|
||||
params: dict[str, str | int] = {"minimum_severity": minimum_severity, "limit": limit}
|
||||
if date:
|
||||
params["date"] = date
|
||||
value = self._request(
|
||||
client_id,
|
||||
"GET",
|
||||
"/attention-vehicles",
|
||||
params=params,
|
||||
correlation_id=correlation_id,
|
||||
)
|
||||
if not isinstance(value, list) or not all(isinstance(item, dict) for item in value):
|
||||
raise MobilityOpsError(
|
||||
"MOBILITYOPS_INVALID_RESPONSE", "MobilityOps returned an unexpected collection"
|
||||
)
|
||||
return value
|
||||
|
||||
_VEHICLE_REF = re.compile(r"^[A-Za-z0-9][A-Za-z0-9-]{0,63}$")
|
||||
|
||||
def vehicle_details(
|
||||
self, client_id: str, vehicle_ref: str, *, correlation_id: str | None = None
|
||||
) -> dict[str, Any]:
|
||||
if not self._VEHICLE_REF.fullmatch(vehicle_ref):
|
||||
raise MobilityOpsError(
|
||||
"MOBILITYOPS_VEHICLE_REF_INVALID", "vehicle reference is invalid"
|
||||
)
|
||||
return cast(
|
||||
dict[str, Any],
|
||||
self._request(
|
||||
client_id,
|
||||
"GET",
|
||||
f"/vehicles/{vehicle_ref}",
|
||||
correlation_id=correlation_id,
|
||||
),
|
||||
)
|
||||
|
||||
def search_knowledge(
|
||||
self,
|
||||
client_id: str,
|
||||
question: str,
|
||||
*,
|
||||
max_sources: int,
|
||||
locale: str = DEFAULT_KNOWLEDGE_LOCALE,
|
||||
correlation_id: str | None = None,
|
||||
) -> dict[str, Any]:
|
||||
if locale not in KNOWLEDGE_LOCALES:
|
||||
raise MobilityOpsError(
|
||||
"MOBILITYOPS_LOCALE_INVALID",
|
||||
"locale must be one of nl-BE, en-GB, fr-BE",
|
||||
)
|
||||
return cast(
|
||||
dict[str, Any],
|
||||
self._request(
|
||||
client_id,
|
||||
"POST",
|
||||
"/search-knowledge",
|
||||
json_body={
|
||||
"question": question,
|
||||
"max_sources": max_sources,
|
||||
"locale": locale,
|
||||
},
|
||||
correlation_id=correlation_id,
|
||||
),
|
||||
)
|
||||
@@ -0,0 +1,47 @@
|
||||
"""Opaque, scope-bound pagination cursors."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import json
|
||||
from dataclasses import dataclass
|
||||
from hashlib import sha256
|
||||
|
||||
|
||||
class InvalidCursor(ValueError):
|
||||
"""Raised for malformed, cross-query, or out-of-range cursors."""
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Cursor:
|
||||
offset: int
|
||||
fingerprint: str
|
||||
|
||||
|
||||
def _fingerprint(*parts: str) -> str:
|
||||
return sha256("\x1f".join(parts).encode("utf-8")).hexdigest()[:32]
|
||||
|
||||
|
||||
def encode_cursor(offset: int, *scope: str) -> str:
|
||||
if offset < 0:
|
||||
raise ValueError("cursor offset must be non-negative")
|
||||
payload = {"o": offset, "f": _fingerprint(*scope)}
|
||||
raw = json.dumps(payload, separators=(",", ":"), sort_keys=True).encode("ascii")
|
||||
return base64.urlsafe_b64encode(raw).decode("ascii").rstrip("=")
|
||||
|
||||
|
||||
def decode_cursor(value: str | None, *scope: str) -> Cursor:
|
||||
if value is None:
|
||||
return Cursor(0, _fingerprint(*scope))
|
||||
if not 1 <= len(value) <= 500:
|
||||
raise InvalidCursor("cursor length is invalid")
|
||||
try:
|
||||
padded = value + "=" * (-len(value) % 4)
|
||||
payload = json.loads(base64.urlsafe_b64decode(padded).decode("ascii"))
|
||||
offset = payload["o"]
|
||||
fingerprint = payload["f"]
|
||||
except (ValueError, KeyError, TypeError, json.JSONDecodeError) as exc:
|
||||
raise InvalidCursor("cursor is invalid") from exc
|
||||
if type(offset) is not int or offset < 0 or fingerprint != _fingerprint(*scope):
|
||||
raise InvalidCursor("cursor does not match this query")
|
||||
return Cursor(offset, fingerprint)
|
||||
@@ -0,0 +1,70 @@
|
||||
"""Deterministic secret exclusion and safe text normalization for untrusted content."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
import unicodedata
|
||||
|
||||
_SECRET_PATTERNS = (
|
||||
re.compile(r"-----BEGIN [A-Z0-9 ]*PRIVATE KEY-----"),
|
||||
re.compile(
|
||||
r"(?i)\b(?:authorization|bearer|api[_-]?key|access[_-]?token|refresh[_-]?token)\s*[:=]\s*[^\s,;]{8,}"
|
||||
),
|
||||
re.compile(r"(?i)\b(?:password|passwd|secret)\s*[:=]\s*['\"]?[^\s'\",;]{8,}"),
|
||||
re.compile(r"\bAKIA[0-9A-Z]{16}\b"),
|
||||
re.compile(r"\beyJ[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\b"),
|
||||
)
|
||||
_CONTROL_RE = re.compile(r"[\x00-\x08\x0b\x0c\x0e-\x1f\x7f]")
|
||||
_INLINE_SECRET_PATTERNS = (
|
||||
re.compile(
|
||||
r"(?i)(\b(?:authorization|bearer|api[_-]?key|access[_-]?token|refresh[_-]?token|"
|
||||
r"password|passwd|secret|credential|private[_-]?key)\s*[:=]\s*['\"]?)"
|
||||
r"[^\s'\",;]{4,}"
|
||||
),
|
||||
re.compile(
|
||||
r"(?i)(\b[A-Z][A-Z0-9_]*(?:TOKEN|SECRET|PASSWORD|PASSWD|API_KEY|CREDENTIAL)"
|
||||
r"\s*=\s*['\"]?)[^\s'\",;]{4,}"
|
||||
),
|
||||
re.compile(
|
||||
r"\b(?:gh[pousr]_[A-Za-z0-9_]{20,}|github_pat_[A-Za-z0-9_]{20,}|glpat-[A-Za-z0-9_-]{20,}|gitea_[A-Za-z0-9_-]{20,})\b"
|
||||
),
|
||||
re.compile(r"(?i)(https?://[^\s/:@]+:)[^\s/@]+@"),
|
||||
)
|
||||
|
||||
|
||||
def normalize_text(value: str) -> str:
|
||||
"""Normalize Unicode and controls without interpreting content."""
|
||||
|
||||
normalized = unicodedata.normalize("NFC", value).replace("\r\n", "\n").replace("\r", "\n")
|
||||
return _CONTROL_RE.sub("", normalized)
|
||||
|
||||
|
||||
def is_secret_like(value: str) -> bool:
|
||||
return any(pattern.search(value) is not None for pattern in _SECRET_PATTERNS)
|
||||
|
||||
|
||||
def safe_content(value: str) -> str | None:
|
||||
"""Return normalized content or None when the whole document must be excluded."""
|
||||
|
||||
normalized = normalize_text(value)
|
||||
return None if is_secret_like(normalized) else normalized
|
||||
|
||||
|
||||
def safe_snippet(value: str, *, max_bytes: int = 2000) -> str:
|
||||
normalized = normalize_text(value)
|
||||
encoded = normalized.encode("utf-8")
|
||||
if len(encoded) <= max_bytes:
|
||||
return normalized
|
||||
return encoded[: max_bytes - 3].decode("utf-8", errors="ignore") + "..."
|
||||
|
||||
|
||||
def redact_sensitive_text(value: str, *, max_bytes: int = 2000) -> str:
|
||||
"""Normalize, truncate and redact credential-shaped inline values."""
|
||||
|
||||
result = safe_snippet(value, max_bytes=max_bytes)
|
||||
for pattern in _INLINE_SECRET_PATTERNS:
|
||||
result = pattern.sub(
|
||||
lambda match: f"{match.group(1)}<redacted>" if match.lastindex else "<redacted>",
|
||||
result,
|
||||
)
|
||||
return result
|
||||
@@ -0,0 +1,25 @@
|
||||
from .bridge import StdioBridge
|
||||
from .matrix import FakeVirtualServer, MatrixResult, run_matrix
|
||||
from .models import (
|
||||
BootstrapHandle,
|
||||
ClientConfig,
|
||||
ClientKind,
|
||||
ClientRecord,
|
||||
ClientRegistry,
|
||||
ClientStatus,
|
||||
generate_config,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
"BootstrapHandle",
|
||||
"ClientConfig",
|
||||
"ClientKind",
|
||||
"ClientRecord",
|
||||
"ClientRegistry",
|
||||
"ClientStatus",
|
||||
"FakeVirtualServer",
|
||||
"MatrixResult",
|
||||
"StdioBridge",
|
||||
"generate_config",
|
||||
"run_matrix",
|
||||
]
|
||||
@@ -0,0 +1,80 @@
|
||||
"""Minimal JSON-lines stdio bridge; tokens are read only for the call and never emitted."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
from collections.abc import Callable, Iterable, Iterator, Mapping
|
||||
from typing import Any, Protocol
|
||||
|
||||
|
||||
class BridgeTransport(Protocol):
|
||||
def send(
|
||||
self, request: Mapping[str, Any], *, token: str, endpoint: str
|
||||
) -> Mapping[str, Any] | None: ...
|
||||
|
||||
|
||||
class StdioBridge:
|
||||
"""Translate bounded JSON-lines messages to remote MCP without shell or token output."""
|
||||
|
||||
ALLOWED_METHODS = frozenset(
|
||||
{
|
||||
"initialize",
|
||||
"notifications/initialized",
|
||||
"ping",
|
||||
"tools/list",
|
||||
"tools/call",
|
||||
"resources/list",
|
||||
"resources/templates/list",
|
||||
"resources/read",
|
||||
"prompts/list",
|
||||
"prompts/get",
|
||||
}
|
||||
)
|
||||
|
||||
def __init__(self, *, endpoint: str, token_env: str | None = None) -> None:
|
||||
if not endpoint.startswith("https://"):
|
||||
raise ValueError("stdio bridge endpoints must use HTTPS")
|
||||
self.endpoint = endpoint
|
||||
self.token_env = token_env or "ITWORX_MCP_ACCESS_TOKEN"
|
||||
|
||||
def forward(
|
||||
self,
|
||||
lines: Iterable[str],
|
||||
transport: BridgeTransport,
|
||||
*,
|
||||
environ: Mapping[str, str] | None = None,
|
||||
token_provider: Callable[[], str] | None = None,
|
||||
) -> Iterator[str]:
|
||||
values = environ if environ is not None else os.environ
|
||||
static_token = values.get(self.token_env)
|
||||
if not static_token and token_provider is None:
|
||||
yield _error("authentication_required")
|
||||
return
|
||||
for raw in lines:
|
||||
if len(raw.encode("utf-8")) > 4 * 1024 * 1024:
|
||||
yield _error("request_too_large")
|
||||
continue
|
||||
try:
|
||||
request = json.loads(raw)
|
||||
except json.JSONDecodeError:
|
||||
yield _error("invalid_json")
|
||||
continue
|
||||
if not isinstance(request, dict) or request.get("method") not in self.ALLOWED_METHODS:
|
||||
yield _error("unsupported_method")
|
||||
continue
|
||||
try:
|
||||
token = token_provider() if token_provider is not None else static_token
|
||||
if not token:
|
||||
yield _error("authentication_required")
|
||||
continue
|
||||
result = transport.send(request, token=token, endpoint=self.endpoint)
|
||||
if result is not None:
|
||||
yield json.dumps(dict(result), separators=(",", ":"))
|
||||
except Exception:
|
||||
yield _error("upstream_unavailable")
|
||||
del static_token
|
||||
|
||||
|
||||
def _error(code: str) -> str:
|
||||
return json.dumps({"error": {"code": code}}, separators=(",", ":"))
|
||||
@@ -0,0 +1,69 @@
|
||||
"""Deterministic MCP discovery/invocation matrix for supported client profiles."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from typing import Any
|
||||
|
||||
from .models import ClientKind
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class MatrixResult:
|
||||
client_kind: ClientKind
|
||||
protocol_version: str
|
||||
connector: str
|
||||
tool: str
|
||||
discovered: bool
|
||||
invoked: bool
|
||||
detail: str
|
||||
|
||||
|
||||
class FakeVirtualServer:
|
||||
"""Offline project virtual server used for the client matrix and negative cases."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self.tools = {
|
||||
"gitea": "gitea.repository.get",
|
||||
"knowledge": "knowledge.document.search",
|
||||
"unraid": "unraid.container.inspect",
|
||||
}
|
||||
self.revoked: set[str] = set()
|
||||
|
||||
def discover(self, client_key: str, protocol_version: str) -> tuple[str, ...]:
|
||||
if client_key in self.revoked:
|
||||
raise PermissionError("client revoked")
|
||||
if protocol_version not in {"2026-07-28", "2025-11-25"}:
|
||||
raise ValueError("unsupported protocol version")
|
||||
return tuple(self.tools.values())
|
||||
|
||||
def invoke(
|
||||
self, client_key: str, tool: str, arguments: dict[str, Any] | None = None
|
||||
) -> dict[str, Any]:
|
||||
del arguments
|
||||
if client_key in self.revoked:
|
||||
raise PermissionError("client revoked")
|
||||
if tool not in self.tools.values():
|
||||
raise LookupError("tool not found")
|
||||
return {"tool": tool, "ok": True, "truncated": False}
|
||||
|
||||
|
||||
def run_matrix(server: FakeVirtualServer) -> tuple[MatrixResult, ...]:
|
||||
profiles = (
|
||||
(ClientKind.NATIVE_2026, "2026-07-28"),
|
||||
(ClientKind.COMPATIBILITY_2025, "2025-11-25"),
|
||||
(ClientKind.STDIO_BRIDGE, "2026-07-28"),
|
||||
(ClientKind.INSPECTOR, "2026-07-28"),
|
||||
)
|
||||
results: list[MatrixResult] = []
|
||||
for kind, revision in profiles:
|
||||
client_key = kind.value
|
||||
tools = server.discover(client_key, revision)
|
||||
for connector, tool in server.tools.items():
|
||||
invoked = bool(server.invoke(client_key, tool)["ok"])
|
||||
results.append(
|
||||
MatrixResult(
|
||||
kind, revision, connector, tool, tool in tools, invoked, "fixture success"
|
||||
)
|
||||
)
|
||||
return tuple(results)
|
||||
@@ -0,0 +1,235 @@
|
||||
"""Typed, secret-free machine-client lifecycle primitives for MCP onboarding."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import UTC, datetime, timedelta
|
||||
from enum import StrEnum
|
||||
from threading import RLock
|
||||
from uuid import UUID, uuid4
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, Field
|
||||
|
||||
|
||||
class ClientKind(StrEnum):
|
||||
NATIVE_2026 = "native-2026"
|
||||
COMPATIBILITY_2025 = "compatibility-2025"
|
||||
STDIO_BRIDGE = "stdio-bridge"
|
||||
INSPECTOR = "inspector"
|
||||
|
||||
|
||||
class ClientStatus(StrEnum):
|
||||
PENDING = "pending"
|
||||
ENABLED = "enabled"
|
||||
DISABLED = "disabled"
|
||||
REVOKED = "revoked"
|
||||
|
||||
|
||||
class ClientRecord(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid", frozen=True)
|
||||
|
||||
id: UUID
|
||||
name: str = Field(min_length=1, max_length=128)
|
||||
project: str = Field(min_length=1, max_length=63, pattern=r"^[a-z0-9][a-z0-9-]*$")
|
||||
environment: str = Field(min_length=1, max_length=32, pattern=r"^[a-z0-9][a-z0-9-]*$")
|
||||
kind: ClientKind
|
||||
status: ClientStatus
|
||||
credential_reference: str = Field(min_length=1, max_length=256)
|
||||
created_at: datetime
|
||||
last_seen_at: datetime | None = None
|
||||
disabled_at: datetime | None = None
|
||||
revoked_at: datetime | None = None
|
||||
|
||||
|
||||
class ClientConfig(BaseModel):
|
||||
"""Generated client configuration. It intentionally contains no token or secret."""
|
||||
|
||||
model_config = ConfigDict(extra="forbid", frozen=True, populate_by_name=True)
|
||||
|
||||
client_id: UUID = Field(serialization_alias="clientId")
|
||||
kind: ClientKind
|
||||
protocol_version: str = Field(serialization_alias="protocolVersion")
|
||||
endpoint: str
|
||||
transport: str
|
||||
scopes: tuple[str, ...]
|
||||
credential_reference: str = Field(serialization_alias="credentialReference")
|
||||
environment_variable: str | None = Field(
|
||||
default=None, serialization_alias="environmentVariable"
|
||||
)
|
||||
bridge_command: str | None = Field(default=None, serialization_alias="bridgeCommand")
|
||||
|
||||
|
||||
class BootstrapHandle(BaseModel):
|
||||
"""A one-time Authentik hand-off reference, never a bearer credential."""
|
||||
|
||||
model_config = ConfigDict(extra="forbid", frozen=True, populate_by_name=True)
|
||||
|
||||
client_id: UUID
|
||||
reference: str = Field(min_length=1, max_length=256)
|
||||
delivery: str = "authentik-service-account"
|
||||
expires_at: datetime = Field(serialization_alias="expiresAt")
|
||||
consumed: bool = False
|
||||
|
||||
|
||||
class ClientRegistry:
|
||||
"""In-memory development registry; production persistence is supplied by the control plane."""
|
||||
|
||||
def __init__(self, *, revocation_cache_ttl_seconds: int = 60) -> None:
|
||||
if not 1 <= revocation_cache_ttl_seconds <= 60:
|
||||
raise ValueError("revocation cache ceiling is 60 seconds")
|
||||
self.revocation_cache_ttl_seconds = revocation_cache_ttl_seconds
|
||||
self._clients: dict[UUID, ClientRecord] = {}
|
||||
self._bootstrap: dict[str, BootstrapHandle] = {}
|
||||
self._lock = RLock()
|
||||
|
||||
def create(
|
||||
self,
|
||||
*,
|
||||
name: str,
|
||||
project: str,
|
||||
environment: str,
|
||||
kind: ClientKind,
|
||||
now: datetime | None = None,
|
||||
) -> ClientRecord:
|
||||
reference = _aware(now)
|
||||
client_id = uuid4()
|
||||
record = ClientRecord(
|
||||
id=client_id,
|
||||
name=name,
|
||||
project=project,
|
||||
environment=environment,
|
||||
kind=kind,
|
||||
status=ClientStatus.ENABLED,
|
||||
credential_reference=f"authentik://mcp-client/{client_id}",
|
||||
created_at=reference,
|
||||
)
|
||||
with self._lock:
|
||||
self._clients[client_id] = record
|
||||
return record
|
||||
|
||||
def list(self) -> tuple[ClientRecord, ...]:
|
||||
with self._lock:
|
||||
return tuple(self._clients.values())
|
||||
|
||||
def get(self, client_id: UUID) -> ClientRecord | None:
|
||||
with self._lock:
|
||||
return self._clients.get(client_id)
|
||||
|
||||
def disable(self, client_id: UUID, *, now: datetime | None = None) -> ClientRecord:
|
||||
return self._transition(client_id, ClientStatus.DISABLED, _aware(now))
|
||||
|
||||
def revoke(self, client_id: UUID, *, now: datetime | None = None) -> ClientRecord:
|
||||
return self._transition(client_id, ClientStatus.REVOKED, _aware(now))
|
||||
|
||||
def _transition(self, client_id: UUID, status: ClientStatus, now: datetime) -> ClientRecord:
|
||||
with self._lock:
|
||||
current = self._clients.get(client_id)
|
||||
if current is None:
|
||||
raise KeyError(client_id)
|
||||
updated = current.model_copy(
|
||||
update={
|
||||
"status": status,
|
||||
"disabled_at": now if status is ClientStatus.DISABLED else current.disabled_at,
|
||||
"revoked_at": now if status is ClientStatus.REVOKED else current.revoked_at,
|
||||
}
|
||||
)
|
||||
self._clients[client_id] = updated
|
||||
return updated
|
||||
|
||||
def touch(self, client_id: UUID, *, now: datetime | None = None) -> ClientRecord:
|
||||
reference = _aware(now)
|
||||
with self._lock:
|
||||
current = self._clients.get(client_id)
|
||||
if current is None:
|
||||
raise KeyError(client_id)
|
||||
if current.status is not ClientStatus.ENABLED:
|
||||
raise PermissionError("client is not enabled")
|
||||
updated = current.model_copy(update={"last_seen_at": reference})
|
||||
self._clients[client_id] = updated
|
||||
return updated
|
||||
|
||||
def authorize(self, client_id: UUID, *, project: str, environment: str) -> ClientRecord:
|
||||
record = self.get(client_id)
|
||||
if record is None or record.status is not ClientStatus.ENABLED:
|
||||
raise PermissionError("client is disabled or revoked")
|
||||
if record.project != project or record.environment != environment:
|
||||
raise PermissionError("client binding does not match endpoint scope")
|
||||
return record
|
||||
|
||||
def issue_bootstrap(
|
||||
self, client_id: UUID, *, now: datetime | None = None, ttl_seconds: int = 300
|
||||
) -> BootstrapHandle:
|
||||
if not 1 <= ttl_seconds <= 900:
|
||||
raise ValueError("bootstrap TTL must be between 1 and 900 seconds")
|
||||
if self.get(client_id) is None:
|
||||
raise KeyError(client_id)
|
||||
reference = _aware(now)
|
||||
handle = BootstrapHandle(
|
||||
client_id=client_id,
|
||||
reference=f"authentik-bootstrap://{uuid4()}",
|
||||
expires_at=reference + timedelta(seconds=ttl_seconds),
|
||||
)
|
||||
with self._lock:
|
||||
self._bootstrap[handle.reference] = handle
|
||||
return handle
|
||||
|
||||
def consume_bootstrap(self, reference: str, *, now: datetime | None = None) -> BootstrapHandle:
|
||||
current = _aware(now)
|
||||
with self._lock:
|
||||
handle = self._bootstrap.get(reference)
|
||||
if handle is None or handle.consumed or handle.expires_at <= current:
|
||||
raise PermissionError("bootstrap reference is invalid or expired")
|
||||
consumed = handle.model_copy(update={"consumed": True})
|
||||
self._bootstrap[reference] = consumed
|
||||
return consumed
|
||||
|
||||
|
||||
def _aware(value: datetime | None) -> datetime:
|
||||
result = value or datetime.now(UTC)
|
||||
if result.tzinfo is None or result.utcoffset() is None:
|
||||
raise ValueError("timestamps must be timezone-aware")
|
||||
return result.astimezone(UTC)
|
||||
|
||||
|
||||
def generate_config(record: ClientRecord, *, public_host: str = "mcp.example.com") -> ClientConfig:
|
||||
endpoint = f"https://{public_host}/mcp/{record.project}/{record.environment}"
|
||||
if record.kind is ClientKind.NATIVE_2026:
|
||||
return ClientConfig(
|
||||
client_id=record.id,
|
||||
kind=record.kind,
|
||||
protocol_version="2026-07-28",
|
||||
endpoint=endpoint,
|
||||
transport="streamable-http",
|
||||
scopes=("tools:read",),
|
||||
credential_reference=record.credential_reference,
|
||||
)
|
||||
if record.kind is ClientKind.COMPATIBILITY_2025:
|
||||
return ClientConfig(
|
||||
client_id=record.id,
|
||||
kind=record.kind,
|
||||
protocol_version="2025-11-25",
|
||||
endpoint=endpoint,
|
||||
transport="streamable-http",
|
||||
scopes=("tools:read",),
|
||||
credential_reference=record.credential_reference,
|
||||
)
|
||||
if record.kind is ClientKind.STDIO_BRIDGE:
|
||||
return ClientConfig(
|
||||
client_id=record.id,
|
||||
kind=record.kind,
|
||||
protocol_version="2026-07-28",
|
||||
endpoint=endpoint,
|
||||
transport="stdio",
|
||||
scopes=("tools:read",),
|
||||
credential_reference=record.credential_reference,
|
||||
environment_variable="ITWORX_MCP_ACCESS_TOKEN",
|
||||
bridge_command="itworx-mcp-bridge",
|
||||
)
|
||||
return ClientConfig(
|
||||
client_id=record.id,
|
||||
kind=record.kind,
|
||||
protocol_version="2026-07-28",
|
||||
endpoint=f"http://127.0.0.1:18442/mcp/{record.project}/{record.environment}",
|
||||
transport="streamable-http",
|
||||
scopes=("tools:read",),
|
||||
credential_reference=record.credential_reference,
|
||||
)
|
||||
@@ -0,0 +1 @@
|
||||
"""Shared Hub contracts used across service boundaries."""
|
||||
@@ -0,0 +1,153 @@
|
||||
"""Typed, public-safe contracts for the dataplane router."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import ipaddress
|
||||
import re
|
||||
from datetime import datetime
|
||||
from typing import Literal, Protocol
|
||||
from urllib.parse import urlparse
|
||||
from uuid import UUID
|
||||
|
||||
from pydantic import AnyHttpUrl, BaseModel, ConfigDict, Field, field_validator, model_validator
|
||||
|
||||
EnvironmentName = Literal["dev", "test", "staging", "prod"]
|
||||
SUPPORTED_PROTOCOLS = frozenset({"2026-07-28", "2025-11-25"})
|
||||
|
||||
|
||||
class ClientBinding(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid", frozen=True)
|
||||
|
||||
client_id: UUID
|
||||
issuer: str = Field(min_length=1, max_length=2048)
|
||||
subject: str = Field(min_length=1, max_length=512)
|
||||
enabled: bool = False
|
||||
|
||||
|
||||
class AliasRecord(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid", frozen=True)
|
||||
|
||||
project_slug: str = Field(pattern=r"^[a-z0-9][a-z0-9-]{0,62}$")
|
||||
workspace_id: UUID | None = None
|
||||
project_id: UUID | None = None
|
||||
environment: EnvironmentName
|
||||
alias: str
|
||||
upstream_url: AnyHttpUrl
|
||||
enabled: bool = False
|
||||
protocol_revisions: tuple[str, ...] = ("2026-07-28", "2025-11-25")
|
||||
bindings: tuple[ClientBinding, ...] = ()
|
||||
|
||||
@field_validator("alias")
|
||||
@classmethod
|
||||
def validate_alias(cls, value: str) -> str:
|
||||
if len(value) > 128 or not re.fullmatch(
|
||||
r"/mcp/[a-z0-9][a-z0-9-]{0,62}/(?:dev|test|staging|prod)/?", value
|
||||
):
|
||||
raise ValueError("alias must be a stable /mcp path")
|
||||
return value.rstrip("/")
|
||||
|
||||
@field_validator("upstream_url")
|
||||
@classmethod
|
||||
def reject_upstream_userinfo(cls, value: AnyHttpUrl) -> AnyHttpUrl:
|
||||
if value.username is not None or value.password is not None:
|
||||
raise ValueError("upstream URL userinfo is not permitted")
|
||||
internal_contextforge = (
|
||||
value.scheme == "http"
|
||||
and value.host in {"contextforge", "contextforge-control"}
|
||||
and value.port == 4444
|
||||
and (
|
||||
(value.path or "").rstrip("/") == "/mcp"
|
||||
or bool(
|
||||
re.fullmatch(
|
||||
r"/servers/[0-9a-f]{32}/mcp",
|
||||
(value.path or "").rstrip("/"),
|
||||
)
|
||||
)
|
||||
)
|
||||
)
|
||||
if value.scheme != "https" and not internal_contextforge:
|
||||
raise ValueError("dataplane upstreams must use HTTPS or the fixed internal gateway")
|
||||
hostname = (urlparse(str(value)).hostname or "").casefold()
|
||||
if hostname in {"localhost", "metadata", "metadata.google.internal"}:
|
||||
raise ValueError("dataplane upstream host is not allowed")
|
||||
try:
|
||||
address = ipaddress.ip_address(hostname)
|
||||
except ValueError:
|
||||
address = None
|
||||
if address is not None and (
|
||||
address.is_private
|
||||
or address.is_loopback
|
||||
or address.is_link_local
|
||||
or address.is_reserved
|
||||
or address.is_unspecified
|
||||
or address.is_multicast
|
||||
):
|
||||
raise ValueError("dataplane upstream host is not allowed")
|
||||
return value
|
||||
|
||||
@field_validator("protocol_revisions")
|
||||
@classmethod
|
||||
def validate_protocols(cls, value: tuple[str, ...]) -> tuple[str, ...]:
|
||||
if not value or not set(value).issubset(SUPPORTED_PROTOCOLS):
|
||||
raise ValueError("unsupported MCP protocol revision")
|
||||
return tuple(dict.fromkeys(value))
|
||||
|
||||
@model_validator(mode="after")
|
||||
def validate_identity(self) -> AliasRecord:
|
||||
if not self.alias.endswith(f"/{self.environment}"):
|
||||
raise ValueError("alias environment does not match the record environment")
|
||||
if len({binding.client_id for binding in self.bindings}) != len(self.bindings):
|
||||
raise ValueError("duplicate client binding")
|
||||
return self
|
||||
|
||||
|
||||
class InvocationMetadata(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid", frozen=True)
|
||||
|
||||
trace_id: str = Field(pattern=r"^[A-Za-z0-9._:-]{1,128}$")
|
||||
project_slug: str
|
||||
environment: EnvironmentName
|
||||
client_id: UUID
|
||||
subject_digest: str = Field(pattern=r"^[0-9a-f]{64}$")
|
||||
method: str = Field(min_length=1, max_length=16)
|
||||
mcp_method: str | None = Field(default=None, max_length=128)
|
||||
mcp_name: str | None = Field(default=None, max_length=256)
|
||||
protocol_revision: str
|
||||
request_bytes: int = Field(ge=0, le=4 * 1024 * 1024)
|
||||
response_status: int = Field(ge=100, le=599)
|
||||
response_bytes: int = Field(ge=0, le=4 * 1024 * 1024)
|
||||
response_content_type: str | None = Field(default=None, max_length=128)
|
||||
jsonrpc_response: bool = False
|
||||
response_id_matches: bool | None = None
|
||||
response_shape: str = Field(default="unknown", pattern=r"^(result|error|unknown)$")
|
||||
duration_ms: int = Field(ge=0, le=120_000)
|
||||
|
||||
|
||||
class RouterSettings(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid", frozen=True)
|
||||
|
||||
max_body_bytes: int = Field(default=4 * 1024 * 1024, ge=1024, le=4 * 1024 * 1024)
|
||||
max_response_bytes: int = Field(default=4 * 1024 * 1024, ge=1024, le=4 * 1024 * 1024)
|
||||
timeout_seconds: float = Field(default=30.0, gt=0, le=30)
|
||||
alias_cache_ttl_seconds: int = Field(default=30, ge=1, le=60)
|
||||
max_upstream_concurrency: int = Field(default=24, ge=1, le=256)
|
||||
upstream_queue_timeout_seconds: float = Field(default=5.0, ge=0.1, le=30)
|
||||
|
||||
|
||||
class PrincipalLike(Protocol):
|
||||
"""Minimal validated principal contract consumed by the dataplane."""
|
||||
|
||||
@property
|
||||
def subject(self) -> str: ...
|
||||
|
||||
@property
|
||||
def issuer(self) -> str: ...
|
||||
|
||||
@property
|
||||
def kind(self) -> object: ...
|
||||
|
||||
@property
|
||||
def client_id(self) -> UUID | None: ...
|
||||
|
||||
@property
|
||||
def expires_at(self) -> datetime: ...
|
||||
@@ -0,0 +1,5 @@
|
||||
"""Typed control-plane domain primitives."""
|
||||
|
||||
from .domain import Scope
|
||||
|
||||
__all__ = ["Scope"]
|
||||
@@ -0,0 +1,303 @@
|
||||
"""Pydantic domain contracts for the Hub control plane.
|
||||
|
||||
These models deliberately contain references to credentials, never credential
|
||||
values. Persistence rows use the same field names so repositories can validate
|
||||
their output through ``from_attributes`` without leaking ORM objects upward.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
from typing import Any, ClassVar
|
||||
from uuid import UUID, uuid4
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, Field, field_validator
|
||||
|
||||
from .errors import MissingProjectScope
|
||||
|
||||
|
||||
class DomainModel(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid", from_attributes=True, use_enum_values=True)
|
||||
_datetime_fields: ClassVar[frozenset[str]] = frozenset()
|
||||
|
||||
@field_validator("*", mode="before")
|
||||
@classmethod
|
||||
def require_aware_datetime(cls, value: Any) -> Any:
|
||||
if isinstance(value, datetime) and value.tzinfo is None:
|
||||
raise ValueError("timestamps must include timezone information")
|
||||
return value
|
||||
|
||||
|
||||
class Scope(DomainModel):
|
||||
"""Mandatory authorization scope passed to every repository boundary."""
|
||||
|
||||
workspace_id: UUID
|
||||
project_id: UUID | None = None
|
||||
|
||||
@classmethod
|
||||
def workspace(cls, workspace_id: UUID) -> Scope:
|
||||
return cls(workspace_id=workspace_id)
|
||||
|
||||
@classmethod
|
||||
def project(cls, workspace_id: UUID, project_id: UUID) -> Scope:
|
||||
return cls(workspace_id=workspace_id, project_id=project_id)
|
||||
|
||||
def require_project(self) -> UUID:
|
||||
if self.project_id is None:
|
||||
raise MissingProjectScope("project scope is required")
|
||||
return self.project_id
|
||||
|
||||
|
||||
class Timestamped(DomainModel):
|
||||
id: UUID = Field(default_factory=uuid4)
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
revision: int = Field(default=1, ge=1)
|
||||
|
||||
|
||||
class Workspace(Timestamped):
|
||||
slug: str = Field(pattern=r"^[a-z0-9][a-z0-9-]{0,62}$")
|
||||
name: str = Field(min_length=1, max_length=160)
|
||||
status: str = Field(default="active", min_length=1, max_length=32)
|
||||
|
||||
|
||||
class Project(Timestamped):
|
||||
workspace_id: UUID
|
||||
slug: str = Field(pattern=r"^[a-z0-9][a-z0-9-]{0,62}$")
|
||||
name: str = Field(min_length=1, max_length=160)
|
||||
description: str | None = Field(default=None, max_length=1000)
|
||||
status: str = Field(default="active", max_length=32)
|
||||
repository_provider: str | None = Field(default=None, max_length=32)
|
||||
repository_owner: str | None = Field(default=None, max_length=120)
|
||||
repository_name: str | None = Field(default=None, max_length=120)
|
||||
classification: str = Field(default="internal", max_length=32)
|
||||
owner_metadata: dict[str, Any] = Field(default_factory=dict)
|
||||
|
||||
|
||||
class Environment(Timestamped):
|
||||
workspace_id: UUID
|
||||
project_id: UUID
|
||||
name: str = Field(pattern=r"^(dev|test|staging|prod)$")
|
||||
classification: str = Field(max_length=32)
|
||||
base_url: str | None = Field(default=None, max_length=2048)
|
||||
status: str = Field(default="active", max_length=32)
|
||||
|
||||
|
||||
class Connector(Timestamped):
|
||||
workspace_id: UUID
|
||||
project_id: UUID
|
||||
environment_id: UUID | None = None
|
||||
type: str = Field(min_length=1, max_length=64)
|
||||
slug: str = Field(pattern=r"^[a-z0-9][a-z0-9-]{0,62}$")
|
||||
endpoint: str | None = Field(default=None, max_length=2048)
|
||||
credential_reference: str | None = Field(default=None, max_length=512)
|
||||
configuration: dict[str, Any] = Field(default_factory=dict)
|
||||
enabled: bool = False
|
||||
health_state: str = Field(default="unconfigured", max_length=32)
|
||||
last_check_at: datetime | None = None
|
||||
latency_ms: int | None = Field(default=None, ge=0)
|
||||
stable_error_code: str | None = Field(default=None, max_length=128)
|
||||
sanitized_error: str | None = Field(default=None, max_length=1000)
|
||||
|
||||
|
||||
class RiskClass(DomainModel):
|
||||
code: str = Field(pattern=r"^[a-z0-9][a-z0-9-]{0,62}$")
|
||||
description: str = Field(min_length=1, max_length=500)
|
||||
allows_writes: bool = False
|
||||
max_output_bytes: int = Field(default=65536, ge=1024, le=10485760)
|
||||
|
||||
|
||||
class Capability(Timestamped):
|
||||
canonical_name: str = Field(pattern=r"^[a-z0-9][a-z0-9.-]{0,119}$")
|
||||
version: str = Field(min_length=1, max_length=32)
|
||||
description: str = Field(min_length=1, max_length=1000)
|
||||
risk_class: str = Field(min_length=1, max_length=64)
|
||||
enabled: bool = True
|
||||
|
||||
|
||||
class ToolDefinition(Timestamped):
|
||||
workspace_id: UUID
|
||||
project_id: UUID
|
||||
connector_id: UUID
|
||||
canonical_name: str = Field(pattern=r"^[a-z0-9][a-z0-9.-]{0,119}$")
|
||||
version: str = Field(min_length=1, max_length=32)
|
||||
input_schema: dict[str, Any] = Field(default_factory=dict)
|
||||
output_schema: dict[str, Any] = Field(default_factory=dict)
|
||||
timeout_ms: int = Field(default=10000, ge=100, le=120000)
|
||||
output_limit_bytes: int = Field(default=65536, ge=1024, le=10485760)
|
||||
rate_limit_per_minute: int = Field(default=60, ge=1, le=100000)
|
||||
redaction_profile: str = Field(default="default", max_length=64)
|
||||
lifecycle_status: str = Field(default="active", max_length=32)
|
||||
|
||||
|
||||
class VirtualServer(Timestamped):
|
||||
workspace_id: UUID
|
||||
project_id: UUID
|
||||
environment_id: UUID
|
||||
slug: str = Field(pattern=r"^[a-z0-9][a-z0-9-]{0,62}$")
|
||||
gateway_external_id: str | None = Field(default=None, max_length=256)
|
||||
desired_hash: str = Field(min_length=1, max_length=128)
|
||||
enabled: bool = False
|
||||
protocol: str = Field(default="streamable-http", max_length=64)
|
||||
health_state: str = Field(default="unconfigured", max_length=32)
|
||||
metadata: dict[str, Any] = Field(default_factory=dict)
|
||||
|
||||
|
||||
class VirtualServerCapability(DomainModel):
|
||||
id: UUID = Field(default_factory=uuid4)
|
||||
workspace_id: UUID
|
||||
project_id: UUID
|
||||
virtual_server_id: UUID
|
||||
capability_id: UUID
|
||||
desired_state_source: str = Field(default="catalog", max_length=64)
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
|
||||
|
||||
class Client(Timestamped):
|
||||
workspace_id: UUID
|
||||
subject: str = Field(min_length=1, max_length=256)
|
||||
issuer: str = Field(min_length=1, max_length=512)
|
||||
client_type: str = Field(default="mcp", max_length=32)
|
||||
status: str = Field(default="pending", max_length=32)
|
||||
revoked_at: datetime | None = None
|
||||
last_seen_at: datetime | None = None
|
||||
metadata: dict[str, Any] = Field(default_factory=dict)
|
||||
|
||||
|
||||
class ClientBinding(Timestamped):
|
||||
workspace_id: UUID
|
||||
project_id: UUID
|
||||
client_id: UUID
|
||||
virtual_server_id: UUID
|
||||
capability_pack: str | None = Field(default=None, max_length=128)
|
||||
constraints: dict[str, Any] = Field(default_factory=dict)
|
||||
enabled: bool = False
|
||||
|
||||
|
||||
class Policy(Timestamped):
|
||||
workspace_id: UUID
|
||||
project_id: UUID | None = None
|
||||
slug: str = Field(pattern=r"^[a-z0-9][a-z0-9-]{0,62}$")
|
||||
priority: int = Field(ge=0, le=100000)
|
||||
description: str = Field(min_length=1, max_length=1000)
|
||||
match: dict[str, Any] = Field(default_factory=dict)
|
||||
conditions: dict[str, Any] = Field(default_factory=dict)
|
||||
decision: str = Field(pattern=r"^(allow|allow_if_conditions|deny|deny_and_alert|enforce)$")
|
||||
terminal: bool = False
|
||||
source: str = Field(default="catalog", max_length=64)
|
||||
enabled: bool = True
|
||||
|
||||
|
||||
class PolicyDecision(DomainModel):
|
||||
id: UUID = Field(default_factory=uuid4)
|
||||
workspace_id: UUID
|
||||
project_id: UUID | None = None
|
||||
policy_id: UUID | None = None
|
||||
decision: str = Field(min_length=1, max_length=32)
|
||||
matched_rule_ids: list[str] = Field(default_factory=list)
|
||||
decision_trace: list[dict[str, Any]] = Field(default_factory=list)
|
||||
reason_code: str | None = Field(default=None, max_length=128)
|
||||
trace_id: str = Field(min_length=1, max_length=128)
|
||||
retention_until: datetime | None = None
|
||||
created_at: datetime
|
||||
|
||||
|
||||
class ReconcileRun(Timestamped):
|
||||
workspace_id: UUID
|
||||
project_id: UUID
|
||||
desired_hash: str = Field(min_length=1, max_length=128)
|
||||
current_hash: str | None = Field(default=None, max_length=128)
|
||||
plan_hash: str | None = Field(default=None, max_length=128)
|
||||
status: str = Field(default="created", max_length=32)
|
||||
planned_changes: list[dict[str, Any]] = Field(default_factory=list)
|
||||
applied_changes: list[dict[str, Any]] = Field(default_factory=list)
|
||||
verification: dict[str, Any] = Field(default_factory=dict)
|
||||
started_at: datetime | None = None
|
||||
completed_at: datetime | None = None
|
||||
|
||||
|
||||
class Operation(Timestamped):
|
||||
workspace_id: UUID
|
||||
project_id: UUID
|
||||
environment_id: UUID
|
||||
operation_type: str = Field(min_length=1, max_length=64)
|
||||
target_ref: str = Field(min_length=1, max_length=512)
|
||||
plan_hash: str = Field(min_length=1, max_length=128)
|
||||
state: str = Field(default="requested", max_length=32)
|
||||
requested_by: str = Field(min_length=1, max_length=256)
|
||||
expires_at: datetime | None = None
|
||||
verification: dict[str, Any] = Field(default_factory=dict)
|
||||
|
||||
|
||||
class Approval(Timestamped):
|
||||
workspace_id: UUID
|
||||
project_id: UUID
|
||||
operation_id: UUID
|
||||
plan_hash: str = Field(min_length=1, max_length=128)
|
||||
state: str = Field(default="pending", max_length=32)
|
||||
actor: str = Field(min_length=1, max_length=256)
|
||||
reason: str | None = Field(default=None, max_length=1000)
|
||||
expires_at: datetime
|
||||
approved_at: datetime | None = None
|
||||
|
||||
|
||||
class AuditEvent(DomainModel):
|
||||
id: UUID = Field(default_factory=uuid4)
|
||||
occurred_at: datetime
|
||||
trace_id: str = Field(min_length=1, max_length=128)
|
||||
workspace_id: UUID
|
||||
project_id: UUID | None = None
|
||||
environment_id: UUID | None = None
|
||||
event_type: str = Field(min_length=1, max_length=128)
|
||||
principal_issuer: str | None = Field(default=None, max_length=512)
|
||||
principal_subject: str | None = Field(default=None, max_length=256)
|
||||
client_id: UUID | None = None
|
||||
virtual_server_id: UUID | None = None
|
||||
tool_name: str | None = Field(default=None, max_length=256)
|
||||
policy_decision: str | None = Field(default=None, max_length=32)
|
||||
matched_rule_ids: list[str] = Field(default_factory=list)
|
||||
policy_trace: list[dict[str, Any]] = Field(default_factory=list)
|
||||
arguments_hash: str | None = Field(default=None, max_length=128)
|
||||
sanitized_arguments: dict[str, Any] = Field(default_factory=dict)
|
||||
duration_ms: int | None = Field(default=None, ge=0)
|
||||
outcome: str = Field(min_length=1, max_length=32)
|
||||
error_code: str | None = Field(default=None, max_length=128)
|
||||
retention_until: datetime | None = None
|
||||
operation_id: UUID | None = None
|
||||
|
||||
|
||||
class KnowledgeSource(Timestamped):
|
||||
workspace_id: UUID
|
||||
project_id: UUID
|
||||
slug: str = Field(pattern=r"^[a-z0-9][a-z0-9-]{0,62}$")
|
||||
source_type: str = Field(min_length=1, max_length=64)
|
||||
uri: str = Field(min_length=1, max_length=2048)
|
||||
visibility: str = Field(default="project", max_length=32)
|
||||
state: str = Field(default="registered", max_length=32)
|
||||
configuration: dict[str, Any] = Field(default_factory=dict)
|
||||
last_indexed_at: datetime | None = None
|
||||
|
||||
|
||||
class KnowledgeDocument(Timestamped):
|
||||
workspace_id: UUID
|
||||
project_id: UUID
|
||||
source_id: UUID
|
||||
external_ref: str = Field(min_length=1, max_length=1024)
|
||||
title: str = Field(min_length=1, max_length=500)
|
||||
content_hash: str = Field(min_length=1, max_length=128)
|
||||
document_version: int = Field(default=1, ge=1)
|
||||
state: str = Field(default="registered", max_length=32)
|
||||
content_bytes: int = Field(default=0, ge=0, le=104857600)
|
||||
metadata: dict[str, Any] = Field(default_factory=dict)
|
||||
|
||||
|
||||
class KnowledgeChunk(Timestamped):
|
||||
workspace_id: UUID
|
||||
project_id: UUID
|
||||
document_id: UUID
|
||||
chunk_index: int = Field(ge=0)
|
||||
text: str = Field(min_length=1, max_length=200000)
|
||||
content_hash: str = Field(min_length=1, max_length=128)
|
||||
token_count: int = Field(default=0, ge=0)
|
||||
metadata: dict[str, Any] = Field(default_factory=dict)
|
||||
@@ -0,0 +1,21 @@
|
||||
"""Domain errors shared by persistence and application services."""
|
||||
|
||||
|
||||
class DomainError(ValueError):
|
||||
"""Base class for expected domain validation failures."""
|
||||
|
||||
|
||||
class InvalidTransition(DomainError):
|
||||
"""Raised when a state machine transition is not permitted."""
|
||||
|
||||
|
||||
class MissingProjectScope(DomainError):
|
||||
"""Raised when a project-owned repository is created without a project scope."""
|
||||
|
||||
|
||||
class ConcurrentUpdateError(DomainError):
|
||||
"""Raised when an optimistic version check detects a stale write."""
|
||||
|
||||
|
||||
class AuditMutationError(DomainError):
|
||||
"""Raised when ordinary application code attempts to mutate an audit event."""
|
||||
@@ -0,0 +1,75 @@
|
||||
"""Explicit state transition tables for control-plane records."""
|
||||
|
||||
from collections.abc import Mapping
|
||||
|
||||
from .errors import InvalidTransition
|
||||
|
||||
TRANSITIONS: Mapping[str, Mapping[str, frozenset[str]]] = {
|
||||
"connector_health": {
|
||||
"unconfigured": frozenset({"checking", "disabled"}),
|
||||
"checking": frozenset({"healthy", "degraded", "unavailable", "misconfigured", "disabled"}),
|
||||
"healthy": frozenset({"checking", "degraded", "unavailable", "disabled"}),
|
||||
"degraded": frozenset({"checking", "healthy", "unavailable", "misconfigured", "disabled"}),
|
||||
"unavailable": frozenset({"checking", "degraded", "misconfigured", "disabled"}),
|
||||
"misconfigured": frozenset({"checking", "disabled"}),
|
||||
"disabled": frozenset(),
|
||||
},
|
||||
"reconcile": {
|
||||
"created": frozenset({"planning"}),
|
||||
"planning": frozenset({"planned", "invalid"}),
|
||||
"planned": frozenset({"applying", "invalid"}),
|
||||
"applying": frozenset({"verifying", "failed"}),
|
||||
"verifying": frozenset({"succeeded", "failed"}),
|
||||
"succeeded": frozenset(),
|
||||
"invalid": frozenset(),
|
||||
"failed": frozenset(),
|
||||
},
|
||||
"client": {
|
||||
"pending": frozenset({"active", "disabled"}),
|
||||
"active": frozenset({"disabled", "revoked"}),
|
||||
"disabled": frozenset({"active", "revoked"}),
|
||||
"revoked": frozenset(),
|
||||
},
|
||||
"operation": {
|
||||
"requested": frozenset({"planned", "rejected"}),
|
||||
"planned": frozenset({"approval_required", "rejected"}),
|
||||
"approval_required": frozenset({"approved", "rejected"}),
|
||||
"approved": frozenset({"executing", "rejected"}),
|
||||
"executing": frozenset({"verifying", "failed"}),
|
||||
"verifying": frozenset({"succeeded", "failed"}),
|
||||
"failed": frozenset({"rollback_pending"}),
|
||||
"rollback_pending": frozenset({"rolled_back", "rollback_failed"}),
|
||||
"rolled_back": frozenset(),
|
||||
"rollback_failed": frozenset(),
|
||||
"succeeded": frozenset(),
|
||||
"rejected": frozenset(),
|
||||
},
|
||||
"approval": {
|
||||
"pending": frozenset({"approved", "rejected", "expired"}),
|
||||
"approved": frozenset(),
|
||||
"rejected": frozenset(),
|
||||
"expired": frozenset(),
|
||||
},
|
||||
"knowledge": {
|
||||
"registered": frozenset({"queued"}),
|
||||
"queued": frozenset({"indexing", "failed"}),
|
||||
"indexing": frozenset({"current", "failed"}),
|
||||
"current": frozenset({"stale"}),
|
||||
"stale": frozenset({"queued"}),
|
||||
"failed": frozenset({"queued"}),
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def transition(machine: str, current: str, target: str) -> str:
|
||||
"""Validate and return a target state for a named state machine."""
|
||||
|
||||
table = TRANSITIONS.get(machine)
|
||||
if table is None:
|
||||
raise InvalidTransition(f"unknown state machine: {machine}")
|
||||
allowed = table.get(current)
|
||||
if allowed is None:
|
||||
raise InvalidTransition(f"unknown {machine} state: {current}")
|
||||
if target not in allowed:
|
||||
raise InvalidTransition(f"invalid {machine} transition: {current} -> {target}")
|
||||
return target
|
||||
@@ -0,0 +1,25 @@
|
||||
"""Small, dependency-light observability primitives for every Hub service."""
|
||||
|
||||
from .telemetry import (
|
||||
MetricsRegistry,
|
||||
StructuredLogger,
|
||||
Telemetry,
|
||||
TraceContext,
|
||||
build_telemetry,
|
||||
new_trace_id,
|
||||
sanitize_fields,
|
||||
trace_from_headers,
|
||||
valid_trace_id,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
"MetricsRegistry",
|
||||
"StructuredLogger",
|
||||
"Telemetry",
|
||||
"TraceContext",
|
||||
"build_telemetry",
|
||||
"new_trace_id",
|
||||
"sanitize_fields",
|
||||
"trace_from_headers",
|
||||
"valid_trace_id",
|
||||
]
|
||||
@@ -0,0 +1,265 @@
|
||||
"""Structured telemetry with W3C trace propagation and OTLP/HTTP export.
|
||||
|
||||
The module intentionally uses the standard library so the health process and
|
||||
connectors can share the same contract without adding a runtime dependency.
|
||||
Export is best-effort and never puts authorization, cookies, request bodies,
|
||||
or secret values into logs, metrics, or spans.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import secrets
|
||||
import sys
|
||||
import time
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
from collections.abc import Iterator, Mapping
|
||||
from contextlib import contextmanager
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import UTC, datetime
|
||||
from typing import Any, TextIO
|
||||
|
||||
_TRACE_ID = re.compile(r"^[0-9a-f]{32}$")
|
||||
_SPAN_ID = re.compile(r"^[0-9a-f]{16}$")
|
||||
_SENSITIVE = re.compile(
|
||||
r"(?:authorization|cookie|password|passwd|secret|token|credential|private.?key|api.?key)",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
_METRIC = re.compile(r"[^a-zA-Z0-9_:]")
|
||||
|
||||
|
||||
def new_trace_id() -> str:
|
||||
return secrets.token_hex(16)
|
||||
|
||||
|
||||
def _new_span_id() -> str:
|
||||
return secrets.token_hex(8)
|
||||
|
||||
|
||||
def valid_trace_id(value: str | None) -> str | None:
|
||||
if value is None:
|
||||
return None
|
||||
candidate = value.strip().lower()
|
||||
return candidate if _TRACE_ID.fullmatch(candidate) else None
|
||||
|
||||
|
||||
def trace_from_headers(headers: Mapping[str, str]) -> TraceContext:
|
||||
"""Accept a W3C traceparent trace-id, otherwise create a new root trace."""
|
||||
|
||||
traceparent = headers.get("traceparent", headers.get("Traceparent", ""))
|
||||
pieces = traceparent.split("-")
|
||||
trace_id = valid_trace_id(pieces[1] if len(pieces) == 4 else None)
|
||||
parent = pieces[2].lower() if len(pieces) == 4 and _SPAN_ID.fullmatch(pieces[2]) else None
|
||||
return TraceContext(trace_id=trace_id or new_trace_id(), parent_span_id=parent)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class TraceContext:
|
||||
trace_id: str
|
||||
parent_span_id: str | None = None
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
if not _TRACE_ID.fullmatch(self.trace_id):
|
||||
raise ValueError("trace_id must be 32 lowercase hexadecimal characters")
|
||||
|
||||
def child(self) -> TraceContext:
|
||||
return TraceContext(self.trace_id, _new_span_id())
|
||||
|
||||
def response_headers(self) -> dict[str, str]:
|
||||
return {"X-Trace-Id": self.trace_id}
|
||||
|
||||
|
||||
def sanitize_fields(value: Any, *, _key: str = "") -> Any:
|
||||
"""Return JSON-safe fields with sensitive keys replaced and bounded values."""
|
||||
|
||||
if _key and _SENSITIVE.search(_key):
|
||||
return "[REDACTED]"
|
||||
if isinstance(value, Mapping):
|
||||
return {str(key): sanitize_fields(item, _key=str(key)) for key, item in value.items()}
|
||||
if isinstance(value, list | tuple | set):
|
||||
return [sanitize_fields(item) for item in value][:100]
|
||||
if isinstance(value, str | int | float | bool) or value is None:
|
||||
return str(value)[:512] if isinstance(value, str) else value
|
||||
return str(value)[:512]
|
||||
|
||||
|
||||
class StructuredLogger:
|
||||
def __init__(self, service: str, *, stream: TextIO | None = None) -> None:
|
||||
self.service = service[:64]
|
||||
self.stream = stream or sys.stdout
|
||||
|
||||
def emit(
|
||||
self,
|
||||
event_code: str,
|
||||
trace_id: str,
|
||||
*,
|
||||
level: str = "info",
|
||||
fields: Mapping[str, Any] | None = None,
|
||||
) -> dict[str, Any]:
|
||||
safe_trace_id = valid_trace_id(trace_id) or new_trace_id()
|
||||
record = {
|
||||
"timestamp": datetime.now(UTC).isoformat(),
|
||||
"service": self.service,
|
||||
"version": os.environ.get("APP_VERSION", "dev")[:64],
|
||||
"environment": os.environ.get("APP_ENV", "unknown")[:32],
|
||||
"traceId": safe_trace_id,
|
||||
"eventCode": event_code[:96],
|
||||
"level": level[:16],
|
||||
"fields": sanitize_fields(fields or {}),
|
||||
}
|
||||
self.stream.write(json.dumps(record, sort_keys=True, separators=(",", ":")) + "\n")
|
||||
self.stream.flush()
|
||||
return record
|
||||
|
||||
|
||||
@dataclass
|
||||
class _Metric:
|
||||
kind: str
|
||||
value: float = 0.0
|
||||
observations: list[float] = field(default_factory=list)
|
||||
|
||||
|
||||
class MetricsRegistry:
|
||||
"""In-process metrics with a stable Prometheus text representation."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self._metrics: dict[tuple[str, tuple[tuple[str, str], ...]], _Metric] = {}
|
||||
|
||||
@staticmethod
|
||||
def _name(name: str) -> str:
|
||||
normalized = _METRIC.sub("_", name).strip("_").lower()
|
||||
if not normalized or normalized[0].isdigit():
|
||||
raise ValueError("metric name must start with a letter or underscore")
|
||||
return normalized
|
||||
|
||||
@staticmethod
|
||||
def _labels(labels: Mapping[str, str] | None) -> tuple[tuple[str, str], ...]:
|
||||
if not labels:
|
||||
return ()
|
||||
safe = []
|
||||
for key, value in sorted(labels.items()):
|
||||
if _SENSITIVE.search(key):
|
||||
continue
|
||||
safe.append((_METRIC.sub("_", key)[:32], str(value)[:64]))
|
||||
return tuple(safe)
|
||||
|
||||
def _get(self, name: str, labels: Mapping[str, str] | None, kind: str) -> _Metric:
|
||||
key = (self._name(name), self._labels(labels))
|
||||
metric = self._metrics.setdefault(key, _Metric(kind=kind))
|
||||
if metric.kind != kind:
|
||||
raise ValueError(f"metric {name!r} changed type")
|
||||
return metric
|
||||
|
||||
def inc(
|
||||
self, name: str, amount: float = 1.0, *, labels: Mapping[str, str] | None = None
|
||||
) -> None:
|
||||
if amount < 0:
|
||||
raise ValueError("counter increments cannot be negative")
|
||||
self._get(name, labels, "counter").value += amount
|
||||
|
||||
def set_gauge(
|
||||
self, name: str, value: float, *, labels: Mapping[str, str] | None = None
|
||||
) -> None:
|
||||
self._get(name, labels, "gauge").value = value
|
||||
|
||||
def observe(self, name: str, value: float, *, labels: Mapping[str, str] | None = None) -> None:
|
||||
metric = self._get(name, labels, "histogram")
|
||||
metric.observations.append(max(0.0, value))
|
||||
|
||||
def render_prometheus(self) -> str:
|
||||
lines: list[str] = []
|
||||
for (name, labels), metric in sorted(self._metrics.items()):
|
||||
label_text = ""
|
||||
if labels:
|
||||
parts = []
|
||||
for key, value in labels:
|
||||
escaped = value.replace(chr(92), chr(92) * 2).replace(
|
||||
chr(34), chr(92) + chr(34)
|
||||
)
|
||||
parts.append(f'{key}="{escaped}"')
|
||||
label_text = "{" + ",".join(parts) + "}"
|
||||
if metric.kind == "histogram":
|
||||
total = sum(metric.observations)
|
||||
count = len(metric.observations)
|
||||
lines.extend(
|
||||
[f"{name}_count{label_text} {count}", f"{name}_sum{label_text} {total:g}"]
|
||||
)
|
||||
else:
|
||||
lines.append(f"{name}{label_text} {metric.value:g}")
|
||||
return "\n".join(lines) + ("\n" if lines else "")
|
||||
|
||||
|
||||
class OTLPHttpExporter:
|
||||
def __init__(self, endpoint: str, *, timeout: float = 1.0) -> None:
|
||||
if not endpoint.startswith(("http://", "https://")):
|
||||
raise ValueError("OTLP endpoint must use http or https")
|
||||
self.endpoint = endpoint.rstrip("/") + "/v1/traces"
|
||||
self.timeout = max(0.1, min(timeout, 5.0))
|
||||
|
||||
def export(self, span: Mapping[str, Any]) -> bool:
|
||||
payload = json.dumps(
|
||||
{"resourceSpans": [{"scopeSpans": [{"spans": [sanitize_fields(span)]}]}]}
|
||||
).encode()
|
||||
request = urllib.request.Request( # noqa: S310
|
||||
self.endpoint,
|
||||
data=payload,
|
||||
headers={"Content-Type": "application/json"},
|
||||
method="POST",
|
||||
)
|
||||
try:
|
||||
with urllib.request.urlopen(request, timeout=self.timeout) as response: # noqa: S310
|
||||
return 200 <= int(response.status) < 300
|
||||
except (OSError, urllib.error.URLError):
|
||||
return False
|
||||
|
||||
|
||||
class Telemetry:
|
||||
def __init__(
|
||||
self,
|
||||
service: str,
|
||||
*,
|
||||
stream: TextIO | None = None,
|
||||
exporter: OTLPHttpExporter | None = None,
|
||||
) -> None:
|
||||
self.logger = StructuredLogger(service, stream=stream)
|
||||
self.metrics = MetricsRegistry()
|
||||
self.exporter = exporter
|
||||
|
||||
@contextmanager
|
||||
def span(
|
||||
self, name: str, context: TraceContext, *, attributes: Mapping[str, Any] | None = None
|
||||
) -> Iterator[TraceContext]:
|
||||
child = context.child()
|
||||
start = time.perf_counter()
|
||||
status = "ok"
|
||||
try:
|
||||
yield child
|
||||
except Exception:
|
||||
status = "error"
|
||||
raise
|
||||
finally:
|
||||
duration = time.perf_counter() - start
|
||||
self.metrics.inc("itworx_requests_total", labels={"operation": name, "status": status})
|
||||
self.metrics.observe(
|
||||
"itworx_request_duration_seconds", duration, labels={"operation": name}
|
||||
)
|
||||
span = {
|
||||
"traceId": child.trace_id,
|
||||
"spanId": child.parent_span_id or _new_span_id(),
|
||||
"name": name[:128],
|
||||
"startTimeUnixNano": int((time.time() - duration) * 1_000_000_000),
|
||||
"endTimeUnixNano": int(time.time() * 1_000_000_000),
|
||||
"status": {"code": status},
|
||||
"attributes": attributes or {},
|
||||
}
|
||||
if self.exporter:
|
||||
self.exporter.export(span)
|
||||
|
||||
|
||||
def build_telemetry(service: str, *, stream: TextIO | None = None) -> Telemetry:
|
||||
endpoint = os.environ.get("OTEL_EXPORTER_OTLP_ENDPOINT", "").strip()
|
||||
exporter = OTLPHttpExporter(endpoint) if endpoint else None
|
||||
return Telemetry(service, stream=stream, exporter=exporter)
|
||||
@@ -0,0 +1,20 @@
|
||||
"""Recovery and backup primitives used by the operational runbooks."""
|
||||
|
||||
from .backup import (
|
||||
BackupArtifact,
|
||||
BackupError,
|
||||
BackupManager,
|
||||
BackupPolicy,
|
||||
build_pg_dump_command,
|
||||
)
|
||||
from .recovery import RecoveryProbe, RecoveryReport
|
||||
|
||||
__all__ = [
|
||||
"BackupArtifact",
|
||||
"BackupError",
|
||||
"BackupManager",
|
||||
"BackupPolicy",
|
||||
"RecoveryProbe",
|
||||
"RecoveryReport",
|
||||
"build_pg_dump_command",
|
||||
]
|
||||
@@ -0,0 +1,243 @@
|
||||
"""Hash-verified, clean-room backup and restore primitives.
|
||||
|
||||
Archives contain only an explicit allowlist of non-secret inputs. Database
|
||||
dump commands use mounted service configuration and never accept a DSN on the
|
||||
command line, keeping credentials out of process listings and evidence.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
import shutil
|
||||
import subprocess
|
||||
import tarfile
|
||||
from collections.abc import Iterable, Mapping
|
||||
from dataclasses import dataclass
|
||||
from datetime import UTC, datetime, timedelta
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from packages.hub_observability import sanitize_fields
|
||||
|
||||
|
||||
class BackupError(ValueError):
|
||||
"""Raised for unsafe, corrupt, or incomplete backup operations."""
|
||||
|
||||
|
||||
_SECRET_NAME = {".env", ".env.example", "secrets", "secret", "password", "credentials"}
|
||||
|
||||
|
||||
def _sha256(path: Path) -> str:
|
||||
digest = hashlib.sha256()
|
||||
with path.open("rb") as handle:
|
||||
for chunk in iter(lambda: handle.read(1024 * 1024), b""):
|
||||
digest.update(chunk)
|
||||
return digest.hexdigest()
|
||||
|
||||
|
||||
def _safe_relative(root: Path, candidate: Path) -> Path:
|
||||
try:
|
||||
return candidate.resolve().relative_to(root.resolve())
|
||||
except ValueError as exc:
|
||||
raise BackupError("backup input must remain below its registered root") from exc
|
||||
|
||||
|
||||
def _is_secret_path(relative: Path) -> bool:
|
||||
return any(
|
||||
any(
|
||||
term in part.lower()
|
||||
for term in ("secret", "password", "credential", "token", "private_key")
|
||||
)
|
||||
or part.lower() in _SECRET_NAME
|
||||
for part in relative.parts
|
||||
)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class BackupPolicy:
|
||||
retention_days: int = 30
|
||||
max_artifacts: int = 14
|
||||
encryption_required_before_export: bool = True
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
if not 1 <= self.retention_days <= 3650:
|
||||
raise ValueError("retention_days must be between 1 and 3650")
|
||||
if not 1 <= self.max_artifacts <= 1000:
|
||||
raise ValueError("max_artifacts must be between 1 and 1000")
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class BackupArtifact:
|
||||
archive: Path
|
||||
manifest: Path
|
||||
sha256: str
|
||||
bytes: int
|
||||
entries: tuple[str, ...]
|
||||
created_at: str
|
||||
|
||||
|
||||
class BackupManager:
|
||||
def __init__(self, backup_root: Path, *, policy: BackupPolicy | None = None) -> None:
|
||||
self.backup_root = backup_root.resolve()
|
||||
self.policy = policy or BackupPolicy()
|
||||
|
||||
def create_archive(
|
||||
self,
|
||||
source_root: Path,
|
||||
*,
|
||||
name: str,
|
||||
include: Iterable[Path],
|
||||
metadata: Mapping[str, Any] | None = None,
|
||||
) -> BackupArtifact:
|
||||
if not name or Path(name).name != name or name in {".", ".."}:
|
||||
raise BackupError("backup name must be a single safe path component")
|
||||
root = source_root.resolve()
|
||||
self.backup_root.mkdir(parents=True, exist_ok=True)
|
||||
archive = self.backup_root / f"{name}.tar.gz"
|
||||
manifest_path = self.backup_root / f"{name}.manifest.json"
|
||||
entries: list[str] = []
|
||||
with tarfile.open(archive, "w:gz") as bundle:
|
||||
for raw in include:
|
||||
candidate = (root / raw).resolve() if not raw.is_absolute() else raw.resolve()
|
||||
relative = _safe_relative(root, candidate)
|
||||
if _is_secret_path(relative):
|
||||
raise BackupError(
|
||||
f"secret-bearing path is not eligible for ordinary backup: {relative}"
|
||||
)
|
||||
if not candidate.exists():
|
||||
raise BackupError(f"backup input does not exist: {relative}")
|
||||
if candidate.is_symlink() or (
|
||||
candidate.is_dir() and any(item.is_symlink() for item in candidate.rglob("*"))
|
||||
):
|
||||
raise BackupError("symlink backup inputs are not allowed")
|
||||
bundle.add(candidate, arcname=relative.as_posix(), recursive=True)
|
||||
entries.append(relative.as_posix())
|
||||
entries = sorted(set(entries))
|
||||
created_at = datetime.now(UTC).isoformat()
|
||||
digest = _sha256(archive)
|
||||
manifest = {
|
||||
"schemaVersion": "1.0",
|
||||
"artifact": archive.name,
|
||||
"sha256": digest,
|
||||
"bytes": archive.stat().st_size,
|
||||
"createdAt": created_at,
|
||||
"entries": entries,
|
||||
"metadata": sanitize_fields(dict(metadata or {})),
|
||||
"encryption": "required-before-export"
|
||||
if self.policy.encryption_required_before_export
|
||||
else "not-required",
|
||||
"retention": {
|
||||
"days": self.policy.retention_days,
|
||||
"maxArtifacts": self.policy.max_artifacts,
|
||||
},
|
||||
}
|
||||
manifest_path.write_text(
|
||||
json.dumps(manifest, indent=2, sort_keys=True) + "\n", encoding="utf-8"
|
||||
)
|
||||
archive.with_suffix(archive.suffix + ".sha256").write_text(
|
||||
f"{digest} {archive.name}\n", encoding="utf-8"
|
||||
)
|
||||
return BackupArtifact(
|
||||
archive, manifest_path, digest, archive.stat().st_size, tuple(entries), created_at
|
||||
)
|
||||
|
||||
def verify(self, artifact: BackupArtifact | Path) -> BackupArtifact:
|
||||
archive = artifact.archive if isinstance(artifact, BackupArtifact) else artifact
|
||||
manifest_path = archive.with_name(archive.name.removesuffix(".tar.gz") + ".manifest.json")
|
||||
if not archive.is_file() or not manifest_path.is_file():
|
||||
raise BackupError("backup archive and manifest are both required")
|
||||
raw = json.loads(manifest_path.read_text(encoding="utf-8"))
|
||||
digest = _sha256(archive)
|
||||
if raw.get("sha256") != digest or raw.get("bytes") != archive.stat().st_size:
|
||||
raise BackupError("backup integrity metadata does not match archive")
|
||||
entries = tuple(str(item) for item in raw.get("entries", []))
|
||||
if any(Path(item).is_absolute() or ".." in Path(item).parts for item in entries):
|
||||
raise BackupError("backup manifest contains an unsafe entry")
|
||||
return BackupArtifact(
|
||||
archive, manifest_path, digest, archive.stat().st_size, entries, str(raw["createdAt"])
|
||||
)
|
||||
|
||||
def restore_clean(self, artifact: BackupArtifact | Path, target_root: Path) -> BackupArtifact:
|
||||
verified = self.verify(artifact)
|
||||
target = target_root.resolve()
|
||||
if target.exists() and any(target.iterdir()):
|
||||
raise BackupError("clean restore target must not contain existing files")
|
||||
target.mkdir(parents=True, exist_ok=True)
|
||||
with tarfile.open(verified.archive, "r:gz") as bundle:
|
||||
members = bundle.getmembers()
|
||||
for member in members:
|
||||
member_path = Path(member.name)
|
||||
if (
|
||||
member_path.is_absolute()
|
||||
or ".." in member_path.parts
|
||||
or member.issym()
|
||||
or member.islnk()
|
||||
):
|
||||
raise BackupError("archive contains an unsafe member")
|
||||
if member.isdir():
|
||||
continue
|
||||
destination = (target / member_path).resolve()
|
||||
try:
|
||||
destination.relative_to(target)
|
||||
except ValueError as exc:
|
||||
raise BackupError("archive member escapes restore target") from exc
|
||||
destination.parent.mkdir(parents=True, exist_ok=True)
|
||||
source = bundle.extractfile(member)
|
||||
if source is None:
|
||||
raise BackupError("archive member could not be read")
|
||||
with destination.open("wb") as output:
|
||||
shutil.copyfileobj(source, output)
|
||||
return verified
|
||||
|
||||
def prune(self, *, now: datetime | None = None) -> list[Path]:
|
||||
current = now or datetime.now(UTC)
|
||||
manifests = sorted(
|
||||
self.backup_root.glob("*.manifest.json"),
|
||||
key=lambda item: item.stat().st_mtime,
|
||||
reverse=True,
|
||||
)
|
||||
removed: list[Path] = []
|
||||
for index, manifest in enumerate(manifests):
|
||||
too_old = datetime.fromtimestamp(manifest.stat().st_mtime, UTC) < current - timedelta(
|
||||
days=self.policy.retention_days
|
||||
)
|
||||
if index >= self.policy.max_artifacts or too_old:
|
||||
archive = manifest.with_name(
|
||||
manifest.name.removesuffix(".manifest.json") + ".tar.gz"
|
||||
)
|
||||
for path in (manifest, archive, archive.with_suffix(archive.suffix + ".sha256")):
|
||||
if path.exists():
|
||||
path.unlink()
|
||||
removed.append(path)
|
||||
return removed
|
||||
|
||||
|
||||
def build_pg_dump_command(
|
||||
database_name: str, output_path: Path, *, executable: str = "pg_dump"
|
||||
) -> list[str]:
|
||||
"""Build a password-free command; libpq reads credentials from its mounted service file."""
|
||||
|
||||
if not database_name or database_name.startswith("-") or "/" in database_name:
|
||||
raise BackupError("database name is not a safe identifier")
|
||||
if output_path.name != output_path.name.replace(" ", "") or output_path.suffix != ".dump":
|
||||
raise BackupError("database dump output must be a simple .dump filename")
|
||||
return [
|
||||
executable,
|
||||
"--format=custom",
|
||||
"--no-owner",
|
||||
"--no-privileges",
|
||||
"--dbname",
|
||||
database_name,
|
||||
"--file",
|
||||
str(output_path),
|
||||
]
|
||||
|
||||
|
||||
def run_pg_dump(
|
||||
database_name: str, output_path: Path, *, environment: Mapping[str, str] | None = None
|
||||
) -> None:
|
||||
command = build_pg_dump_command(database_name, output_path)
|
||||
safe_environment = dict(environment or os.environ)
|
||||
subprocess.run(command, check=True, env=safe_environment, stdout=subprocess.DEVNULL) # noqa: S603
|
||||
@@ -0,0 +1,66 @@
|
||||
"""Deterministic recovery assertions used by clean-room restore tests."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Callable, Mapping
|
||||
from dataclasses import dataclass
|
||||
from typing import Any
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class RecoveryReport:
|
||||
checks: Mapping[str, bool]
|
||||
details: Mapping[str, str]
|
||||
|
||||
@property
|
||||
def passed(self) -> bool:
|
||||
return all(self.checks.values())
|
||||
|
||||
|
||||
class RecoveryProbe:
|
||||
"""Prove recovery semantics without requiring a production credential."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
login: Callable[[], bool],
|
||||
discovery: Callable[[], bool],
|
||||
tool_call: Callable[[], bool],
|
||||
negative_auth: Callable[[], bool],
|
||||
audit_continuity: Callable[[], bool],
|
||||
) -> None:
|
||||
self._checks = {
|
||||
"login": login,
|
||||
"discovery": discovery,
|
||||
"toolCall": tool_call,
|
||||
"negativeAuth": negative_auth,
|
||||
"auditContinuity": audit_continuity,
|
||||
}
|
||||
|
||||
def run(self) -> RecoveryReport:
|
||||
results: dict[str, bool] = {}
|
||||
details: dict[str, str] = {}
|
||||
for name, check in self._checks.items():
|
||||
try:
|
||||
result = bool(check())
|
||||
except Exception as exc: # noqa: BLE001
|
||||
result = False
|
||||
details[name] = type(exc).__name__
|
||||
results[name] = result
|
||||
details.setdefault(name, "passed" if result else "failed")
|
||||
return RecoveryReport(results, details)
|
||||
|
||||
|
||||
def connector_isolation_report(statuses: Mapping[str, str]) -> dict[str, Any]:
|
||||
"""A failed connector degrades only itself and never grants access elsewhere."""
|
||||
|
||||
normalized = {str(name): str(status) for name, status in statuses.items()}
|
||||
failed = sorted(
|
||||
name for name, status in normalized.items() if status not in {"healthy", "ready"}
|
||||
)
|
||||
return {
|
||||
"status": "degraded" if failed else "healthy",
|
||||
"failedConnectors": failed,
|
||||
"isolated": True,
|
||||
"redisAuthoritative": False,
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
"""Deterministic, deny-first policy, audit, and rate-limit primitives."""
|
||||
|
||||
from .audit import AuditEvent, AuditLog, AuditPage, InMemoryAuditLog, sanitize_arguments
|
||||
from .engine import (
|
||||
Decision,
|
||||
DecisionTrace,
|
||||
InvocationContext,
|
||||
PolicyEngine,
|
||||
PolicyRule,
|
||||
PolicySimulator,
|
||||
PolicyValidationError,
|
||||
)
|
||||
from .governance import Governance, PolicyDenied, governed_tool
|
||||
from .rate_limit import RateLimitExceeded, SlidingWindowRateLimiter
|
||||
|
||||
__all__ = [
|
||||
"AuditEvent",
|
||||
"AuditLog",
|
||||
"AuditPage",
|
||||
"Decision",
|
||||
"DecisionTrace",
|
||||
"Governance",
|
||||
"governed_tool",
|
||||
"InMemoryAuditLog",
|
||||
"InvocationContext",
|
||||
"PolicyDenied",
|
||||
"PolicyEngine",
|
||||
"PolicyRule",
|
||||
"PolicySimulator",
|
||||
"PolicyValidationError",
|
||||
"RateLimitExceeded",
|
||||
"SlidingWindowRateLimiter",
|
||||
"sanitize_arguments",
|
||||
]
|
||||
@@ -0,0 +1,165 @@
|
||||
"""Sanitized append-only audit ingestion, search, retention, and export metadata."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
import re
|
||||
from collections.abc import Mapping
|
||||
from datetime import UTC, datetime, timedelta
|
||||
from typing import Any, Protocol
|
||||
from uuid import UUID, uuid4
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, Field
|
||||
|
||||
from .engine import DecisionTrace
|
||||
|
||||
_SECRET_VALUE = re.compile(r"(?:bearer\s+|eyJ[A-Za-z0-9_-]{20,}|-----BEGIN[^-]+-----)", re.I)
|
||||
|
||||
_SENSITIVE = re.compile(
|
||||
r"(?:password|secret|token|private|authorization|cookie|credential|api[_-]?key)", re.I
|
||||
)
|
||||
|
||||
|
||||
def sanitize_arguments(value: Any, *, max_bytes: int = 16_384, _depth: int = 0) -> Any:
|
||||
if _depth > 5:
|
||||
return "<depth-limited>"
|
||||
result: Any
|
||||
if isinstance(value, Mapping):
|
||||
result = {
|
||||
str(key)[:80]: (
|
||||
"<redacted>"
|
||||
if _SENSITIVE.search(str(key))
|
||||
else sanitize_arguments(item, max_bytes=max_bytes, _depth=_depth + 1)
|
||||
)
|
||||
for key, item in list(value.items())[:64]
|
||||
}
|
||||
elif isinstance(value, list | tuple):
|
||||
result = [
|
||||
sanitize_arguments(item, max_bytes=max_bytes, _depth=_depth + 1)
|
||||
for item in list(value)[:64]
|
||||
]
|
||||
elif isinstance(value, str | int | float | bool) or value is None:
|
||||
if isinstance(value, str) and _SECRET_VALUE.search(value):
|
||||
result = "<redacted>"
|
||||
else:
|
||||
result = value if not isinstance(value, str) else value[:2048]
|
||||
else:
|
||||
result = f"<{type(value).__name__}>"
|
||||
if len(json.dumps(result, sort_keys=True, default=str).encode()) > max_bytes:
|
||||
return "<arguments-truncated>"
|
||||
return result
|
||||
|
||||
|
||||
class AuditEvent(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid", frozen=True)
|
||||
|
||||
id: UUID = Field(default_factory=uuid4)
|
||||
occurred_at: datetime = Field(default_factory=lambda: datetime.now(UTC))
|
||||
trace_id: str = Field(min_length=1, max_length=128)
|
||||
event_type: str = Field(default="mcp.tool.invoked", max_length=128)
|
||||
workspace: str = Field(min_length=1, max_length=128)
|
||||
project: str = Field(min_length=1, max_length=128)
|
||||
environment: str = Field(min_length=1, max_length=32)
|
||||
principal: str = Field(min_length=1, max_length=256)
|
||||
client_id: UUID | None = None
|
||||
tool: str = Field(min_length=1, max_length=256)
|
||||
policy_decision: str
|
||||
matched_rule_ids: tuple[str, ...] = ()
|
||||
policy_trace: tuple[dict[str, Any], ...] = ()
|
||||
arguments_hash: str = Field(pattern=r"^[0-9a-f]{64}$")
|
||||
sanitized_arguments: dict[str, Any] = Field(default_factory=dict)
|
||||
duration_ms: int = Field(ge=0, le=120_000, default=0)
|
||||
outcome: str = Field(pattern=r"^(success|denied|error|rate_limited)$")
|
||||
error_code: str | None = None
|
||||
retention_until: datetime | None = None
|
||||
|
||||
@classmethod
|
||||
def from_context(
|
||||
cls,
|
||||
context: Any,
|
||||
trace: DecisionTrace,
|
||||
*,
|
||||
outcome: str,
|
||||
started: float,
|
||||
error_code: str | None = None,
|
||||
result_summary: dict[str, Any] | None = None,
|
||||
) -> AuditEvent:
|
||||
import time
|
||||
|
||||
raw = json.dumps(dict(context.arguments), sort_keys=True, default=str).encode()
|
||||
return cls(
|
||||
trace_id=trace.trace_id,
|
||||
workspace=context.workspace,
|
||||
project=context.project,
|
||||
environment=context.environment,
|
||||
principal=context.principal,
|
||||
client_id=context.client_id,
|
||||
tool=context.tool,
|
||||
policy_decision=trace.decision.value,
|
||||
matched_rule_ids=trace.matched_rule_ids,
|
||||
policy_trace=tuple(item.model_dump(mode="json") for item in trace.steps),
|
||||
arguments_hash=hashlib.sha256(raw).hexdigest(),
|
||||
sanitized_arguments=sanitize_arguments({
|
||||
**dict(context.arguments),
|
||||
**({"_result": result_summary} if result_summary else {}),
|
||||
}),
|
||||
duration_ms=min(120_000, int((time.monotonic() - started) * 1000)),
|
||||
outcome=outcome,
|
||||
error_code=error_code,
|
||||
retention_until=datetime.now(UTC) + timedelta(days=30),
|
||||
)
|
||||
|
||||
|
||||
class AuditPage(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid", frozen=True)
|
||||
|
||||
items: tuple[AuditEvent, ...]
|
||||
next_offset: int | None = None
|
||||
|
||||
|
||||
class AuditLog(Protocol):
|
||||
def append(self, event: AuditEvent) -> None: ...
|
||||
def search(
|
||||
self, *, workspace: str, project: str, limit: int = 100, offset: int = 0
|
||||
) -> AuditPage: ...
|
||||
def export_metadata(self, *, workspace: str, project: str) -> dict[str, Any]: ...
|
||||
|
||||
|
||||
class InMemoryAuditLog:
|
||||
"""Reference sink used by services and tests; it deliberately has no update/delete API."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self.events: list[AuditEvent] = []
|
||||
|
||||
def append(self, event: AuditEvent) -> None:
|
||||
if any(item.id == event.id for item in self.events):
|
||||
raise ValueError("audit event IDs are immutable")
|
||||
self.events.append(event)
|
||||
|
||||
def search(
|
||||
self, *, workspace: str, project: str, limit: int = 100, offset: int = 0
|
||||
) -> AuditPage:
|
||||
if not 1 <= limit <= 500 or offset < 0:
|
||||
raise ValueError("invalid audit pagination")
|
||||
items = tuple(
|
||||
item for item in self.events if item.workspace == workspace and item.project == project
|
||||
)
|
||||
selected = items[offset : offset + limit]
|
||||
next_offset = offset + len(selected) if offset + len(selected) < len(items) else None
|
||||
return AuditPage(items=selected, next_offset=next_offset)
|
||||
|
||||
def export_metadata(self, *, workspace: str, project: str) -> dict[str, Any]:
|
||||
events = self.search(workspace=workspace, project=project, limit=500).items
|
||||
digest = hashlib.sha256(
|
||||
"".join(item.model_dump_json() for item in events).encode()
|
||||
).hexdigest()
|
||||
return {
|
||||
"workspace": workspace,
|
||||
"project": project,
|
||||
"eventCount": len(events),
|
||||
"sha256": digest,
|
||||
"generatedAt": datetime.now(UTC).isoformat(),
|
||||
"format": "audit-event-v1",
|
||||
"access": "policy-restricted",
|
||||
}
|
||||
@@ -0,0 +1,419 @@
|
||||
"""Small typed policy evaluator shared by management, router, and connectors."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import fnmatch
|
||||
import re
|
||||
from collections.abc import Iterable, Mapping
|
||||
from enum import StrEnum
|
||||
from typing import Any
|
||||
from uuid import UUID, uuid4
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, Field, field_validator
|
||||
|
||||
|
||||
class PolicyValidationError(ValueError):
|
||||
"""A policy contains an unsupported or unsafe selector."""
|
||||
|
||||
|
||||
class Decision(StrEnum):
|
||||
ALLOW = "allow"
|
||||
ALLOW_WITH_PREVIEW = "allow_with_preview"
|
||||
ALLOW_WITH_APPROVAL = "allow_with_approval"
|
||||
ALLOW_IF_CONDITIONS = "allow_if_conditions"
|
||||
ENFORCE = "enforce"
|
||||
DENY = "deny"
|
||||
DENY_AND_ALERT = "deny_and_alert"
|
||||
|
||||
|
||||
_IDENTIFIER = re.compile(r"^[A-Za-z0-9._:/-]{1,256}$")
|
||||
_MATCH_FIELDS = {
|
||||
"workspace",
|
||||
"workspaceId",
|
||||
"project",
|
||||
"projectId",
|
||||
"projects",
|
||||
"projectSlugs",
|
||||
"environment",
|
||||
"environments",
|
||||
"environmentClassifications",
|
||||
"client",
|
||||
"clientId",
|
||||
"clientIds",
|
||||
"principal",
|
||||
"principalType",
|
||||
"virtualServer",
|
||||
"virtualServers",
|
||||
"capability",
|
||||
"capabilities",
|
||||
"tool",
|
||||
"tools",
|
||||
"toolPatterns",
|
||||
"resourceType",
|
||||
"resourceTypes",
|
||||
"connector",
|
||||
"connectors",
|
||||
"riskClass",
|
||||
"riskClasses",
|
||||
"origin",
|
||||
"origins",
|
||||
}
|
||||
_CONDITION_FIELDS = {
|
||||
"requireEnabledClient",
|
||||
"requireBinding",
|
||||
"requireScope",
|
||||
"requireCapabilityPack",
|
||||
"requireConnectorBinding",
|
||||
"requireInputValidation",
|
||||
"requireOutputValidation",
|
||||
"requireRedaction",
|
||||
"maxArgumentBytes",
|
||||
"allEqual",
|
||||
"requiredClaims",
|
||||
"maxDefaultBytes",
|
||||
"maxDefaultItems",
|
||||
"redactionProfile",
|
||||
}
|
||||
|
||||
|
||||
class InvocationContext(BaseModel):
|
||||
"""Validated identity and target context used at every sensitive boundary."""
|
||||
|
||||
model_config = ConfigDict(extra="forbid", frozen=True)
|
||||
|
||||
workspace: str = Field(min_length=1, max_length=128)
|
||||
project: str = Field(min_length=1, max_length=128)
|
||||
environment: str = Field(pattern=r"^[a-z0-9][a-z0-9-]{0,31}$")
|
||||
client_id: UUID | None = None
|
||||
principal: str = Field(min_length=1, max_length=256)
|
||||
principal_type: str = Field(default="machine", max_length=64)
|
||||
virtual_server: str | None = Field(default=None, max_length=128)
|
||||
capability: str | None = Field(default=None, max_length=256)
|
||||
tool: str = Field(min_length=1, max_length=256)
|
||||
connector: str = Field(min_length=1, max_length=64)
|
||||
risk_class: str = Field(default="R1", pattern=r"^(?:R[0-9]+|RX)$")
|
||||
origin: str = Field(default="mcp", max_length=64)
|
||||
resource_type: str | None = Field(default=None, max_length=64)
|
||||
environment_classification: str | None = Field(default=None, max_length=64)
|
||||
binding_present: bool = False
|
||||
client_enabled: bool = False
|
||||
arguments: Mapping[str, Any] = Field(default_factory=dict)
|
||||
trace_id: str = Field(default_factory=lambda: uuid4().hex, pattern=r"^[A-Za-z0-9._:-]{1,128}$")
|
||||
|
||||
|
||||
class PolicyRule(BaseModel):
|
||||
"""A catalog or persisted rule with only typed selector primitives."""
|
||||
|
||||
model_config = ConfigDict(extra="forbid", frozen=True)
|
||||
|
||||
slug: str = Field(pattern=r"^[a-z0-9][a-z0-9-]{0,62}$")
|
||||
workspace: str | None = None
|
||||
project: str | None = None
|
||||
priority: int = Field(ge=0, le=100000)
|
||||
description: str = Field(min_length=1, max_length=1000)
|
||||
match: dict[str, Any] = Field(default_factory=dict)
|
||||
conditions: dict[str, Any] = Field(default_factory=dict)
|
||||
decision: Decision
|
||||
terminal: bool = False
|
||||
source: str = Field(default="catalog", max_length=64)
|
||||
enabled: bool = True
|
||||
|
||||
@field_validator("match", "conditions")
|
||||
@classmethod
|
||||
def safe_maps(cls, value: dict[str, Any]) -> dict[str, Any]:
|
||||
if len(value) > 32:
|
||||
raise PolicyValidationError("policy selector map is too large")
|
||||
return value
|
||||
|
||||
@classmethod
|
||||
def from_document(cls, document: Mapping[str, Any]) -> PolicyRule:
|
||||
metadata = document.get("metadata") or {}
|
||||
spec = document.get("spec") or {}
|
||||
if not isinstance(metadata, Mapping) or not isinstance(spec, Mapping):
|
||||
raise PolicyValidationError("policy document metadata and spec must be objects")
|
||||
match = dict(spec.get("match") or {})
|
||||
conditions = dict(spec.get("conditions") or {})
|
||||
unknown_match = set(match) - _MATCH_FIELDS
|
||||
unknown_conditions = set(conditions) - _CONDITION_FIELDS
|
||||
if unknown_match or unknown_conditions:
|
||||
raise PolicyValidationError(
|
||||
f"unsupported policy fields: {sorted(unknown_match | unknown_conditions)}"
|
||||
)
|
||||
project = spec.get("project")
|
||||
workspace = spec.get("workspace")
|
||||
return cls(
|
||||
slug=str(metadata.get("slug", "")),
|
||||
workspace=str(workspace) if workspace is not None else None,
|
||||
project=str(project) if project is not None else None,
|
||||
priority=int(spec.get("priority", 0)),
|
||||
description=str(spec.get("description", "")),
|
||||
match=match,
|
||||
conditions=conditions,
|
||||
decision=Decision(str(spec.get("decision", "deny"))),
|
||||
terminal=bool(spec.get("terminal", False)),
|
||||
source=str(spec.get("source", "catalog")),
|
||||
enabled=bool(spec.get("enabled", True)),
|
||||
)
|
||||
|
||||
|
||||
class TraceStep(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid", frozen=True)
|
||||
|
||||
phase: str
|
||||
rule: str | None = None
|
||||
matched: bool
|
||||
reason: str
|
||||
|
||||
|
||||
class DecisionTrace(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid", frozen=True)
|
||||
|
||||
trace_id: str
|
||||
decision: Decision
|
||||
reason_code: str
|
||||
matched_rule_ids: tuple[str, ...] = ()
|
||||
steps: tuple[TraceStep, ...] = ()
|
||||
|
||||
|
||||
def _values(value: Any) -> tuple[str, ...]:
|
||||
if isinstance(value, list | tuple | set):
|
||||
return tuple(str(item) for item in value)
|
||||
return (str(value),)
|
||||
|
||||
|
||||
def _context_value(context: InvocationContext, key: str) -> tuple[str, ...]:
|
||||
mapping: dict[str, Any] = {
|
||||
"workspace": context.workspace,
|
||||
"workspaceId": context.workspace,
|
||||
"project": context.project,
|
||||
"projectId": context.project,
|
||||
"projectSlugs": context.project,
|
||||
"projects": context.project,
|
||||
"environment": context.environment,
|
||||
"environments": context.environment,
|
||||
"environmentClassifications": context.environment_classification or context.environment,
|
||||
"resourceType": context.resource_type or "",
|
||||
"resourceTypes": context.resource_type or "",
|
||||
"client": str(context.client_id) if context.client_id else "",
|
||||
"clientId": str(context.client_id) if context.client_id else "",
|
||||
"clientIds": str(context.client_id) if context.client_id else "",
|
||||
"principal": context.principal,
|
||||
"principalType": context.principal_type,
|
||||
"virtualServer": context.virtual_server or "",
|
||||
"capability": context.capability or "",
|
||||
"capabilities": context.capability or "",
|
||||
"tool": context.tool,
|
||||
"tools": context.tool,
|
||||
"toolPatterns": context.tool,
|
||||
"connector": context.connector,
|
||||
"connectors": context.connector,
|
||||
"riskClass": context.risk_class,
|
||||
"riskClasses": context.risk_class,
|
||||
"origin": context.origin,
|
||||
"origins": context.origin,
|
||||
}
|
||||
return _values(mapping.get(key, ""))
|
||||
|
||||
|
||||
def _rule_scope_matches(rule: PolicyRule, context: InvocationContext) -> bool:
|
||||
return (rule.workspace is None or rule.workspace == context.workspace) and (
|
||||
rule.project is None or rule.project == context.project
|
||||
)
|
||||
|
||||
|
||||
def _matches(rule: PolicyRule, context: InvocationContext) -> bool:
|
||||
if not _rule_scope_matches(rule, context):
|
||||
return False
|
||||
for key, expected in rule.match.items():
|
||||
if key not in _MATCH_FIELDS:
|
||||
raise PolicyValidationError(f"unsupported policy match field: {key}")
|
||||
actual = _context_value(context, key)
|
||||
expected_values = _values(expected)
|
||||
if key in {"toolPatterns"}:
|
||||
if not any(
|
||||
fnmatch.fnmatchcase(value, pattern)
|
||||
for value in actual
|
||||
for pattern in expected_values
|
||||
):
|
||||
return False
|
||||
elif not any(value in expected_values for value in actual):
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
def _conditions_match(rule: PolicyRule, context: InvocationContext) -> bool:
|
||||
for key, expected in rule.conditions.items():
|
||||
if key not in _CONDITION_FIELDS:
|
||||
raise PolicyValidationError(f"unsupported policy condition: {key}")
|
||||
if key == "requireEnabledClient" and bool(expected) and not context.client_enabled:
|
||||
return False
|
||||
if key == "requireBinding" and bool(expected) and not context.binding_present:
|
||||
return False
|
||||
if key == "maxArgumentBytes" and len(str(dict(context.arguments)).encode()) > int(expected):
|
||||
return False
|
||||
if key in {
|
||||
"allEqual",
|
||||
"requiredClaims",
|
||||
"maxDefaultBytes",
|
||||
"maxDefaultItems",
|
||||
"redactionProfile",
|
||||
}:
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
class PolicyEngine:
|
||||
"""Deterministic precedence: validation, global deny, explicit deny, allow, default deny."""
|
||||
|
||||
def __init__(self, policies: Iterable[PolicyRule]) -> None:
|
||||
self.policies = tuple(
|
||||
sorted(
|
||||
(item for item in policies if item.enabled),
|
||||
key=lambda item: (-item.priority, item.slug),
|
||||
)
|
||||
)
|
||||
for policy in self.policies:
|
||||
unknown = set(policy.match) - _MATCH_FIELDS
|
||||
if unknown:
|
||||
raise PolicyValidationError(f"unsupported policy match fields: {sorted(unknown)}")
|
||||
if (
|
||||
policy.decision
|
||||
in {
|
||||
Decision.ALLOW,
|
||||
Decision.ALLOW_IF_CONDITIONS,
|
||||
Decision.ALLOW_WITH_APPROVAL,
|
||||
Decision.ALLOW_WITH_PREVIEW,
|
||||
}
|
||||
and policy.project is None
|
||||
and policy.slug != "global-deny"
|
||||
):
|
||||
# Workspace-wide allows must not expand a project scope implicitly.
|
||||
continue
|
||||
|
||||
def evaluate(self, context: InvocationContext) -> DecisionTrace:
|
||||
steps: list[TraceStep] = []
|
||||
if not context.binding_present:
|
||||
return DecisionTrace(
|
||||
trace_id=context.trace_id,
|
||||
decision=Decision.DENY,
|
||||
reason_code="MISSING_BINDING",
|
||||
steps=(
|
||||
TraceStep(
|
||||
phase="identity", matched=False, reason="client/project binding is required"
|
||||
),
|
||||
),
|
||||
)
|
||||
for rule in self.policies:
|
||||
matched = _matches(rule, context)
|
||||
steps.append(
|
||||
TraceStep(
|
||||
phase="global-deny" if rule.project is None else "match",
|
||||
rule=rule.slug,
|
||||
matched=matched,
|
||||
reason="selector matched" if matched else "selector did not match",
|
||||
)
|
||||
)
|
||||
if (
|
||||
matched
|
||||
and rule.project is None
|
||||
and rule.decision in {Decision.DENY, Decision.DENY_AND_ALERT}
|
||||
and rule.terminal
|
||||
):
|
||||
return self._result(context, rule.decision, "GLOBAL_DENY", [rule.slug], steps)
|
||||
for rule in self.policies:
|
||||
if (
|
||||
rule.slug == "global-deny"
|
||||
or rule.decision not in {Decision.DENY, Decision.DENY_AND_ALERT}
|
||||
or not _matches(rule, context)
|
||||
):
|
||||
continue
|
||||
return self._result(context, rule.decision, "EXPLICIT_DENY", [rule.slug], steps)
|
||||
for rule in self.policies:
|
||||
if rule.decision not in {
|
||||
Decision.ALLOW,
|
||||
Decision.ALLOW_IF_CONDITIONS,
|
||||
Decision.ALLOW_WITH_APPROVAL,
|
||||
Decision.ALLOW_WITH_PREVIEW,
|
||||
} or not _matches(rule, context):
|
||||
continue
|
||||
conditions = _conditions_match(rule, context)
|
||||
steps.append(
|
||||
TraceStep(
|
||||
phase="conditions",
|
||||
rule=rule.slug,
|
||||
matched=conditions,
|
||||
reason="conditions satisfied" if conditions else "conditions not satisfied",
|
||||
)
|
||||
)
|
||||
if conditions:
|
||||
return self._result(context, rule.decision, "ALLOW_RULE", [rule.slug], steps)
|
||||
return self._result(context, Decision.DENY, "DEFAULT_DENY", [], steps)
|
||||
|
||||
@staticmethod
|
||||
def _result(
|
||||
context: InvocationContext,
|
||||
decision: Decision,
|
||||
reason: str,
|
||||
rules: list[str],
|
||||
steps: list[TraceStep],
|
||||
) -> DecisionTrace:
|
||||
return DecisionTrace(
|
||||
trace_id=context.trace_id,
|
||||
decision=decision,
|
||||
reason_code=reason,
|
||||
matched_rule_ids=tuple(rules),
|
||||
steps=tuple(steps),
|
||||
)
|
||||
|
||||
|
||||
class PolicySimulator:
|
||||
"""Runs the exact engine against a known-entity inventory without side effects."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
engine: PolicyEngine,
|
||||
*,
|
||||
known_projects: Iterable[str] = (),
|
||||
known_clients: Iterable[UUID] = (),
|
||||
known_tools: Iterable[str] = (),
|
||||
) -> None:
|
||||
self.engine = engine
|
||||
self.known_projects = frozenset(known_projects)
|
||||
self.known_clients = frozenset(known_clients)
|
||||
self.known_tools = frozenset(known_tools)
|
||||
|
||||
def simulate(self, context: InvocationContext) -> DecisionTrace:
|
||||
if self.known_projects and context.project not in self.known_projects:
|
||||
return DecisionTrace(
|
||||
trace_id=context.trace_id,
|
||||
decision=Decision.DENY,
|
||||
reason_code="UNKNOWN_PROJECT",
|
||||
steps=(
|
||||
TraceStep(
|
||||
phase="simulation", matched=False, reason="project is not a known entity"
|
||||
),
|
||||
),
|
||||
)
|
||||
if self.known_tools and context.tool not in self.known_tools:
|
||||
return DecisionTrace(
|
||||
trace_id=context.trace_id,
|
||||
decision=Decision.DENY,
|
||||
reason_code="UNKNOWN_TOOL",
|
||||
steps=(
|
||||
TraceStep(
|
||||
phase="simulation", matched=False, reason="tool is not a known entity"
|
||||
),
|
||||
),
|
||||
)
|
||||
if context.client_id and self.known_clients and context.client_id not in self.known_clients:
|
||||
return DecisionTrace(
|
||||
trace_id=context.trace_id,
|
||||
decision=Decision.DENY,
|
||||
reason_code="UNKNOWN_CLIENT",
|
||||
steps=(
|
||||
TraceStep(
|
||||
phase="simulation", matched=False, reason="client is not a known entity"
|
||||
),
|
||||
),
|
||||
)
|
||||
return self.engine.evaluate(context)
|
||||
@@ -0,0 +1,173 @@
|
||||
"""Shared decision, rate-limit, and audit lifecycle used at every invocation boundary."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import inspect
|
||||
import time
|
||||
from collections.abc import Callable, Mapping
|
||||
from functools import wraps
|
||||
from typing import Any, TypeVar, cast
|
||||
|
||||
from .audit import AuditEvent, AuditLog
|
||||
from .engine import Decision, DecisionTrace, InvocationContext, PolicyEngine
|
||||
from .rate_limit import RateLimitExceeded, SlidingWindowRateLimiter
|
||||
|
||||
|
||||
class PolicyDenied(PermissionError):
|
||||
def __init__(self, trace: DecisionTrace) -> None:
|
||||
super().__init__(trace.reason_code)
|
||||
self.trace = trace
|
||||
self.code = trace.reason_code
|
||||
|
||||
|
||||
T = TypeVar("T", bound=Callable[..., Any])
|
||||
|
||||
|
||||
def governed_tool(tool: str) -> Callable[[T], T]:
|
||||
"""Decorate direct connector tool methods with the shared governance lifecycle."""
|
||||
|
||||
def decorator(method: T) -> T:
|
||||
@wraps(method)
|
||||
def wrapped(self: Any, context: Any, *args: Any, **kwargs: Any) -> Any:
|
||||
governance = getattr(self, "governance", None)
|
||||
if governance is None or getattr(self, "_governance_dispatch", False):
|
||||
return method(self, context, *args, **kwargs)
|
||||
bound = inspect.signature(method).bind(self, context, *args, **kwargs)
|
||||
arguments = {
|
||||
key: value
|
||||
for key, value in bound.arguments.items()
|
||||
if key not in {"self", "context"}
|
||||
}
|
||||
policy_context = governance.context(
|
||||
workspace=str(context.workspace_id),
|
||||
project=context.project_slug,
|
||||
environment=context.environment,
|
||||
principal=str(context.client_id or "anonymous"),
|
||||
client_id=context.client_id,
|
||||
binding_present=context.client_id is not None,
|
||||
client_enabled=context.client_id is not None,
|
||||
tool=tool,
|
||||
connector=getattr(self, "connector_type", "connector"),
|
||||
arguments=arguments,
|
||||
)
|
||||
self._governance_dispatch = True
|
||||
try:
|
||||
return governance.invoke(
|
||||
policy_context, lambda: method(self, context, *args, **kwargs)
|
||||
)
|
||||
finally:
|
||||
self._governance_dispatch = False
|
||||
|
||||
return cast(T, wrapped)
|
||||
|
||||
return decorator
|
||||
|
||||
|
||||
class Governance:
|
||||
def __init__(
|
||||
self,
|
||||
engine: PolicyEngine,
|
||||
audit: AuditLog,
|
||||
*,
|
||||
rate_limiter: SlidingWindowRateLimiter | None = None,
|
||||
) -> None:
|
||||
self.engine = engine
|
||||
self.audit = audit
|
||||
self.rate_limiter = rate_limiter
|
||||
|
||||
def begin(self, context: InvocationContext) -> tuple[DecisionTrace, float]:
|
||||
started = time.monotonic()
|
||||
trace = self.engine.evaluate(context)
|
||||
if trace.decision in {Decision.DENY, Decision.DENY_AND_ALERT}:
|
||||
self.audit.append(
|
||||
AuditEvent.from_context(
|
||||
context, trace, outcome="denied", started=started, error_code=trace.reason_code
|
||||
)
|
||||
)
|
||||
raise PolicyDenied(trace)
|
||||
if self.rate_limiter is not None:
|
||||
try:
|
||||
self.rate_limiter.check(
|
||||
client_id=context.client_id or context.principal,
|
||||
project=context.project,
|
||||
tool=context.tool,
|
||||
)
|
||||
except RateLimitExceeded:
|
||||
self.audit.append(
|
||||
AuditEvent.from_context(
|
||||
context,
|
||||
trace,
|
||||
outcome="rate_limited",
|
||||
started=started,
|
||||
error_code="RATE_LIMITED",
|
||||
)
|
||||
)
|
||||
raise
|
||||
return trace, started
|
||||
|
||||
def finish(
|
||||
self,
|
||||
context: InvocationContext,
|
||||
trace: DecisionTrace,
|
||||
started: float,
|
||||
*,
|
||||
outcome: str = "success",
|
||||
error_code: str | None = None,
|
||||
result_summary: dict[str, Any] | None = None,
|
||||
) -> None:
|
||||
self.audit.append(
|
||||
AuditEvent.from_context(
|
||||
context, trace, outcome=outcome, started=started, error_code=error_code,
|
||||
result_summary=result_summary,
|
||||
)
|
||||
)
|
||||
|
||||
def invoke(self, context: InvocationContext, operation: Callable[[], Any]) -> Any:
|
||||
trace, started = self.begin(context)
|
||||
try:
|
||||
result = operation()
|
||||
except Exception as exc:
|
||||
self.finish(
|
||||
context,
|
||||
trace,
|
||||
started,
|
||||
outcome="error",
|
||||
error_code=getattr(exc, "code", type(exc).__name__),
|
||||
)
|
||||
raise
|
||||
summary = None
|
||||
if context.tool == "gitea.pullrequest.merge" and isinstance(result, dict):
|
||||
data = result.get("data") or {}
|
||||
if isinstance(data, dict):
|
||||
summary = data.get("auditEvidence")
|
||||
self.finish(context, trace, started, result_summary=summary)
|
||||
return result
|
||||
|
||||
@staticmethod
|
||||
def context(
|
||||
*,
|
||||
workspace: str,
|
||||
project: str,
|
||||
environment: str,
|
||||
principal: str,
|
||||
tool: str,
|
||||
connector: str,
|
||||
arguments: Mapping[str, Any],
|
||||
client_id: Any = None,
|
||||
binding_present: bool = True,
|
||||
client_enabled: bool = True,
|
||||
**kwargs: Any,
|
||||
) -> InvocationContext:
|
||||
return InvocationContext(
|
||||
workspace=workspace,
|
||||
project=project,
|
||||
environment=environment,
|
||||
principal=principal,
|
||||
tool=tool,
|
||||
connector=connector,
|
||||
arguments=dict(arguments),
|
||||
client_id=client_id,
|
||||
binding_present=binding_present,
|
||||
client_enabled=client_enabled,
|
||||
**kwargs,
|
||||
)
|
||||
@@ -0,0 +1,45 @@
|
||||
"""In-process bounded sliding-window rate limiter with isolated compound keys."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import time
|
||||
from collections import defaultdict, deque
|
||||
from threading import Lock
|
||||
from uuid import UUID
|
||||
|
||||
|
||||
class RateLimitExceeded(RuntimeError):
|
||||
code = "RATE_LIMITED"
|
||||
|
||||
|
||||
class SlidingWindowRateLimiter:
|
||||
def __init__(
|
||||
self, *, limit: int = 60, window_seconds: float = 60.0, clock: object = time.monotonic
|
||||
) -> None:
|
||||
if limit < 1 or window_seconds <= 0:
|
||||
raise ValueError("rate limit must be positive")
|
||||
self.limit = limit
|
||||
self.window_seconds = window_seconds
|
||||
self.clock = clock
|
||||
self._entries: dict[tuple[str, str, str], deque[float]] = defaultdict(deque)
|
||||
self._lock = Lock()
|
||||
|
||||
def allow(self, *, client_id: UUID | str, project: str, tool: str) -> bool:
|
||||
key = (str(client_id), project, tool)
|
||||
now = self.clock() # type: ignore[operator]
|
||||
with self._lock:
|
||||
bucket = self._entries[key]
|
||||
while bucket and bucket[0] <= now - self.window_seconds:
|
||||
bucket.popleft()
|
||||
if len(bucket) >= self.limit:
|
||||
return False
|
||||
bucket.append(now)
|
||||
return True
|
||||
|
||||
def check(self, *, client_id: UUID | str, project: str, tool: str) -> None:
|
||||
if not self.allow(client_id=client_id, project=project, tool=tool):
|
||||
raise RateLimitExceeded("invocation rate limit exceeded")
|
||||
|
||||
def clear(self) -> None:
|
||||
with self._lock:
|
||||
self._entries.clear()
|
||||
@@ -0,0 +1,17 @@
|
||||
"""Production runtime configuration primitives."""
|
||||
|
||||
from .secret_files import (
|
||||
SecretFileError,
|
||||
read_secret_file,
|
||||
validate_required_secret_paths,
|
||||
validate_secret_file,
|
||||
)
|
||||
from .service_tokens import issue_broker_service_token
|
||||
|
||||
__all__ = [
|
||||
"SecretFileError",
|
||||
"issue_broker_service_token",
|
||||
"read_secret_file",
|
||||
"validate_required_secret_paths",
|
||||
"validate_secret_file",
|
||||
]
|
||||
@@ -0,0 +1,94 @@
|
||||
"""Fail-closed secret-file access without value projection or logging."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import stat
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
class SecretFileError(RuntimeError):
|
||||
"""Stable non-secret error raised for an invalid runtime secret file."""
|
||||
|
||||
|
||||
def validate_secret_file(
|
||||
path: Path,
|
||||
*,
|
||||
root: Path = Path("/run/secrets"),
|
||||
expected_uid: int | None = None,
|
||||
expected_gid: int | None = None,
|
||||
max_bytes: int = 8192,
|
||||
) -> bytes:
|
||||
"""Return bounded bytes only after containment, mode, owner, and content checks."""
|
||||
|
||||
try:
|
||||
resolved_root = root.resolve(strict=True)
|
||||
resolved = path.resolve(strict=True)
|
||||
resolved.relative_to(resolved_root)
|
||||
metadata = resolved.stat()
|
||||
except (OSError, ValueError) as exc:
|
||||
raise SecretFileError("required secret file is unavailable") from exc
|
||||
if not stat.S_ISREG(metadata.st_mode):
|
||||
raise SecretFileError("required secret path is not a regular file")
|
||||
mode = stat.S_IMODE(metadata.st_mode)
|
||||
if os.name != "nt" and (mode & 0o077 or mode & 0o111 or not mode & 0o400):
|
||||
raise SecretFileError("required secret file permissions are unsafe")
|
||||
if expected_uid is not None and metadata.st_uid != expected_uid:
|
||||
raise SecretFileError("required secret file owner is invalid")
|
||||
if expected_gid is not None and metadata.st_gid != expected_gid:
|
||||
raise SecretFileError("required secret file group is invalid")
|
||||
try:
|
||||
payload = resolved.read_bytes()
|
||||
except OSError as exc:
|
||||
raise SecretFileError("required secret file is unreadable") from exc
|
||||
payload = payload.rstrip(b"\r\n")
|
||||
if (
|
||||
not payload
|
||||
or len(payload) > max_bytes
|
||||
or b"\x00" in payload
|
||||
or b"\r" in payload.rstrip(b"\r\n")
|
||||
or b"\n" in payload.rstrip(b"\r\n")
|
||||
):
|
||||
raise SecretFileError("required secret file content is invalid")
|
||||
return payload
|
||||
|
||||
|
||||
def read_secret_file(
|
||||
path: Path,
|
||||
*,
|
||||
root: Path = Path("/run/secrets"),
|
||||
expected_uid: int | None = None,
|
||||
expected_gid: int | None = None,
|
||||
max_bytes: int = 8192,
|
||||
) -> str:
|
||||
"""Read a UTF-8 secret at its internal consumption boundary."""
|
||||
|
||||
payload = validate_secret_file(
|
||||
path,
|
||||
root=root,
|
||||
expected_uid=expected_uid,
|
||||
expected_gid=expected_gid,
|
||||
max_bytes=max_bytes,
|
||||
)
|
||||
try:
|
||||
return payload.decode("utf-8")
|
||||
except UnicodeDecodeError as exc:
|
||||
raise SecretFileError("required secret file encoding is invalid") from exc
|
||||
|
||||
|
||||
def validate_required_secret_paths(raw: str) -> None:
|
||||
"""Validate a comma-separated production startup contract without returning values."""
|
||||
|
||||
if not raw.strip():
|
||||
return
|
||||
expected_uid = (
|
||||
int(os.environ["SECRET_EXPECTED_UID"]) if os.getenv("SECRET_EXPECTED_UID") else None
|
||||
)
|
||||
expected_gid = (
|
||||
int(os.environ["SECRET_EXPECTED_GID"]) if os.getenv("SECRET_EXPECTED_GID") else None
|
||||
)
|
||||
for value in raw.split(","):
|
||||
path = Path(value.strip())
|
||||
if not path.is_absolute():
|
||||
raise SecretFileError("required secret path must be absolute")
|
||||
validate_secret_file(path, expected_uid=expected_uid, expected_gid=expected_gid)
|
||||
@@ -0,0 +1,39 @@
|
||||
"""Short-lived internal service tokens shared by trusted runtime components."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import hashlib
|
||||
import hmac
|
||||
import json
|
||||
import time
|
||||
|
||||
|
||||
def issue_broker_service_token(
|
||||
secret: str,
|
||||
project: str,
|
||||
environment: str,
|
||||
*,
|
||||
ttl_seconds: int = 60,
|
||||
) -> str:
|
||||
"""Issue a bounded Docker broker token for one validated project context."""
|
||||
|
||||
payload = {
|
||||
"aud": "docker-read-broker",
|
||||
"project": project,
|
||||
"environment": environment,
|
||||
"exp": int(time.time()) + ttl_seconds,
|
||||
}
|
||||
encoded = (
|
||||
base64.urlsafe_b64encode(json.dumps(payload, separators=(",", ":")).encode())
|
||||
.decode()
|
||||
.rstrip("=")
|
||||
)
|
||||
signature = (
|
||||
base64.urlsafe_b64encode(
|
||||
hmac.new(secret.encode(), encoded.encode(), hashlib.sha256).digest()
|
||||
)
|
||||
.decode()
|
||||
.rstrip("=")
|
||||
)
|
||||
return f"{encoded}.{signature}"
|
||||
@@ -0,0 +1,103 @@
|
||||
"""Process-local MCP runtime supervision and recovery telemetry."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import os
|
||||
import socket
|
||||
import time
|
||||
from datetime import UTC, datetime
|
||||
from threading import Lock
|
||||
from typing import Any
|
||||
|
||||
from mcp.server.context import CallNext, ServerRequestContext
|
||||
from mcp.shared.exceptions import MCPError
|
||||
|
||||
|
||||
class RuntimeSupervisor:
|
||||
"""Track transport health without turning tool failures into process failures."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self._started_monotonic = time.monotonic()
|
||||
self._started_at = datetime.now(UTC).isoformat()
|
||||
material = f"{socket.gethostname()}:{os.getpid()}:{self._started_at}".encode()
|
||||
self.worker_id = hashlib.sha256(material).hexdigest()[:16]
|
||||
self.generation = os.environ.get(
|
||||
"HUB_RUNTIME_GENERATION",
|
||||
os.environ.get("GITEA_CONNECTOR_BUILD_SHA", "development"),
|
||||
)[:64]
|
||||
self.restart_count = max(int(os.environ.get("HUB_RUNTIME_RESTART_COUNT", "0")), 0)
|
||||
self._lock = Lock()
|
||||
self._in_flight = 0
|
||||
self._requests = 0
|
||||
self._failures = 0
|
||||
self._last_error_code: str | None = None
|
||||
self._last_fatal_error_code: str | None = None
|
||||
self._circuit_state = "CLOSED"
|
||||
self._recovery_count = 0
|
||||
|
||||
def started(self) -> None:
|
||||
with self._lock:
|
||||
self._in_flight += 1
|
||||
self._requests += 1
|
||||
|
||||
def completed(self) -> None:
|
||||
with self._lock:
|
||||
self._in_flight = max(self._in_flight - 1, 0)
|
||||
if self._circuit_state == "DEGRADED":
|
||||
self._circuit_state = "CLOSED"
|
||||
self._recovery_count += 1
|
||||
|
||||
def failed(self, code: str, *, fatal: bool) -> None:
|
||||
with self._lock:
|
||||
self._in_flight = max(self._in_flight - 1, 0)
|
||||
self._failures += 1
|
||||
self._last_error_code = code[:128]
|
||||
if fatal:
|
||||
self._last_fatal_error_code = code[:128]
|
||||
self._circuit_state = "DEGRADED"
|
||||
|
||||
def snapshot(self) -> dict[str, Any]:
|
||||
with self._lock:
|
||||
return {
|
||||
"workerId": self.worker_id,
|
||||
"sessionId": None,
|
||||
"sessionMode": "stateless-resumable-http",
|
||||
"resumable": True,
|
||||
"startedAt": self._started_at,
|
||||
"uptimeSeconds": max(int(time.monotonic() - self._started_monotonic), 0),
|
||||
"restartCount": self.restart_count,
|
||||
"generation": self.generation,
|
||||
"circuitState": self._circuit_state,
|
||||
"recoveryCount": self._recovery_count,
|
||||
"requests": self._requests,
|
||||
"inFlight": self._in_flight,
|
||||
"failures": self._failures,
|
||||
"lastErrorCode": self._last_error_code,
|
||||
"lastFatalErrorCode": self._last_fatal_error_code,
|
||||
}
|
||||
|
||||
|
||||
SUPERVISOR = RuntimeSupervisor()
|
||||
|
||||
|
||||
class SupervisorMiddleware:
|
||||
"""Keep per-operation errors observable while preserving the next request."""
|
||||
|
||||
async def __call__(self, ctx: ServerRequestContext[Any, Any], call_next: CallNext) -> Any:
|
||||
SUPERVISOR.started()
|
||||
try:
|
||||
result = await call_next(ctx)
|
||||
except MCPError as exc:
|
||||
data = exc.data if isinstance(exc.data, dict) else {}
|
||||
SUPERVISOR.failed(str(data.get("code", "MCP_ERROR")), fatal=False)
|
||||
raise
|
||||
except Exception as exc:
|
||||
SUPERVISOR.failed(type(exc).__name__, fatal=True)
|
||||
raise
|
||||
SUPERVISOR.completed()
|
||||
return result
|
||||
|
||||
|
||||
def runtime_snapshot() -> dict[str, Any]:
|
||||
return SUPERVISOR.snapshot()
|
||||
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"name": "@itworx-mcp-hub/ui",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"description": "Reserved shared UI workspace; implementation begins in WP-120."
|
||||
}
|
||||
Reference in new issue
Block a user