tenseleyflow/loader / da96452

Browse files

Prefer external search roots

Authored by mfwolffe <wolffemf@dukes.jmu.edu>
SHA
da964525825c4be99a97fb3a450828410547016e
Parents
873539b
Tree
f2f194e

2 changed files

StatusFile+-
M src/loader/runtime/hooks.py 62 0
M tests/test_permissions.py 44 0
src/loader/runtime/hooks.pymodified
@@ -273,6 +273,7 @@ class RelativePathContextHook(BaseToolHook):
273273
             resolved = self._resolve_recent_context_path(
274274
                 raw_path,
275275
                 require_existing=require_existing,
276
+                prefer_external_ancestor=context.tool_call.name in self._SEARCH_TOOLS,
276277
             )
277278
         if resolved is None:
278279
             return HookResult()
@@ -296,9 +297,17 @@ class RelativePathContextHook(BaseToolHook):
296297
         raw_path: str,
297298
         *,
298299
         require_existing: bool,
300
+        prefer_external_ancestor: bool,
299301
     ) -> str | None:
300302
         workspace_candidate = (self.workspace_root / raw_path).expanduser()
301303
         if workspace_candidate.exists():
304
+            if prefer_external_ancestor:
305
+                anchored = self._resolve_recent_context_ancestor(
306
+                    raw_path,
307
+                    require_existing=require_existing,
308
+                )
309
+                if anchored is not None:
310
+                    return anchored
302311
             return None
303312
 
304313
         for base_dir in self.action_tracker.recent_path_contexts():
@@ -309,6 +318,59 @@ class RelativePathContextHook(BaseToolHook):
309318
                 continue
310319
             if candidate.exists() or candidate.parent.exists():
311320
                 return str(candidate)
321
+        if prefer_external_ancestor:
322
+            return self._resolve_recent_context_ancestor(
323
+                raw_path,
324
+                require_existing=require_existing,
325
+            )
326
+        return None
327
+
328
+    def _resolve_recent_context_ancestor(
329
+        self,
330
+        raw_path: str,
331
+        *,
332
+        require_existing: bool,
333
+    ) -> str | None:
334
+        raw_parts = tuple(part for part in Path(raw_path).parts if part not in {"."})
335
+        if not raw_parts:
336
+            return None
337
+
338
+        for base_dir in self.action_tracker.recent_path_contexts():
339
+            base_path = Path(base_dir).expanduser()
340
+            try:
341
+                resolved_base = base_path.resolve(strict=False)
342
+            except Exception:
343
+                resolved_base = base_path
344
+            if resolved_base == self.workspace_root:
345
+                continue
346
+            try:
347
+                resolved_base.relative_to(self.workspace_root)
348
+                continue
349
+            except ValueError:
350
+                pass
351
+
352
+            matched = self._match_recent_context_ancestor(
353
+                resolved_base,
354
+                raw_parts,
355
+            )
356
+            if matched is None:
357
+                continue
358
+            if require_existing and not matched.exists():
359
+                continue
360
+            return str(matched)
361
+        return None
362
+
363
+    def _match_recent_context_ancestor(
364
+        self,
365
+        base_path: Path,
366
+        raw_parts: tuple[str, ...],
367
+    ) -> Path | None:
368
+        candidates = [base_path, *base_path.parents]
369
+        for candidate in candidates:
370
+            if len(candidate.parts) < len(raw_parts):
371
+                continue
372
+            if candidate.parts[-len(raw_parts) :] == raw_parts:
373
+                return candidate
312374
         return None
313375
 
314376
     def _resolve_workspace_mirror_path(
tests/test_permissions.pymodified
@@ -539,6 +539,50 @@ async def test_relative_path_context_hook_remaps_workspace_mirror_of_external_ro
539539
     ]
540540
 
541541
 
542
+@pytest.mark.asyncio
543
+async def test_relative_path_context_hook_prefers_external_search_ancestor_over_workspace_match(
544
+    temp_dir: Path,
545
+) -> None:
546
+    workspace_root = temp_dir / "workspace"
547
+    (workspace_root / "guides").mkdir(parents=True)
548
+    external_root = temp_dir / "external-home"
549
+    external_fortran = external_root / "Loader" / "guides" / "fortran"
550
+    external_fortran.mkdir(parents=True)
551
+    (external_fortran / "index.html").write_text("<html></html>\n")
552
+
553
+    registry = create_default_registry(workspace_root)
554
+    policy = build_permission_policy(
555
+        active_mode=PermissionMode.WORKSPACE_WRITE,
556
+        workspace_root=workspace_root,
557
+        tool_requirements=registry.get_tool_requirements(),
558
+    )
559
+    action_tracker = ActionTracker()
560
+    action_tracker.record_tool_call(
561
+        "read",
562
+        {"file_path": str(external_fortran / "index.html")},
563
+    )
564
+    hook = RelativePathContextHook(action_tracker, workspace_root)
565
+
566
+    result = await hook.pre_tool_use(
567
+        HookContext(
568
+            tool_call=ToolCall(
569
+                id="glob-ancestor-1",
570
+                name="glob",
571
+                arguments={"path": "guides", "pattern": "**"},
572
+            ),
573
+            tool=registry.get("glob"),
574
+            registry=registry,
575
+            permission_policy=policy,
576
+            source="native",
577
+        )
578
+    )
579
+
580
+    assert result.updated_arguments is not None
581
+    assert Path(result.updated_arguments["path"]).resolve() == (
582
+        external_root / "Loader" / "guides"
583
+    ).resolve()
584
+
585
+
542586
 class FakeSession:
543587
     def __init__(self, *, active_dod_path: str, messages: list[Message]) -> None:
544588
         self.active_dod_path = active_dod_path