@chrrxs/robloxstudio-mcp 2.22.4 → 2.23.0

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,127 @@
1
+ export interface ImportedAssetInstance {
2
+ IsA(className: string): boolean;
3
+ GetDescendants(): ImportedAssetInstance[];
4
+ }
5
+
6
+ export interface ForbiddenImportScan {
7
+ scripts: ImportedAssetInstance[];
8
+ packageLinks: ImportedAssetInstance[];
9
+ }
10
+
11
+ export interface AssetSanitizationOperations {
12
+ forceUnparent: (root: ImportedAssetInstance) => boolean;
13
+ destroy: (instance: ImportedAssetInstance) => boolean;
14
+ }
15
+
16
+ export interface AssetSanitizationResult {
17
+ success: boolean;
18
+ removedScriptCount: number;
19
+ removedPackageLinkCount: number;
20
+ remainingScriptCount: number;
21
+ remainingPackageLinkCount: number;
22
+ error?: string;
23
+ }
24
+
25
+ function countInstances(instances: ImportedAssetInstance[]): number {
26
+ let count = 0;
27
+ for (const _instance of instances) count++;
28
+ return count;
29
+ }
30
+
31
+ export function scanForbiddenImportedInstances(root: ImportedAssetInstance): ForbiddenImportScan {
32
+ const scripts: ImportedAssetInstance[] = [];
33
+ const packageLinks: ImportedAssetInstance[] = [];
34
+
35
+ function inspect(instance: ImportedAssetInstance) {
36
+ // IsA("LuaSourceContainer") covers Script, LocalScript, ModuleScript,
37
+ // and any future script-bearing subclass. Names, source, creator,
38
+ // reputation, and hierarchy depth are deliberately irrelevant.
39
+ if (instance.IsA("LuaSourceContainer")) {
40
+ scripts.push(instance);
41
+ }
42
+ if (instance.IsA("PackageLink")) {
43
+ packageLinks.push(instance);
44
+ }
45
+ }
46
+
47
+ inspect(root);
48
+ // GetDescendants performs an engine-level, unlimited-depth traversal.
49
+ // Never replace this security scan with a depth-limited tree walk.
50
+ for (const descendant of root.GetDescendants()) {
51
+ inspect(descendant);
52
+ }
53
+
54
+ return { scripts, packageLinks };
55
+ }
56
+
57
+ export function sanitizeLoadedAsset(
58
+ root: ImportedAssetInstance,
59
+ operations: AssetSanitizationOperations,
60
+ ): AssetSanitizationResult {
61
+ if (!operations.forceUnparent(root)) {
62
+ operations.destroy(root);
63
+ return {
64
+ success: false,
65
+ removedScriptCount: 0,
66
+ removedPackageLinkCount: 0,
67
+ remainingScriptCount: 0,
68
+ remainingPackageLinkCount: 0,
69
+ error: "Loaded asset could not be kept unparented for sanitization.",
70
+ };
71
+ }
72
+
73
+ const firstScan = scanForbiddenImportedInstances(root);
74
+ const firstScriptCount = countInstances(firstScan.scripts);
75
+ const firstPackageLinkCount = countInstances(firstScan.packageLinks);
76
+
77
+ // A forbidden root cannot be removed while preserving an import wrapper.
78
+ // Destroy the whole load and fail closed.
79
+ if (root.IsA("LuaSourceContainer") || root.IsA("PackageLink")) {
80
+ const rootWasScript = root.IsA("LuaSourceContainer");
81
+ const rootWasPackageLink = root.IsA("PackageLink");
82
+ operations.destroy(root);
83
+ return {
84
+ success: false,
85
+ removedScriptCount: firstScriptCount,
86
+ removedPackageLinkCount: firstPackageLinkCount,
87
+ remainingScriptCount: rootWasScript ? 1 : 0,
88
+ remainingPackageLinkCount: rootWasPackageLink ? 1 : 0,
89
+ error: "Loaded asset root is a forbidden executable or package-link instance.",
90
+ };
91
+ }
92
+
93
+ let removedScriptCount = 0;
94
+ for (const scriptInstance of firstScan.scripts) {
95
+ if (operations.destroy(scriptInstance)) removedScriptCount++;
96
+ }
97
+
98
+ let removedPackageLinkCount = 0;
99
+ for (const packageLink of firstScan.packageLinks) {
100
+ if (operations.destroy(packageLink)) removedPackageLinkCount++;
101
+ }
102
+
103
+ // Mandatory second unlimited-depth scan. If any forbidden instance
104
+ // survived, destroy the entire imported root and insert nothing.
105
+ const verificationScan = scanForbiddenImportedInstances(root);
106
+ const remainingScriptCount = countInstances(verificationScan.scripts);
107
+ const remainingPackageLinkCount = countInstances(verificationScan.packageLinks);
108
+ if (remainingScriptCount > 0 || remainingPackageLinkCount > 0) {
109
+ operations.destroy(root);
110
+ return {
111
+ success: false,
112
+ removedScriptCount,
113
+ removedPackageLinkCount,
114
+ remainingScriptCount,
115
+ remainingPackageLinkCount,
116
+ error: "Asset sanitization verification failed; the entire imported asset was destroyed.",
117
+ };
118
+ }
119
+
120
+ return {
121
+ success: true,
122
+ removedScriptCount,
123
+ removedPackageLinkCount,
124
+ remainingScriptCount: 0,
125
+ remainingPackageLinkCount: 0,
126
+ };
127
+ }
@@ -1,5 +1,11 @@
1
1
  import Utils from "../Utils";
