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

1""" 

2Manifest Validator — OS-level contracts for AppManifest integrity. 

3 

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. 

7 

8Follows the budget_gate.py pattern: static methods, fail-closed, clear reasons. 

9 

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""" 

16 

17import math 

18import re 

19from typing import Any, Dict, List, Tuple 

20 

21from core.platform.app_manifest import AppType 

22 

23# ─── Constants (frozen by convention — do not modify) ────────────── 

24 

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}) 

32 

33# Valid AppType values (derived from enum) 

34_VALID_TYPES = frozenset(t.value for t in AppType) 

35 

36# Valid AI capability types 

37_VALID_AI_CAPABILITY_TYPES = frozenset({ 

38 'llm', 'vision', 'tts', 'stt', 'image_gen', 'embedding', 'code', 

39}) 

40 

41# Valid model policies 

42_VALID_MODEL_POLICIES = frozenset({ 

43 'local_only', 'local_preferred', 'any', 

44}) 

45 

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} 

58 

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}$') 

61 

62# Version: semver X.Y.Z or 'auto' 

63_SEMVER_RE = re.compile(r'^\d+\.\d+\.\d+$') 

64 

65 

66# ─── Validator ───────────────────────────────────────────────────── 

67 

68class ManifestValidator: 

69 """Validates AppManifest fields against OS contracts. 

70 

71 All methods are static — no state needed. 

72 Returns (valid, errors) tuples following budget_gate.py pattern. 

73 """ 

74 

75 @staticmethod 

76 def validate(manifest) -> Tuple[bool, List[str]]: 

77 """Full validation of an AppManifest. 

78 

79 Returns (is_valid, [error_messages]). 

80 """ 

81 errors = [] 

82 

83 # ID 

84 ok, msg = ManifestValidator.validate_id(manifest.id) 

85 if not ok: 

86 errors.append(msg) 

87 

88 # Type 

89 ok, msg = ManifestValidator.validate_type(manifest.type) 

90 if not ok: 

91 errors.append(msg) 

92 

93 # Version 

94 ok, msg = ManifestValidator.validate_version(manifest.version) 

95 if not ok: 

96 errors.append(msg) 

97 

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) 

104 

105 # Permissions 

106 ok, msg = ManifestValidator.validate_permissions(manifest.permissions) 

107 if not ok: 

108 errors.append(msg) 

109 

110 # AI Capabilities 

111 ok, cap_errors = ManifestValidator.validate_ai_capabilities( 

112 manifest.ai_capabilities) 

113 if not ok: 

114 errors.extend(cap_errors) 

115 

116 # Size 

117 ok, msg = ManifestValidator.validate_size(manifest.default_size) 

118 if not ok: 

119 errors.append(msg) 

120 

121 valid = len(errors) == 0 

122 

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 

141 

142 return (valid, errors) 

143 

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, '') 

154 

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, '') 

163 

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, '') 

173 

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 

180 

181 required = schema.get('required', []) 

182 any_of = schema.get('any_of', []) 

183 

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}'") 

189 

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}") 

194 

195 return (True, '') 

196 

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, '') 

208 

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, []) 

214 

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 

220 

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)}") 

226 

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') 

238 

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') 

246 

247 return (len(errors) == 0, errors) 

248 

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, '')