Coverage for core / platform / manifest_validator.py: 95.9%
122 statements
« prev ^ index » next coverage.py v7.14.0, created at 2026-05-12 04:49 +0000
« prev ^ index » next coverage.py v7.14.0, created at 2026-05-12 04:49 +0000
1"""
2Manifest Validator — OS-level contracts for AppManifest integrity.
4Every AppManifest registered in HART OS must pass validation. This prevents
5Frankenstein extensions from polluting the OS with invalid types, dangerous
6entries, or arbitrary permissions.
8Follows the budget_gate.py pattern: static methods, fail-closed, clear reasons.
10Usage:
11 from core.platform.manifest_validator import ManifestValidator
12 valid, errors = ManifestValidator.validate(manifest)
13 if not valid:
14 raise ValueError(f"Invalid manifest: {'; '.join(errors)}")
15"""
17import math
18import re
19from typing import Any, Dict, List, Tuple
21from core.platform.app_manifest import AppType
23# ─── Constants (frozen by convention — do not modify) ──────────────
25# Valid permissions an app can declare
26KNOWN_PERMISSIONS = frozenset({
27 'network', 'audio', 'display', 'input', 'filesystem',
28 'system_read', 'system_write', 'camera', 'microphone',
29 'clipboard', 'notifications', 'bluetooth', 'usb',
30 'location', 'process_manage', 'power_manage',
31})
33# Valid AppType values (derived from enum)
34_VALID_TYPES = frozenset(t.value for t in AppType)
36# Valid AI capability types
37_VALID_AI_CAPABILITY_TYPES = frozenset({
38 'llm', 'vision', 'tts', 'stt', 'image_gen', 'embedding', 'code',
39})
41# Valid model policies
42_VALID_MODEL_POLICIES = frozenset({
43 'local_only', 'local_preferred', 'any',
44})
46# Required entry keys per AppType
47ENTRY_SCHEMA: Dict[str, Dict[str, Any]] = {
48 'nunba_panel': {'required': ['route']},
49 'system_panel': {'required': ['loader']},
50 'dynamic_panel': {'required': ['route']},
51 'desktop_app': {'required': ['exec']},
52 'service': {'any_of': ['http', 'exec']},
53 'agent': {'required': ['prompt_id', 'flow_id']},
54 'mcp_server': {'required': ['mcp']},
55 'channel': {'required': ['adapter']},
56 'extension': {'required': ['module']},
57}
59# ID format: alphanumeric, hyphens, underscores, 1-64 chars
60_ID_RE = re.compile(r'^[a-zA-Z0-9][a-zA-Z0-9_-]{0,63}$')
62# Version: semver X.Y.Z or 'auto'
63_SEMVER_RE = re.compile(r'^\d+\.\d+\.\d+$')
66# ─── Validator ─────────────────────────────────────────────────────
68class ManifestValidator:
69 """Validates AppManifest fields against OS contracts.
71 All methods are static — no state needed.
72 Returns (valid, errors) tuples following budget_gate.py pattern.
73 """
75 @staticmethod
76 def validate(manifest) -> Tuple[bool, List[str]]:
77 """Full validation of an AppManifest.
79 Returns (is_valid, [error_messages]).
80 """
81 errors = []
83 # ID
84 ok, msg = ManifestValidator.validate_id(manifest.id)
85 if not ok:
86 errors.append(msg)
88 # Type
89 ok, msg = ManifestValidator.validate_type(manifest.type)
90 if not ok:
91 errors.append(msg)
93 # Version
94 ok, msg = ManifestValidator.validate_version(manifest.version)
95 if not ok:
96 errors.append(msg)
98 # Entry (only if type is valid)
99 if manifest.type in _VALID_TYPES:
100 ok, msg = ManifestValidator.validate_entry(
101 manifest.type, manifest.entry)
102 if not ok:
103 errors.append(msg)
105 # Permissions
106 ok, msg = ManifestValidator.validate_permissions(manifest.permissions)
107 if not ok:
108 errors.append(msg)
110 # AI Capabilities
111 ok, cap_errors = ManifestValidator.validate_ai_capabilities(
112 manifest.ai_capabilities)
113 if not ok:
114 errors.extend(cap_errors)
116 # Size
117 ok, msg = ManifestValidator.validate_size(manifest.default_size)
118 if not ok:
119 errors.append(msg)
121 valid = len(errors) == 0
123 # Emit event + audit log on failure (non-blocking, best-effort)
124 if not valid:
125 try:
126 from core.platform.events import emit_event
127 emit_event('manifest.validation_failed', {
128 'app_id': manifest.id,
129 'errors': errors,
130 })
131 except Exception:
132 pass
133 try:
134 from security.immutable_audit_log import get_audit_log
135 get_audit_log().log_event(
136 'security', 'manifest_validator',
137 f"Rejected manifest '{manifest.id}'",
138 detail={'errors': errors})
139 except Exception:
140 pass
142 return (valid, errors)
144 @staticmethod
145 def validate_id(app_id: str) -> Tuple[bool, str]:
146 """ID must be alphanumeric/-/_, 1-64 chars, start with alphanumeric."""
147 if not app_id:
148 return (False, 'id must not be empty')
149 if not _ID_RE.match(app_id):
150 return (False,
151 f"id '{app_id}' must be 1-64 chars, alphanumeric/-/_, "
152 f"starting with alphanumeric")
153 return (True, '')
155 @staticmethod
156 def validate_type(app_type: str) -> Tuple[bool, str]:
157 """Type must be a valid AppType enum value."""
158 if app_type not in _VALID_TYPES:
159 return (False,
160 f"type '{app_type}' not in valid types: "
161 f"{sorted(_VALID_TYPES)}")
162 return (True, '')
164 @staticmethod
165 def validate_version(version: str) -> Tuple[bool, str]:
166 """Version must be semver X.Y.Z or 'auto'."""
167 if version == 'auto':
168 return (True, '')
169 if not _SEMVER_RE.match(version):
170 return (False,
171 f"version '{version}' must be semver (X.Y.Z) or 'auto'")
172 return (True, '')
174 @staticmethod
175 def validate_entry(app_type: str, entry: dict) -> Tuple[bool, str]:
176 """Entry must contain required keys for the given AppType."""
177 schema = ENTRY_SCHEMA.get(app_type)
178 if schema is None:
179 return (True, '') # Unknown type already caught by validate_type
181 required = schema.get('required', [])
182 any_of = schema.get('any_of', [])
184 for key in required:
185 if key not in entry:
186 return (False,
187 f"entry for type '{app_type}' missing required "
188 f"key '{key}'")
190 if any_of and not any(k in entry for k in any_of):
191 return (False,
192 f"entry for type '{app_type}' must have at least one "
193 f"of: {any_of}")
195 return (True, '')
197 @staticmethod
198 def validate_permissions(permissions: list) -> Tuple[bool, str]:
199 """All permissions must be in KNOWN_PERMISSIONS."""
200 if not permissions:
201 return (True, '')
202 unknown = [p for p in permissions if p not in KNOWN_PERMISSIONS]
203 if unknown:
204 return (False,
205 f"unknown permissions: {unknown}. "
206 f"Valid: {sorted(KNOWN_PERMISSIONS)}")
207 return (True, '')
209 @staticmethod
210 def validate_ai_capabilities(capabilities: list) -> Tuple[bool, List[str]]:
211 """Validate AI capability declarations."""
212 if not capabilities:
213 return (True, [])
215 errors = []
216 for i, cap in enumerate(capabilities):
217 if not isinstance(cap, dict):
218 errors.append(f'ai_capabilities[{i}] must be a dict')
219 continue
221 cap_type = cap.get('type', '')
222 if cap_type not in _VALID_AI_CAPABILITY_TYPES:
223 errors.append(
224 f"ai_capabilities[{i}].type '{cap_type}' not in "
225 f"{sorted(_VALID_AI_CAPABILITY_TYPES)}")
227 # Numeric bounds
228 for field in ('min_accuracy', 'max_latency_ms', 'max_cost_spark'):
229 val = cap.get(field, 0)
230 if isinstance(val, (int, float)):
231 if math.isnan(val) or math.isinf(val):
232 errors.append(
233 f'ai_capabilities[{i}].{field} must not be '
234 f'NaN or Inf')
235 elif val < 0:
236 errors.append(
237 f'ai_capabilities[{i}].{field} must be >= 0')
239 # Accuracy bounds
240 accuracy = cap.get('min_accuracy', 0)
241 if isinstance(accuracy, (int, float)) and not (
242 math.isnan(accuracy) or math.isinf(accuracy)):
243 if accuracy > 1.0:
244 errors.append(
245 f'ai_capabilities[{i}].min_accuracy must be <= 1.0')
247 return (len(errors) == 0, errors)
249 @staticmethod
250 def validate_size(size: tuple) -> Tuple[bool, str]:
251 """Width/height must be positive integers, max 7680x4320."""
252 if not isinstance(size, (tuple, list)) or len(size) != 2:
253 return (False, 'default_size must be a (width, height) tuple')
254 w, h = size
255 if not isinstance(w, int) or not isinstance(h, int):
256 return (False, 'default_size width/height must be integers')
257 if w <= 0 or h <= 0:
258 return (False, 'default_size width/height must be positive')
259 if w > 7680 or h > 4320:
260 return (False,
261 f'default_size {w}x{h} exceeds max 7680x4320')
262 return (True, '')