@myvillage/cli 1.50.0 → 1.60.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,75 @@
1
+ # MyVillage GameKit Bundle Preflight
2
+
3
+ Inspects a Unity AssetBundle before submission and rejects it if it references any script outside the GameKit SDK allowlist.
4
+
5
+ This script is invoked automatically by `myvillage deploy` for Unity bundles — you don't normally run it directly. It's also run server-side by the ed-platform as a defense-in-depth check.
6
+
7
+ ## Why it exists
8
+
9
+ Unity AssetBundles can ship scenes, prefabs, and assets — but **not custom C# code**. Custom scripts must be types compiled into the M-UNI host app, which means they must come from the GameKit SDK. Preflight enforces that rule so unauthorized references are caught at upload time with a clear error, not as a confusing runtime crash on a player's device.
10
+
11
+ ## Install
12
+
13
+ Requires **Python 3.9+** and **UnityPy 1.25.0** (specifically — newer versions may track Unity 6 deltas differently).
14
+
15
+ ```bash
16
+ pip install -r requirements.txt
17
+ ```
18
+
19
+ The CLI checks for `python3` and `UnityPy` automatically and prints install hints if either is missing.
20
+
21
+ ## Run directly (for debugging)
22
+
23
+ ```bash
24
+ python3 preflight.py path/to/bundle.bundle path/to/allowlist.json
25
+ ```
26
+
27
+ The allowlist JSON should match the shape returned by `GET /api/v1/gamekit/versions/<version>/allowlist`:
28
+
29
+ ```json
30
+ {
31
+ "monoScripts": [
32
+ { "guid": "3a93ea8e08ef42e99b40236c4f7807a2", "path": "Runtime/IMissionHost.cs" }
33
+ ],
34
+ "assemblies": [
35
+ { "guid": "44b2f5a459b64410baeb0af8e1480847", "name": "MyVillage.GameKit" }
36
+ ]
37
+ }
38
+ ```
39
+
40
+ ## Output
41
+
42
+ JSON to stdout. Exit codes:
43
+
44
+ | Code | Meaning |
45
+ |------|---------|
46
+ | 0 | Bundle passes — no unauthorized references |
47
+ | 1 | Bundle has violations (listed in JSON `violations` array) |
48
+ | 2 | Preflight itself errored (missing files, parse error, missing UnityPy) |
49
+
50
+ On pass:
51
+ ```json
52
+ {
53
+ "status": "pass",
54
+ "bundle": "...",
55
+ "scriptsSeen": 12,
56
+ "violations": [],
57
+ "violationCount": 0
58
+ }
59
+ ```
60
+
61
+ On fail:
62
+ ```json
63
+ {
64
+ "status": "fail",
65
+ "violations": [
66
+ {
67
+ "kind": "unauthorized_script",
68
+ "assembly": "Assembly-CSharp",
69
+ "namespace": "",
70
+ "className": "MyEnemyAI",
71
+ "reason": "Script references assembly 'Assembly-CSharp' which is not in the SDK allowlist..."
72
+ }
73
+ ]
74
+ }
75
+ ```
@@ -0,0 +1,291 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ MyVillage GameKit bundle preflight.
4
+
5
+ Scans a Unity AssetBundle (the output of Unity's AssetBundle build pipeline)
6
+ for MonoScript references and Visual Scripting graph nodes that aren't in
7
+ the GameKit SDK's allowlist. Also rejects bundles containing embedded
8
+ .dll files.
9
+
10
+ Used by:
11
+ - MyVillage CLI's `myvillage deploy` (catches violations pre-upload)
12
+ - ed-platform's game-submission.service (re-runs after upload — actual gate)
13
+
14
+ Usage:
15
+ preflight.py <bundle_path> <allowlist_json_path>
16
+
17
+ Output:
18
+ JSON to stdout. Exit code 0 on pass, 1 on violations, 2 on error.
19
+
20
+ Allowlist JSON shape (matches GameKitVersion.guidAllowlist):
21
+ {
22
+ "monoScripts": [{ "guid": "...", "path": "..." }],
23
+ "assemblies": [{ "guid": "...", "name": "MyVillage.GameKit" }],
24
+ "nodes": ["Unity.VisualScripting.Branch", ...] # M2+
25
+ }
26
+ """
27
+
28
+ import json
29
+ import sys
30
+ from pathlib import Path
31
+
32
+
33
+ def _emit(payload, exit_code):
34
+ print(json.dumps(payload, indent=2))
35
+ sys.exit(exit_code)
36
+
37
+
38
+ def _error(message):
39
+ _emit({"status": "error", "error": message}, 2)
40
+
41
+
42
+ # Unity ships these assemblies with the editor/runtime. References to types in
43
+ # them are always allowed — they're trusted code that's already in the host.
44
+ # Anything not on this list and not in the SDK allowlist gets rejected.
45
+ BUILTIN_ASSEMBLY_PREFIXES = (
46
+ "UnityEngine",
47
+ "UnityEditor",
48
+ "Unity.", # Unity.TextMeshPro, Unity.InputSystem, Unity.VisualScripting, etc.
49
+ "TextMeshPro",
50
+ "mscorlib",
51
+ "System",
52
+ "netstandard",
53
+ )
54
+
55
+
56
+ def _is_builtin_assembly(name: str) -> bool:
57
+ if not name:
58
+ return False
59
+ return any(name == p or name.startswith(p + ".") or name.startswith(p) for p in BUILTIN_ASSEMBLY_PREFIXES)
60
+
61
+
62
+ # Visual Scripting nodes that reach into arbitrary .NET via reflection. These
63
+ # are the entire "dangerous surface" of Visual Scripting (File.IO, networking,
64
+ # Process.Start, etc. are NOT exposed as dedicated nodes — they're only
65
+ # reachable through these four). Block them outright; MyVillage Custom Units
66
+ # provide the curated alternative.
67
+ FORBIDDEN_GRAPH_NODES = {
68
+ "Unity.VisualScripting.InvokeMember",
69
+ "Unity.VisualScripting.GetMember",
70
+ "Unity.VisualScripting.SetMember",
71
+ "Unity.VisualScripting.Expose",
72
+ }
73
+
74
+
75
+ def _normalize_type(qualname: str) -> str:
76
+ """Strip assembly suffix from an assembly-qualified .NET type name.
77
+ 'Unity.VisualScripting.InvokeMember, Unity.VisualScripting.Flow'
78
+ -> 'Unity.VisualScripting.InvokeMember'
79
+ """
80
+ if not qualname:
81
+ return ""
82
+ return qualname.split(",", 1)[0].strip()
83
+
84
+
85
+ def _walk_for_types(obj, types_out):
86
+ """Recursively walk a parsed JSON object collecting every '$type' value."""
87
+ if isinstance(obj, dict):
88
+ t = obj.get("$type")
89
+ if isinstance(t, str):
90
+ types_out.append((_normalize_type(t), obj))
91
+ for v in obj.values():
92
+ _walk_for_types(v, types_out)
93
+ elif isinstance(obj, list):
94
+ for v in obj:
95
+ _walk_for_types(v, types_out)
96
+
97
+
98
+ def _extract_graph_json(obj):
99
+ """Return the inner FullSerializer JSON string from a ScriptGraphAsset
100
+ or StateGraphAsset typetree, or None if not present."""
101
+ try:
102
+ tree = obj.read_typetree()
103
+ except Exception:
104
+ return None
105
+ # The Macro<TGraph> shape stores the graph at _data._json.
106
+ data = tree.get("_data") if isinstance(tree, dict) else None
107
+ if isinstance(data, dict):
108
+ s = data.get("_json")
109
+ if isinstance(s, str) and s:
110
+ return s
111
+ # Fallback: some asset variants put _json at the root.
112
+ s = tree.get("_json") if isinstance(tree, dict) else None
113
+ if isinstance(s, str) and s:
114
+ return s
115
+ return None
116
+
117
+
118
+ def main():
119
+ if len(sys.argv) != 3:
120
+ _error("Usage: preflight.py <bundle_path> <allowlist_json_path>")
121
+
122
+ bundle_path = Path(sys.argv[1])
123
+ allowlist_path = Path(sys.argv[2])
124
+
125
+ if not bundle_path.exists():
126
+ _error(f"Bundle not found: {bundle_path}")
127
+ if not allowlist_path.exists():
128
+ _error(f"Allowlist not found: {allowlist_path}")
129
+
130
+ try:
131
+ import UnityPy
132
+ except ImportError:
133
+ _error(
134
+ "UnityPy is not installed in this Python environment. "
135
+ "Install with: pip install 'UnityPy==1.25.0' "
136
+ "(or 'pip3 install' on systems where pip points at Python 2)."
137
+ )
138
+
139
+ try:
140
+ allowlist = json.loads(allowlist_path.read_text())
141
+ except json.JSONDecodeError as e:
142
+ _error(f"Allowlist JSON parse error: {e}")
143
+
144
+ allowed_assemblies = {entry["name"] for entry in allowlist.get("assemblies", []) if "name" in entry}
145
+ allowed_nodes = set(allowlist.get("nodes") or [])
146
+
147
+ try:
148
+ env = UnityPy.load(str(bundle_path))
149
+ except Exception as e:
150
+ _error(f"Failed to parse bundle (UnityPy): {e}")
151
+
152
+ violations = []
153
+ scripts_seen = 0
154
+ graphs_seen = 0
155
+ nodes_seen = 0
156
+ seen_script_keys = set()
157
+ seen_node_violations = set() # dedupe (kind, type) pairs
158
+
159
+ # ── MonoScript checks (M1) ────────────────────────────────────────────
160
+ for obj in env.objects:
161
+ if obj.type.name != "MonoScript":
162
+ continue
163
+ scripts_seen += 1
164
+ try:
165
+ tree = obj.read_typetree()
166
+ except Exception as e:
167
+ violations.append({
168
+ "kind": "unreadable_script",
169
+ "reason": f"Failed to read MonoScript at path_id {obj.path_id}: {e}",
170
+ })
171
+ continue
172
+
173
+ assembly = tree.get("m_AssemblyName") or ""
174
+ namespace = tree.get("m_Namespace") or ""
175
+ class_name = tree.get("m_ClassName") or ""
176
+ key = (assembly, namespace, class_name)
177
+
178
+ if _is_builtin_assembly(assembly):
179
+ continue
180
+ if assembly in allowed_assemblies:
181
+ continue
182
+ if key in seen_script_keys:
183
+ continue
184
+ seen_script_keys.add(key)
185
+ violations.append({
186
+ "kind": "unauthorized_script",
187
+ "assembly": assembly or "<empty>",
188
+ "namespace": namespace,
189
+ "className": class_name,
190
+ "reason": (
191
+ f"Script references assembly '{assembly}' which is not in the SDK "
192
+ f"allowlist and is not a Unity built-in. Convert to MissionBase + "
193
+ f"MissionConfig or a Visual Scripting graph."
194
+ ),
195
+ })
196
+
197
+ # ── Visual Scripting graph checks (M2) ────────────────────────────────
198
+ # Skip entirely if the allowlist doesn't declare a `nodes` list yet
199
+ # (older SDK versions pre-M2 don't need this enforcement).
200
+ if allowed_nodes:
201
+ for obj in env.objects:
202
+ if obj.type.name not in ("ScriptGraphAsset", "StateGraphAsset", "MonoBehaviour"):
203
+ continue
204
+ graph_json = _extract_graph_json(obj)
205
+ if not graph_json:
206
+ continue
207
+ graphs_seen += 1
208
+ try:
209
+ graph = json.loads(graph_json)
210
+ except json.JSONDecodeError as e:
211
+ violations.append({
212
+ "kind": "unparseable_graph",
213
+ "reason": f"Visual Scripting graph JSON parse failed: {e}",
214
+ })
215
+ continue
216
+
217
+ collected = []
218
+ _walk_for_types(graph, collected)
219
+ nodes_seen += len(collected)
220
+
221
+ for type_name, _node in collected:
222
+ if not type_name:
223
+ continue
224
+ # Block the four reflection nodes outright
225
+ if type_name in FORBIDDEN_GRAPH_NODES:
226
+ dedup_key = ("forbidden_node", type_name)
227
+ if dedup_key in seen_node_violations:
228
+ continue
229
+ seen_node_violations.add(dedup_key)
230
+ violations.append({
231
+ "kind": "forbidden_node",
232
+ "unitType": type_name,
233
+ "reason": (
234
+ "Visual Scripting reflection nodes (InvokeMember, GetMember, "
235
+ "SetMember, Expose) are blocked. Use a MyVillage Custom Unit "
236
+ "(under the 'MyVillage' category in the node finder) instead."
237
+ ),
238
+ })
239
+ continue
240
+ # Only enforce positive allowlist on Unit-shaped entries. The
241
+ # FullSerializer JSON contains many $type entries that aren't
242
+ # graph nodes (Vector3 values, color literals, etc.). Limit
243
+ # the positive check to Unity.VisualScripting.* + MyVillage.*
244
+ # which are the namespaces graph units live in.
245
+ if not (type_name.startswith("Unity.VisualScripting.")
246
+ or type_name.startswith("MyVillage.GameKit.VisualScripting.")):
247
+ continue
248
+ if type_name in allowed_nodes:
249
+ continue
250
+ dedup_key = ("unauthorized_node", type_name)
251
+ if dedup_key in seen_node_violations:
252
+ continue
253
+ seen_node_violations.add(dedup_key)
254
+ violations.append({
255
+ "kind": "unauthorized_node",
256
+ "unitType": type_name,
257
+ "reason": (
258
+ f"Visual Scripting node '{type_name}' is not on the GameKit "
259
+ f"allowlist for this SDK version. If your game needs this, "
260
+ f"contact MyVillage to request the node be added."
261
+ ),
262
+ })
263
+
264
+ # ── Embedded DLLs are always forbidden ────────────────────────────────
265
+ embedded_files = []
266
+ for cab_name, _cab in getattr(env, "files", {}).items():
267
+ if isinstance(cab_name, str) and cab_name.lower().endswith(".dll"):
268
+ embedded_files.append(cab_name)
269
+ for name in embedded_files:
270
+ violations.append({
271
+ "kind": "embedded_dll",
272
+ "path": name,
273
+ "reason": "Bundle contains an embedded .dll. Bundles may only ship assets, not compiled assemblies.",
274
+ })
275
+
276
+ result = {
277
+ "status": "fail" if violations else "pass",
278
+ "bundle": str(bundle_path),
279
+ "scriptsSeen": scripts_seen,
280
+ "graphsSeen": graphs_seen,
281
+ "nodesSeen": nodes_seen,
282
+ "allowedAssemblies": sorted(allowed_assemblies),
283
+ "allowedNodeCount": len(allowed_nodes),
284
+ "violations": violations,
285
+ "violationCount": len(violations),
286
+ }
287
+ _emit(result, 1 if violations else 0)
288
+
289
+
290
+ if __name__ == "__main__":
291
+ main()
@@ -0,0 +1 @@
1
+ UnityPy==1.25.0