@myvillage/cli 1.51.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.
- package/package.json +2 -1
- package/src/commands/create-game.js +18 -12
- package/src/commands/deploy.js +166 -53
- package/src/index.js +2 -1
- package/src/utils/api.js +10 -0
- package/src/utils/preflight.js +172 -0
- package/src/utils/templates.js +189 -517
- package/tools/preflight/README.md +75 -0
- package/tools/preflight/preflight.py +291 -0
- package/tools/preflight/requirements.txt +1 -0
|
@@ -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
|