governance.py 7.7 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178
  1. """Closed, local-only manifest contract for governed plugins.
  2. This module deliberately contains no loader, dynamic import, evaluator, shell,
  3. subprocess, network client or user-selected filesystem path. A manifest is
  4. metadata for a fixed built-in fixture handler, never an executable package.
  5. """
  6. from __future__ import annotations
  7. import copy
  8. import hashlib
  9. import json
  10. import re
  11. import unicodedata
  12. from typing import Any
  13. class PluginManifestError(ValueError):
  14. """A plugin manifest did not meet the closed local-baseline contract."""
  15. _TOP_LEVEL = frozenset(
  16. {
  17. "schema_version",
  18. "plugin_uid",
  19. "name",
  20. "version",
  21. "api_version",
  22. "type",
  23. "capabilities",
  24. "permissions",
  25. "resource",
  26. "compatibility",
  27. "distribution",
  28. }
  29. )
  30. _TYPES = frozenset({"connector", "parser", "quality", "notification", "approval", "agent_mcp"})
  31. _CAPABILITIES = {
  32. "connector": frozenset({"discover", "snapshot", "evidence"}),
  33. "parser": frozenset({"parse", "evidence"}),
  34. "quality": frozenset({"evaluate", "evidence"}),
  35. "notification": frozenset({"prepare", "evidence"}),
  36. "approval": frozenset({"evaluate", "evidence"}),
  37. "agent_mcp": frozenset({"read", "suggest", "evidence"}),
  38. }
  39. _PERMISSIONS = frozenset({"network", "file", "secret", "child_process"})
  40. _RESOURCE = frozenset({"timeout_ms", "max_output_bytes", "max_concurrency", "max_retries"})
  41. _COMPATIBILITY = frozenset({"platform_api"})
  42. _DISTRIBUTION = frozenset({"kind", "fixture_id", "artifact_digest"})
  43. _SENSITIVE = frozenset(
  44. {
  45. "url", "uri", "endpoint", "path", "file", "file_path", "filename", "command", "shell",
  46. "script", "code", "module", "import", "eval", "exec", "secret", "token", "password",
  47. "credential", "api_key", "authorization", "raw_rows", "rows", "records", "payload", "sql",
  48. }
  49. )
  50. _ID = re.compile(r"^[a-z][a-z0-9-]{2,62}$")
  51. _SEMVER = re.compile(r"^[0-9]+\.[0-9]+\.[0-9]+(?:-[0-9A-Za-z.-]+)?$")
  52. _HEX = re.compile(r"^[a-f0-9]{64}$")
  53. def canonical_digest(value: dict[str, Any]) -> str:
  54. return hashlib.sha256(
  55. json.dumps(value, sort_keys=True, separators=(",", ":"), ensure_ascii=True).encode("utf-8")
  56. ).hexdigest()
  57. def fixture_signature_digest(artifact_digest: str) -> str:
  58. """Bind the sole local fixture artifact to the platform-owned trust key.
  59. This is deliberately not a payload supplied public key or a general
  60. signature verifier. Enterprise CA and registry signing remain disabled.
  61. """
  62. if not isinstance(artifact_digest, str) or not _HEX.fullmatch(artifact_digest):
  63. raise PluginManifestError("artifact_digest_invalid")
  64. return hashlib.sha256(f"local-fixture-key-v1|{artifact_digest}".encode("ascii")).hexdigest()
  65. def _closed(value: Any, allowed: frozenset[str], label: str) -> dict[str, Any]:
  66. if not isinstance(value, dict) or len(value) > len(allowed):
  67. raise PluginManifestError(f"{label}_closed")
  68. for key in value:
  69. if not isinstance(key, str) or unicodedata.normalize("NFKC", key) != key:
  70. raise PluginManifestError("manifest_unicode_rejected")
  71. # The four declared capability switches are metadata, not values. All
  72. # other sensitive vocabulary remains forbidden even before unknown-key
  73. # processing so it cannot become an accidental extension point.
  74. if key.casefold() in _SENSITIVE and key not in allowed:
  75. raise PluginManifestError("manifest_sensitive_key")
  76. if set(value) != allowed:
  77. raise PluginManifestError(f"{label}_closed")
  78. return copy.deepcopy(value)
  79. def _ascii_text(value: Any, label: str, maximum: int = 120) -> str:
  80. if not isinstance(value, str) or not value or len(value) > maximum:
  81. raise PluginManifestError(f"{label}_invalid")
  82. if unicodedata.normalize("NFKC", value) != value or not value.isascii():
  83. raise PluginManifestError(f"{label}_unicode_rejected")
  84. return value
  85. def _positive_int(value: Any, label: str, maximum: int) -> int:
  86. if isinstance(value, bool) or not isinstance(value, int) or not 0 < value <= maximum:
  87. raise PluginManifestError(f"{label}_invalid")
  88. return value
  89. def normalize_plugin_manifest(value: Any) -> dict[str, Any]:
  90. """Normalize a v1 manifest for the sole fixed engineering fixture.
  91. Enterprise registry artifacts are intentionally rejected until an external
  92. trust model is approved. This validation makes that inactivity executable.
  93. """
  94. body = _closed(value, _TOP_LEVEL, "manifest")
  95. if body["schema_version"] != 1 or body["api_version"] != "1":
  96. raise PluginManifestError("manifest_version_invalid")
  97. plugin_uid = _ascii_text(body["plugin_uid"], "plugin_uid", 63)
  98. name = _ascii_text(body["name"], "name", 63)
  99. if not _ID.fullmatch(plugin_uid) or not _ID.fullmatch(name) or plugin_uid != name:
  100. raise PluginManifestError("plugin_identity_invalid")
  101. version = _ascii_text(body["version"], "version", 80)
  102. if not _SEMVER.fullmatch(version):
  103. raise PluginManifestError("plugin_version_invalid")
  104. plugin_type = _ascii_text(body["type"], "plugin_type", 20)
  105. if plugin_type not in _TYPES:
  106. raise PluginManifestError("plugin_type_invalid")
  107. capabilities = body["capabilities"]
  108. if not isinstance(capabilities, list) or not 1 <= len(capabilities) <= 8:
  109. raise PluginManifestError("capabilities_invalid")
  110. normalized_capabilities = tuple(_ascii_text(item, "capability", 40) for item in capabilities)
  111. if len(set(normalized_capabilities)) != len(normalized_capabilities) or set(normalized_capabilities) - _CAPABILITIES[plugin_type]:
  112. raise PluginManifestError("capabilities_invalid")
  113. permissions = _closed(body["permissions"], _PERMISSIONS, "permissions")
  114. if any(item is not False for item in permissions.values()):
  115. raise PluginManifestError("permissions_denied")
  116. resource = _closed(body["resource"], _RESOURCE, "resource")
  117. normalized_resource = {
  118. "timeout_ms": _positive_int(resource["timeout_ms"], "timeout_ms", 5000),
  119. "max_output_bytes": _positive_int(resource["max_output_bytes"], "max_output_bytes", 65536),
  120. "max_concurrency": _positive_int(resource["max_concurrency"], "max_concurrency", 1),
  121. "max_retries": _positive_int(resource["max_retries"], "max_retries", 3),
  122. }
  123. if normalized_resource["max_output_bytes"] < 64:
  124. raise PluginManifestError("max_output_bytes_invalid")
  125. compatibility = _closed(body["compatibility"], _COMPATIBILITY, "compatibility")
  126. if compatibility["platform_api"] != "1":
  127. raise PluginManifestError("compatibility_invalid")
  128. distribution = _closed(body["distribution"], _DISTRIBUTION, "distribution")
  129. if distribution["kind"] != "builtin_fixture" or distribution["fixture_id"] != "ENGINEERING_EVIDENCE_ONLY":
  130. raise PluginManifestError("distribution_denied")
  131. artifact_digest = _ascii_text(distribution["artifact_digest"], "artifact_digest", 64)
  132. if not _HEX.fullmatch(artifact_digest):
  133. raise PluginManifestError("artifact_digest_invalid")
  134. normalized = {
  135. "schema_version": 1,
  136. "plugin_uid": plugin_uid,
  137. "name": name,
  138. "version": version,
  139. "api_version": "1",
  140. "type": plugin_type,
  141. "capabilities": list(normalized_capabilities),
  142. "permissions": dict.fromkeys(sorted(_PERMISSIONS), False),
  143. "resource": normalized_resource,
  144. "compatibility": {"platform_api": "1"},
  145. "distribution": {
  146. "kind": "builtin_fixture",
  147. "fixture_id": "ENGINEERING_EVIDENCE_ONLY",
  148. "artifact_digest": artifact_digest,
  149. },
  150. }
  151. normalized["manifest_digest"] = canonical_digest(normalized)
  152. return normalized
  153. __all__ = ["PluginManifestError", "canonical_digest", "fixture_signature_digest", "normalize_plugin_manifest"]