Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 11 additions & 3 deletions notebook_intelligence/built_in_toolsets.py
Original file line number Diff line number Diff line change
Expand Up @@ -335,15 +335,23 @@ async def search_files(
pinfo = file_pattern if file_pattern else pattern
return f"No files found matching pattern '{pinfo}' in '{directory}'"

root_dir = Path(jupyter_root_dir).expanduser().resolve()
for file_path in files:
root_dir = Path(jupyter_root_dir).expanduser().resolve()
try:
rel_path = file_path.relative_to(root_dir)
except ValueError:
rel_path = file_path
# A glob hit outside the workspace root should not be read.
continue

try:
safe_file = _get_safe_path(str(rel_path))
except ValueError:
# Outbound symlinks (e.g. leak.txt -> /etc/passwd) must be
# skipped, matching read_file's safe_jupyter_path gate.
continue

try:
with open(file_path, 'r', encoding='utf-8') as f:
with open(safe_file, 'r', encoding='utf-8') as f:
lines = f.readlines()
except Exception:
continue # skip unreadable files
Expand Down
58 changes: 58 additions & 0 deletions tests/test_builtin_search_files_sandbox.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
"""Sandbox tests for the built-in search_files tool.

``read_file`` routes every path through ``safe_jupyter_path`` before
opening; ``search_files`` must apply the same gate to each glob match so
outbound workspace symlinks cannot leak host file contents.
"""

import asyncio

import pytest

import notebook_intelligence.built_in_toolsets as toolsets
from notebook_intelligence.util import set_jupyter_root_dir


@pytest.fixture
def jupyter_root(tmp_path, monkeypatch):
root = tmp_path / "workspace"
root.mkdir()
monkeypatch.setattr(toolsets, "get_jupyter_root_dir", lambda: str(root))
set_jupyter_root_dir(str(root))
return root


def _search_files(pattern: str, **kwargs) -> str:
tool = toolsets.search_files._tool_function
return asyncio.run(tool(pattern=pattern, **kwargs))


class TestSearchFilesSymlinkSandbox:
def test_skips_outbound_symlink_when_searching_content(
self, jupyter_root, tmp_path
):
outside = tmp_path / "secret.txt"
outside.write_text("TOP_SECRET_DATA\n", encoding="utf-8")
link = jupyter_root / "leak.txt"
link.symlink_to(outside)

result = _search_files(
pattern="leak.txt",
directory=".",
content_pattern="TOP_SECRET",
)

assert "TOP_SECRET_DATA" not in result
assert "No matches found" in result

def test_reads_legitimate_workspace_file(self, jupyter_root):
target = jupyter_root / "notes.txt"
target.write_text("hello workspace\n", encoding="utf-8")

result = _search_files(
pattern="notes.txt",
directory=".",
content_pattern="workspace",
)

assert "hello workspace" in result
Loading