2
2
  import Recording from "../Recording";
3
+ import {
4
+ sanitizeLoadedAsset,
5
+ scanForbiddenImportedInstances,
6
+ type AssetSanitizationOperations,
7
+ type ImportedAssetInstance,
8
+ } from "../AssetSanitizationPolicy";
3
9
 
4
10
  const AssetService = game.GetService("AssetService");
5
11
  const ChangeHistoryService = game.GetService("ChangeHistoryService");
@@ -8,6 +14,41 @@ const Selection = game.GetService("Selection");
8
14
  const { getInstancePath, getInstanceByPath } = Utils;
9
15
  const { beginRecording, finishRecording } = Recording;
10
16
 
17
+ const THIRD_PARTY_ASSET_SETTING_HINT =
18
+ '\nTo load public Creator Store assets that you do not own, enable "Allow Loading Third Party Assets" in Game Settings > Security.';
19
+
20
+ function destroyImportedRoot(root: Instance) {
21
+ pcall(() => {
22
+ root.Destroy();
23
+ });
24
+ }
25
+
26
+ function formatAssetLoadFailure(assetId: number, loadError: unknown): string {
27
+ const [settingReadable, allowInsertFreeAssets] = pcall(() => {
28
+ return (AssetService as unknown as { AllowInsertFreeAssets: boolean }).AllowInsertFreeAssets;
29
+ });
30
+ const settingHint = settingReadable && allowInsertFreeAssets === true
31
+ ? ""
32
+ : THIRD_PARTY_ASSET_SETTING_HINT;
33
+ return `Failed to load asset ${assetId}: ${tostring(loadError)}${settingHint}`;
34
+ }
35
+
36
+ const sanitizationOperations: AssetSanitizationOperations = {
37
+ forceUnparent: (root: ImportedAssetInstance) => {
38
+ const instance = root as Instance;
39
+ const [unparentedOk] = pcall(() => {
40
+ instance.Parent = undefined;
41
+ });
42
+ return unparentedOk && instance.Parent === undefined;
43
+ },
44
+ destroy: (instance: ImportedAssetInstance) => {
45
+ const [destroyed] = pcall(() => {
46
+ (instance as Instance).Destroy();
47
+ });
48
+ return destroyed;
49
+ },
50
+ };
51
+
11
52
  function insertAsset(requestData: Record<string, unknown>) {
12
53
  const assetId = requestData.assetId as number;
13
54
  const parentPath = (requestData.parentPath as string) ?? "game.Workspace";
@@ -25,21 +66,29 @@ function insertAsset(requestData: Record<string, unknown>) {
25
66
  const recordingId = beginRecording(`Insert asset ${assetId}`);
26
67
 
27
68
  let wrapperModel: Instance | undefined;
69
+ const insertedInstances: Instance[] = [];
28
70
  const [insertSuccess, insertResult] = pcall(() => {
29
- const loadedWrapper = (AssetService as unknown as { LoadAssetAsync(id: number): Instance }).LoadAssetAsync(assetId);
71
+ const [loadSuccess, loadResult] = pcall(() => {
72
+ return (AssetService as unknown as { LoadAssetAsync(id: number): Instance }).LoadAssetAsync(assetId);
73
+ });
74
+ if (!loadSuccess || !loadResult) {
75
+ error(formatAssetLoadFailure(assetId, loadResult), 0);
76
+ }
77
+ const loadedWrapper = loadResult as Instance;
30
78
  wrapperModel = loadedWrapper;
31
79
 
32
- const insertedInstances: Instance[] = [];
33
- const children = loadedWrapper.GetChildren();
34
-
35
- for (const child of children) {
36
- child.Parent = parentInstance;
37
- insertedInstances.push(child);
80
+ const sanitization = sanitizeLoadedAsset(loadedWrapper, sanitizationOperations);
81
+ if (!sanitization.success) {
82
+ error(sanitization.error ?? "Asset sanitization failed.", 0);
38
83
  }
39
84
 
85
+ const children = loadedWrapper.GetChildren();
86
+
87
+ // Position while every sanitized child is still contained by the
88
+ // unparented wrapper. Nothing reaches the DataModel before verification.
40
89
  if (position) {
41
90
  const pos = new Vector3(position.x ?? 0, position.y ?? 0, position.z ?? 0);
42
- for (const inst of insertedInstances) {
91
+ for (const inst of children) {
43
92
  if (inst.IsA("BasePart")) {
44
93
  inst.Position = pos;
45
94
  } else if (inst.IsA("Model")) {
@@ -55,6 +104,13 @@ function insertAsset(requestData: Record<string, unknown>) {
55
104
  }
56
105
  }
57
106
 
107
+ // This is the first point at which imported content is parented into
108
+ // Studio. Both unlimited-depth scans have already passed.
109
+ for (const child of children) {
110
+ child.Parent = parentInstance;
111
+ insertedInstances.push(child);
112
+ }
113
+
58
114
  pcall(() => {
59
115
  Selection.Set(insertedInstances);
60
116
  });
@@ -71,9 +127,23 @@ function insertAsset(requestData: Record<string, unknown>) {
71
127
  parentPath,
72
128
  insertedCount: insertedInstances.size(),
73
129
  instances: resultInstances,
130
+ sanitization: {
131
+ removedScriptCount: sanitization.removedScriptCount,
132
+ removedPackageLinkCount: sanitization.removedPackageLinkCount,
133
+ verifiedClean: true,
134
+ },
74
135
  };
75
136
  });
76
137
 
138
+ if (!insertSuccess) {
139
+ // Roll back any partially-parented children if a later operation failed.
140
+ for (const inserted of insertedInstances) {
141
+ pcall(() => {
142
+ inserted.Destroy();
143
+ });
144
+ }
145
+ }
146
+
77
147
  if (wrapperModel) {
78
148
  pcall(() => {
79
149
  wrapperModel!.Destroy();
@@ -91,8 +161,8 @@ function insertAsset(requestData: Record<string, unknown>) {
91
161
 
92
162
  function previewAsset(requestData: Record<string, unknown>) {
93
163
  const assetId = requestData.assetId as number;
94
- const includeProperties = (requestData.includeProperties as boolean) ?? true;
95
- const maxDepth = (requestData.maxDepth as number) ?? 10;
164
+ const includeProperties = (requestData.includeProperties as boolean) ?? false;
165
+ const maxDepth = (requestData.maxDepth as number) ?? 4;
96
166
 
97
167
  if (!assetId) {
98
168
  return { error: "assetId is required" };
@@ -103,27 +173,100 @@ function previewAsset(requestData: Record<string, unknown>) {
103
173
  });
104
174
 
105
175
  if (!loadSuccess || !wrapperModel) {
106
- return { error: `Failed to load asset ${assetId}: ${tostring(wrapperModel)}` };
176
+ return { error: formatAssetLoadFailure(assetId, wrapperModel) };
177
+ }
178
+
179
+ // Previewed assets never enter the DataModel.
180
+ const [unparentedOk] = pcall(() => {
181
+ (wrapperModel as Instance).Parent = undefined;
182
+ });
183
+ if (!unparentedOk || (wrapperModel as Instance).Parent !== undefined) {
184
+ destroyImportedRoot(wrapperModel as Instance);
185
+ return { error: `Failed to keep asset ${assetId} unparented for preview` };
107
186
  }
108
187
 
109
- // Stats tracking
188
+ // Security and visual-capability tracking always scan the full descendant
189
+ // hierarchy. maxDepth only affects the display tree below.
110
190
  let totalInstances = 0;
111
191
  const classCounts: Record<string, number> = {};
112
- let hasScripts = false;
113
192
  let hasAnimations = false;
114
193
  let hasSounds = false;
115
194
  let hasParticles = false;
116
-
117
- function buildHierarchy(instance: Instance, depth: number): Record<string, unknown> {
195
+ let hasVfx = false;
196
+ let hasDecalsOrTextures = false;
197
+ let hasMeshes = false;
198
+ let hasLights = false;
199
+ let hasAttachments = false;
200
+ const soundReferences: Record<string, unknown>[] = [];
201
+ const uniqueSoundContentIds = new Set<string>();
202
+
203
+ function recordSummary(instance: Instance) {
118
204
  totalInstances++;
119
-
120
205
  const className = instance.ClassName;
121
206
  classCounts[className] = (classCounts[className] ?? 0) + 1;
122
207
 
123
- if (instance.IsA("LuaSourceContainer")) hasScripts = true;
124
208
  if (className === "Animation" || className === "AnimationController" || className === "Animator") hasAnimations = true;
125
- if (instance.IsA("Sound")) hasSounds = true;
126
- if (className === "ParticleEmitter" || className === "Fire" || className === "Smoke" || className === "Sparkles") hasParticles = true;
209
+ if (instance.IsA("Sound")) {
210
+ hasSounds = true;
211
+ const sound = instance as Sound;
212
+ const soundId = sound.SoundId;
213
+ if (soundId !== "") uniqueSoundContentIds.add(soundId);
214
+ soundReferences.push({
215
+ path: sound.GetFullName(),
216
+ name: sound.Name,
217
+ className,
218
+ soundId,
219
+ volume: sound.Volume,
220
+ playbackSpeed: sound.PlaybackSpeed,
221
+ looped: sound.Looped,
222
+ isLoaded: sound.IsLoaded,
223
+ timeLength: sound.TimeLength,
224
+ rollOffMode: tostring(sound.RollOffMode),
225
+ rollOffMinDistance: sound.RollOffMinDistance,
226
+ rollOffMaxDistance: sound.RollOffMaxDistance,
227
+ });
228
+ } else if (instance.IsA("AudioPlayer")) {
229
+ hasSounds = true;
230
+ const audioPlayer = instance as AudioPlayer;
231
+ const soundId = tostring(audioPlayer.Asset);
232
+ if (soundId !== "") uniqueSoundContentIds.add(soundId);
233
+ soundReferences.push({
234
+ path: audioPlayer.GetFullName(),
235
+ name: audioPlayer.Name,
236
+ className,
237
+ soundId,
238
+ volume: audioPlayer.Volume,
239
+ playbackSpeed: audioPlayer.PlaybackSpeed,
240
+ looped: audioPlayer.Looping,
241
+ autoPlay: audioPlayer.AutoPlay,
242
+ isLoaded: audioPlayer.IsReady,
243
+ timeLength: audioPlayer.TimeLength,
244
+ });
245
+ }
246
+ if (
247
+ instance.IsA("ParticleEmitter") ||
248
+ className === "Fire" ||
249
+ className === "Smoke" ||
250
+ className === "Sparkles"
251
+ ) {
252
+ hasParticles = true;
253
+ hasVfx = true;
254
+ }
255
+ if (instance.IsA("Beam") || instance.IsA("Trail")) hasVfx = true;
256
+ if (instance.IsA("Decal") || instance.IsA("Texture")) hasDecalsOrTextures = true;
257
+ if (instance.IsA("MeshPart") || instance.IsA("SpecialMesh")) hasMeshes = true;
258
+ if (instance.IsA("Light")) hasLights = true;
259
+ if (instance.IsA("Attachment")) hasAttachments = true;
260
+ }
261
+
262
+ recordSummary(wrapperModel as Instance);
263
+ for (const descendant of (wrapperModel as Instance).GetDescendants()) {
264
+ recordSummary(descendant);
265
+ }
266
+ const forbiddenScan = scanForbiddenImportedInstances(wrapperModel as Instance);
267
+
268
+ function buildHierarchy(instance: Instance, depth: number): Record<string, unknown> {
269
+ const className = instance.ClassName;
127
270
 
128
271
  const node: Record<string, unknown> = {
129
272
  name: instance.Name,
@@ -155,15 +298,6 @@ function previewAsset(requestData: Record<string, unknown>) {
155
298
  }
156
299
  }
157
300
 
158
- if (instance.IsA("LuaSourceContainer")) {
159
- const [ok, src] = pcall(() => (instance as unknown as { Source: string }).Source);
160
- if (ok && src) {
161
- const preview = string.sub(src, 1, 200);
162
- props.sourcePreview = preview;
163
- props.sourceLength = src.size();
164
- }
165
- }
166
-
167
301
  if (className === "Decal" || className === "Texture") {
168
302
  const [ok, texId] = pcall(() => (instance as unknown as { Texture: string }).Texture);
169
303
  if (ok) props.texture = texId;
@@ -171,6 +305,8 @@ function previewAsset(requestData: Record<string, unknown>) {
171
305
 
172
306
  if (instance.IsA("Sound")) {
173
307
  props.soundId = (instance as Sound).SoundId;
308
+ } else if (instance.IsA("AudioPlayer")) {
309
+ props.soundId = tostring((instance as AudioPlayer).Asset);
174
310
  }
175
311
 
176
312
  // Only include props if there are any
@@ -213,13 +349,27 @@ function previewAsset(requestData: Record<string, unknown>) {
213
349
  success: true,
214
350
  assetId,
215
351
  hierarchy: hierarchyRoots,
352
+ sounds: soundReferences,
216
353
  summary: {
217
354
  totalInstances,
218
355
  classCounts,
219
- hasScripts,
356
+ hasScripts: forbiddenScan.scripts.size() > 0,
357
+ scriptCount: forbiddenScan.scripts.size(),
358
+ hasPackageLinks: forbiddenScan.packageLinks.size() > 0,
359
+ packageLinkCount: forbiddenScan.packageLinks.size(),
220
360
  hasAnimations,
221
361
  hasSounds,
362
+ soundCount: soundReferences.size(),
363
+ uniqueSoundContentIdCount: uniqueSoundContentIds.size(),
222
364
  hasParticles,
365
+ hasVfx,
366
+ hasDecalsOrTextures,
367
+ hasMeshes,
368
+ hasLights,
369
+ hasAttachments,
370
+ securityScanDepth: "unlimited",
371
+ scriptSourceExposed: false,
372
+ insertPolicy: "All LuaSourceContainer and PackageLink instances are stripped and verified before insertion.",
223
373
  },
224
374
  };
225
375
  });