Recover implicit glob search roots
- SHA
a5d8a7603b93e7155dd09d4b1b1e0b2f68400e0c- Parents
-
1ef5974 - Tree
069d6ab
a5d8a76
a5d8a7603b93e7155dd09d4b1b1e0b2f68400e0c1ef5974
069d6ab| Status | File | + | - |
|---|---|---|---|
| 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): | ||
| 188 | 188 | arguments: dict[str, Any], |
| 189 | 189 | ) -> dict[str, Any] | None: |
| 190 | 190 | pattern = str(arguments.get("pattern", "")).strip() |
| 191 | - if not pattern or not pattern.startswith(("/", "~", "./", "../")): | |
| 191 | + if not pattern: | |
| 192 | 192 | return None |
| 193 | 193 | |
| 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 | |
| 197 | 205 | if not parent or not basename: |
| 198 | 206 | return None |
| 199 | 207 | if any(token in parent for token in ("*", "?", "[")): |
@@ -204,6 +212,30 @@ class SearchPathAliasHook(BaseToolHook): | ||
| 204 | 212 | updated_arguments["pattern"] = basename |
| 205 | 213 | return updated_arguments |
| 206 | 214 | |
| 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 | + | |
| 207 | 239 | |
| 208 | 240 | class RelativePathContextHook(BaseToolHook): |
| 209 | 241 | """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( | ||
| 421 | 421 | assert result.updated_arguments["pattern"] == "*.html" |
| 422 | 422 | |
| 423 | 423 | |
| 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 | + | |
| 424 | 484 | @pytest.mark.asyncio |
| 425 | 485 | async def test_relative_path_context_hook_remaps_workspace_mirror_of_external_root( |
| 426 | 486 | temp_dir: Path, |