diff --git a/CHANGELOG.md b/CHANGELOG.md index 79c3df3..039e0d7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,7 @@ All notable public changes to Nexus Unity are documented here. - Consolidated duplicate internal Ollama-review and serialized-property write helpers. ### Fixed +- `find_objects` now safely constructs name search regexes with a 100ms match timeout and falls back gracefully to literal substring search on invalid regex patterns or match timeouts (#119). - Restrict type resolution (`FindType`) to user project assemblies and standard public `UnityEngine` assemblies, disallow internal system/editor assemblies and namespaces, and enforce strict type allowlist constraints for component and ScriptableObject creation/inspection tools (#139). - Resolve symlinks and directory junctions to their real filesystem targets in `ValidatePath` before checking project boundaries to prevent path traversal. - Reject abstract ScriptableObject types before calling `CreateInstance` in `create_scriptable_object_asset` and `list_fields_for_type`. diff --git a/Editor/MCPServerMethods.References.cs b/Editor/MCPServerMethods.References.cs new file mode 100644 index 0000000..111fd26 --- /dev/null +++ b/Editor/MCPServerMethods.References.cs @@ -0,0 +1,163 @@ +using System.Collections.Generic; +using System.Linq; +using Newtonsoft.Json.Linq; +using UnityEditor; +using UnityEngine; + +namespace UnityMCP.Editor +{ + public static partial class MCPServerMethods + { + private static JToken FindReferences(JToken p) + { + string targetGuid = p?["target_guid"]?.ToString(); + EntityId targetId = MCPServerMethods.ExtractId(p, "target_id"); + + if (string.IsNullOrEmpty(targetGuid) && targetId == default) + throw new System.Exception("Either target_guid or target_id is required."); + + Object targetObject = ResolveTargetObject(targetGuid, targetId); + if (targetObject == null) + throw new System.Exception("Could not find the target object."); + + string searchGuid = GetSearchGuid(targetGuid, targetObject); + JArray assetRefs = FindAssetReferences(searchGuid); + + HashSet targetInstanceIds = BuildTargetInstanceIds(targetObject, searchGuid); + JArray sceneRefs = FindSceneReferences(targetInstanceIds); + + var result = new JObject(); + result["status"] = "Success"; + result["asset_references"] = assetRefs; + result["scene_references"] = sceneRefs; + return result; + } + + private static Object ResolveTargetObject(string targetGuid, EntityId targetId) + { + if (!string.IsNullOrEmpty(targetGuid)) + { + string path = AssetDatabase.GUIDToAssetPath(targetGuid); + if (!string.IsNullOrEmpty(path)) + return AssetDatabase.LoadAssetAtPath(path); + } + else if (targetId != default) + { + return MCPServerMethods.IdToObject(targetId); + } + return null; + } + + private static string GetSearchGuid(string targetGuid, Object targetObject) + { + if (!string.IsNullOrEmpty(targetGuid)) return targetGuid; + string path = AssetDatabase.GetAssetPath(targetObject); + return !string.IsNullOrEmpty(path) ? AssetDatabase.AssetPathToGUID(path) : null; + } + + private static JArray FindAssetReferences(string searchGuid) + { + var assetRefs = new JArray(); + if (!string.IsNullOrEmpty(searchGuid)) + { + string[] dependencies = AssetDatabase.FindAssets("ref:" + searchGuid); + foreach (var guid in dependencies) + { + string path = AssetDatabase.GUIDToAssetPath(guid); + if (!string.IsNullOrEmpty(path)) + assetRefs.Add(path); + } + } + return assetRefs; + } + + private static HashSet BuildTargetInstanceIds(Object targetObject, string searchGuid) + { + var targetInstanceIds = new HashSet(); + if (targetObject != null) + targetInstanceIds.Add(targetObject.GetId()); + + if (!string.IsNullOrEmpty(searchGuid)) + { + string path = AssetDatabase.GUIDToAssetPath(searchGuid); + if (!string.IsNullOrEmpty(path)) + { + var allAssets = AssetDatabase.LoadAllAssetsAtPath(path); + foreach (var a in allAssets) + { + if (a != null) + targetInstanceIds.Add(a.GetId()); + } + } + } + return targetInstanceIds; + } + + private static JArray FindSceneReferences(HashSet targetInstanceIds) + { + var sceneRefs = new JArray(); + var allGameObjects = Resources.FindObjectsOfTypeAll() + .Where(go => go.hideFlags == HideFlags.None || go.hideFlags == HideFlags.NotEditable); + + using (UnityEngine.Pool.ListPool.Get(out var components)) + { + foreach (var go in allGameObjects) + { + if (go.scene == null || !go.scene.isLoaded) continue; + + go.GetComponents(components); + var goMatches = InspectGameObjectReferences(components, targetInstanceIds); + if (goMatches.Count > 0) + { + var goData = new JObject + { + ["name"] = go.name, + ["instance_id"] = go.GetRawId(), + ["references"] = goMatches + }; + sceneRefs.Add(goData); + } + } + } + return sceneRefs; + } + + private static JArray InspectGameObjectReferences(List components, HashSet targetInstanceIds) + { + var goMatches = new JArray(); + foreach (var comp in components) + { + if (comp == null) continue; + + using (var so = new SerializedObject(comp)) + { + var prop = so.GetIterator(); + bool enterChildren = true; + while (prop.Next(enterChildren)) + { + enterChildren = false; + if (prop.propertyType == SerializedPropertyType.ObjectReference) + { + var propId = MCPServerMethods.GetObjectReferenceId(prop); + var objRef = prop.objectReferenceValue; + + bool directMatch = targetInstanceIds.Contains(propId); + bool indirectMatch = objRef != null && targetInstanceIds.Contains(objRef.GetId()); + + if (directMatch || indirectMatch) + { + var matchDetail = new JObject + { + ["component"] = GetTypeName(comp.GetType()), + ["field"] = prop.propertyPath + }; + goMatches.Add(matchDetail); + } + } + } + } + } + return goMatches; + } + } +} diff --git a/Editor/MCPServerMethods.References.cs.meta b/Editor/MCPServerMethods.References.cs.meta new file mode 100644 index 0000000..f1a9a50 --- /dev/null +++ b/Editor/MCPServerMethods.References.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: a7d83f219b4e4c91a2578b930d8c11e2 diff --git a/Editor/MCPServerMethods.Search.cs b/Editor/MCPServerMethods.Search.cs index 21a8b16..508d74e 100644 --- a/Editor/MCPServerMethods.Search.cs +++ b/Editor/MCPServerMethods.Search.cs @@ -82,11 +82,7 @@ private static JToken FindObjects(JToken p) .Where(go => go.hideFlags == HideFlags.None); } - if (!string.IsNullOrEmpty(name)) - { - var regex = new System.Text.RegularExpressions.Regex(name, System.Text.RegularExpressions.RegexOptions.IgnoreCase); - results = results.Where(go => regex.IsMatch(go.name)); - } + results = FilterByName(results, name); if (!string.IsNullOrEmpty(tag)) results = results.Where(go => go.CompareTag(tag)); @@ -94,6 +90,38 @@ private static JToken FindObjects(JToken p) return new JObject { ["objects"] = new JArray(results.Take(50).Select(SerializeGameObject)) }; } + private static IEnumerable FilterByName(IEnumerable results, string name) + { + if (string.IsNullOrEmpty(name)) return results; + + System.Text.RegularExpressions.Regex regex = null; + try + { + regex = new System.Text.RegularExpressions.Regex(name, System.Text.RegularExpressions.RegexOptions.IgnoreCase, System.TimeSpan.FromMilliseconds(100)); + } + catch (System.ArgumentException) + { + // Invalid regex pattern (e.g. "Player (1)"), fallback to literal substring search + } + + if (regex != null) + { + return results.Where(go => + { + try + { + return regex.IsMatch(go.name); + } + catch (System.Text.RegularExpressions.RegexMatchTimeoutException) + { + return go.name.IndexOf(name, System.StringComparison.OrdinalIgnoreCase) >= 0; + } + }); + } + + return results.Where(go => go.name.IndexOf(name, System.StringComparison.OrdinalIgnoreCase) >= 0); + } + private static Stack _pathStackCache = new Stack(); private static JToken GetObjectPath(JToken p) @@ -121,129 +149,5 @@ private static JToken PingObject(JToken p) EditorGUIUtility.PingObject(obj); return new JObject { ["status"] = "Success", ["message"] = "Pinged" }; } - - private static JToken FindReferences(JToken p) - { - string targetGuid = p?["target_guid"]?.ToString(); - EntityId targetId = MCPServerMethods.ExtractId(p, "target_id"); - - if (string.IsNullOrEmpty(targetGuid) && targetId == default) - throw new System.Exception("Either target_guid or target_id is required."); - - Object targetObject = null; - if (!string.IsNullOrEmpty(targetGuid)) - { - string path = AssetDatabase.GUIDToAssetPath(targetGuid); - if (!string.IsNullOrEmpty(path)) - targetObject = AssetDatabase.LoadAssetAtPath(path); - } - else if (targetId != default) - { - targetObject = MCPServerMethods.IdToObject(targetId); - } - - if (targetObject == null) - throw new System.Exception("Could not find the target object."); - - var assetRefs = new JArray(); - string searchGuid = targetGuid; - - if (string.IsNullOrEmpty(searchGuid)) - { - string path = AssetDatabase.GetAssetPath(targetObject); - if (!string.IsNullOrEmpty(path)) - searchGuid = AssetDatabase.AssetPathToGUID(path); - } - - if (!string.IsNullOrEmpty(searchGuid)) - { - string[] dependencies = AssetDatabase.FindAssets("ref:" + searchGuid); - foreach (var guid in dependencies) - { - string path = AssetDatabase.GUIDToAssetPath(guid); - if (!string.IsNullOrEmpty(path)) - assetRefs.Add(path); - } - } - - var sceneRefs = new JArray(); - var allGameObjects = Resources.FindObjectsOfTypeAll() - .Where(go => go.hideFlags == HideFlags.None || go.hideFlags == HideFlags.NotEditable); - - var targetInstanceIds = new HashSet(); - if (targetObject != null) - targetInstanceIds.Add(targetObject.GetId()); - - if (!string.IsNullOrEmpty(searchGuid)) - { - string path = AssetDatabase.GUIDToAssetPath(searchGuid); - if (!string.IsNullOrEmpty(path)) - { - var allAssets = AssetDatabase.LoadAllAssetsAtPath(path); - foreach (var a in allAssets) - { - if (a != null) - targetInstanceIds.Add(a.GetId()); - } - } - } - - using (UnityEngine.Pool.ListPool.Get(out var components)) - { - foreach (var go in allGameObjects) - { - if (go.scene == null || !go.scene.isLoaded) continue; - - go.GetComponents(components); - var goMatches = new JArray(); - - foreach (var comp in components) - { - if (comp == null) continue; - - using (var so = new SerializedObject(comp)) - { - var prop = so.GetIterator(); - bool enterChildren = true; - while (prop.Next(enterChildren)) - { - enterChildren = false; - if (prop.propertyType == SerializedPropertyType.ObjectReference) - { - var propId = MCPServerMethods.GetObjectReferenceId(prop); - var objRef = prop.objectReferenceValue; - - bool directMatch = targetInstanceIds.Contains(propId); - bool indirectMatch = objRef != null && targetInstanceIds.Contains(objRef.GetId()); - - if (directMatch || indirectMatch) - { - var matchDetail = new JObject(); - matchDetail["component"] = GetTypeName(comp.GetType()); - matchDetail["field"] = prop.propertyPath; - goMatches.Add(matchDetail); - } - } - } - } - } - - if (goMatches.Count > 0) - { - var goData = new JObject(); - goData["name"] = go.name; - goData["instance_id"] = go.GetRawId(); - goData["references"] = goMatches; - sceneRefs.Add(goData); - } - } - } - - var result = new JObject(); - result["status"] = "Success"; - result["asset_references"] = assetRefs; - result["scene_references"] = sceneRefs; - return result; - } } } diff --git a/Tests~/Editor/FindObjectsTests.cs b/Tests~/Editor/FindObjectsTests.cs new file mode 100644 index 0000000..b9187d7 --- /dev/null +++ b/Tests~/Editor/FindObjectsTests.cs @@ -0,0 +1,65 @@ +using System.Linq; +using Newtonsoft.Json.Linq; +using NUnit.Framework; +using UnityEngine; + +namespace UnityMCP.Editor.Tests +{ + public class FindObjectsTests + { + [SetUp] + public void SetUp() + { + MCPServerMethods.Init(); + } + + [Test] + public void FindObjectsHandlesInvalidRegexAndPerformsLiteralSearch() + { + var go = new GameObject("TestObject (1)"); + try + { + JObject result = RpcResult("find_objects", new JObject { ["name"] = "TestObject (1)" }); + JArray objects = (JArray)result["objects"]; + Assert.IsNotNull(objects); + Assert.IsTrue(objects.Any(o => o["name"]?.ToString() == "TestObject (1)")); + } + finally + { + Object.DestroyImmediate(go); + } + } + + [Test] + public void FindObjectsMatchesValidRegexPattern() + { + var go = new GameObject("RegexObject123"); + try + { + JObject result = RpcResult("find_objects", new JObject { ["name"] = "^RegexObject\\d+$" }); + JArray objects = (JArray)result["objects"]; + Assert.IsNotNull(objects); + Assert.IsTrue(objects.Any(o => o["name"]?.ToString() == "RegexObject123")); + } + finally + { + Object.DestroyImmediate(go); + } + } + + private static JObject RpcResult(string method, JObject parameters = null) + { + var request = new JObject + { + ["jsonrpc"] = "2.0", + ["method"] = method, + ["params"] = parameters ?? new JObject(), + ["id"] = 1 + }; + + JObject response = JObject.Parse(MCPServerMethods.ProcessJsonRpc(request.ToString(Formatting.None))); + Assert.IsNull(response["error"], response.ToString(Formatting.None)); + return (JObject)response["result"]; + } + } +}