tenseleyflow/loader / a5d8a76

Browse files

Recover implicit glob search roots

Authored by espadonne
SHA
a5d8a7603b93e7155dd09d4b1b1e0b2f68400e0c
Parents
1ef5974
Tree
069d6ab

2 changed files

StatusFile+-
M src/loader/runtime/hooks.py 36 4
M tests/test_permissions.py 60 0
src/loader/runtime/hooks.pymodified
@@ -188,12 +188,20 @@ class SearchPathAliasHook(BaseToolHook):
188188
         arguments: dict[str, Any],
189189
     ) -> dict[str, Any] | None:
190190
         pattern = str(arguments.get("pattern", "")).strip()
191
-        if not pattern or not pattern.startswith(("/", "~", "./", "../")):
191
+        if not pattern:
192192
             return None
193193
 
194
-        pattern_path = Path(pattern)
195
-        parent = str(pattern_path.parent).strip()
196
-        basename = pattern_path.name.strip()
194
+        parent = ""
195
+        basename = ""
196
+        if pattern.startswith(("/", "~", "./", "../")):
197
+            pattern_path = Path(pattern)
198
+            parent = str(pattern_path.parent).strip()
199
+            basename = pattern_path.name.strip()
200
+        else:
201
+            implicit = self._split_implicit_glob_parent(pattern)
202
+            if implicit is None:
203
+                return None
204
+            parent, basename = implicit
197205
         if not parent or not basename:
198206
             return None
199207
         if any(token in parent for token in ("*", "?", "[")):
@@ -204,6 +212,30 @@ class SearchPathAliasHook(BaseToolHook):
204212
         updated_arguments["pattern"] = basename
205213
         return updated_arguments
206214
 
215
+    def _split_implicit_glob_parent(self, pattern: str) -> tuple[str, str] | None:
216
+        if "/" not in pattern:
217
+            return None
218
+
219
+        parts = [segment for segment in pattern.split("/") if segment]
220
+        while parts and self._is_wildcard_segment(parts[0]):
221
+            parts.pop(0)
222
+        if len(parts) < 2:
223
+            return None
224
+
225
+        parent_parts = parts[:-1]
226
+        basename = parts[-1].strip()
227
+        if not basename or not parent_parts:
228
+            return None
229
+        if any(self._segment_contains_glob(segment) for segment in parent_parts):
230
+            return None
231
+        return "/".join(parent_parts), basename
232
+
233
+    def _is_wildcard_segment(self, segment: str) -> bool:
234
+        return bool(segment) and all(char in "*?[]" for char in segment)
235
+
236
+    def _segment_contains_glob(self, segment: str) -> bool:
237
+        return any(token in segment for token in ("*", "?", "["))
238
+
207239
 
208240
 class RelativePathContextHook(BaseToolHook):
209241
     """Recover relative file/search paths against recently-used external directories."""
tests/test_permissions.pymodified
@@ -421,6 +421,66 @@ async def test_search_path_alias_hook_splits_full_glob_pattern(
421421
     assert result.updated_arguments["pattern"] == "*.html"
422422
 
423423
 
424
+@pytest.mark.asyncio
425
+async def test_search_path_alias_hook_splits_implicit_recursive_glob_parent(
426
+    temp_dir: Path,
427
+) -> None:
428
+    registry = create_default_registry(temp_dir)
429
+    policy = build_permission_policy(
430
+        active_mode=PermissionMode.WORKSPACE_WRITE,
431
+        workspace_root=temp_dir,
432
+        tool_requirements=registry.get_tool_requirements(),
433
+    )
434
+    hook = SearchPathAliasHook()
435
+
436
+    result = await hook.pre_tool_use(
437
+        HookContext(
438
+            tool_call=ToolCall(
439
+                id="glob-implicit-1",
440
+                name="glob",
441
+                arguments={"pattern": "**/Loader/guides/nginx/chapters/*.html"},
442
+            ),
443
+            tool=registry.get("glob"),
444
+            registry=registry,
445
+            permission_policy=policy,
446
+            source="native",
447
+        )
448
+    )
449
+
450
+    assert result.updated_arguments is not None
451
+    assert result.updated_arguments["path"] == "Loader/guides/nginx/chapters"
452
+    assert result.updated_arguments["pattern"] == "*.html"
453
+
454
+
455
+@pytest.mark.asyncio
456
+async def test_search_path_alias_hook_leaves_fully_generic_recursive_glob_unchanged(
457
+    temp_dir: Path,
458
+) -> None:
459
+    registry = create_default_registry(temp_dir)
460
+    policy = build_permission_policy(
461
+        active_mode=PermissionMode.WORKSPACE_WRITE,
462
+        workspace_root=temp_dir,
463
+        tool_requirements=registry.get_tool_requirements(),
464
+    )
465
+    hook = SearchPathAliasHook()
466
+
467
+    result = await hook.pre_tool_use(
468
+        HookContext(
469
+            tool_call=ToolCall(
470
+                id="glob-generic-1",
471
+                name="glob",
472
+                arguments={"pattern": "**/*.html"},
473
+            ),
474
+            tool=registry.get("glob"),
475
+            registry=registry,
476
+            permission_policy=policy,
477
+            source="native",
478
+        )
479
+    )
480
+
481
+    assert result.updated_arguments is None
482
+
483
+
424484
 @pytest.mark.asyncio
425485
 async def test_relative_path_context_hook_remaps_workspace_mirror_of_external_root(
426486
     temp_dir: Path,