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
69 changes: 67 additions & 2 deletions packages/kaos/src/kaos/__init__.py
Original file line number Diff line number Diff line change
@@ -1,9 +1,10 @@
from __future__ import annotations

import contextvars
from collections.abc import AsyncGenerator, AsyncIterator, Iterable
import fnmatch
from collections.abc import AsyncGenerator, AsyncIterator, Callable, Iterable
from dataclasses import dataclass
from pathlib import PurePath
from pathlib import PurePath, PurePosixPath
from typing import TYPE_CHECKING, Literal, Protocol, runtime_checkable

if TYPE_CHECKING:
Expand Down Expand Up @@ -294,6 +295,70 @@ def glob(
return get_current_kaos().glob(path, pattern, case_sensitive=case_sensitive)


async def glob_pruned(
base_dir: KaosPath,
pattern: str,
*,
should_ignore: Callable[[KaosPath, bool], bool] | None = None,
) -> list[KaosPath]:
"""Glob with a pruning callback to skip entries during traversal."""
normalized_pattern = pattern.replace("\\", "/")
parts = list(PurePosixPath(normalized_pattern).parts)
if parts and parts[0] == "/":
parts = parts[1:]

matches: list[KaosPath] = []

def _should_ignore(path: KaosPath, is_dir: bool) -> bool:
if should_ignore is None:
return False
return should_ignore(path, is_dir)

async def recurse(current: KaosPath, idx: int) -> None:
if idx == len(parts):
matches.append(current)
return

part = parts[idx]
if part == "**":
await recurse(current, idx + 1)

if not await current.is_dir():
return

async for child in current.iterdir():
try:
is_dir = await child.is_dir()
except OSError:
continue

if _should_ignore(child, is_dir):
continue

if is_dir:
await recurse(child, idx)
elif idx == len(parts) - 1:
matches.append(child)
return

if not await current.is_dir():
return

async for child in current.iterdir():
try:
is_dir = await child.is_dir()
except OSError:
continue

if _should_ignore(child, is_dir):
continue

if fnmatch.fnmatchcase(child.name, part):
await recurse(child, idx + 1)

await recurse(base_dir, 0)
return matches

async def readbytes(path: StrOrKaosPath, n: int | None = None) -> bytes:
return await get_current_kaos().readbytes(path, n=n)

Expand Down
2 changes: 1 addition & 1 deletion src/kimi_cli/acp/tools.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,10 @@
from contextlib import suppress

import acp
from kaos import get_current_kaos
from kaos.local import local_kaos
from kosong.tooling import CallableTool2, ToolReturnValue

from kaos import get_current_kaos
from kimi_cli.soul.agent import Runtime
from kimi_cli.soul.approval import Approval
from kimi_cli.soul.toolset import KimiToolset
Expand Down
2 changes: 1 addition & 1 deletion src/kimi_cli/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,10 +7,10 @@
from pathlib import Path
from typing import TYPE_CHECKING, Any

import kaos
from kaos.path import KaosPath
from pydantic import SecretStr

import kaos
from kimi_cli.agentspec import DEFAULT_AGENT_FILE
from kimi_cli.auth.oauth import OAuthManager
from kimi_cli.cli import InputFormat, OutputFormat
Expand Down
2 changes: 1 addition & 1 deletion src/kimi_cli/metadata.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,11 +4,11 @@
from hashlib import md5
from pathlib import Path

from kaos import get_current_kaos
from kaos.local import local_kaos
from kaos.path import KaosPath
from pydantic import BaseModel, ConfigDict, Field

from kaos import get_current_kaos
from kimi_cli.share import get_share_dir
from kimi_cli.utils.logging import logger

Expand Down
68 changes: 64 additions & 4 deletions src/kimi_cli/tools/file/glob.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,10 +3,13 @@
from pathlib import Path
from typing import override

from kaos import glob_pruned
from kaos.path import KaosPath
from kosong.tooling import CallableTool2, ToolError, ToolOk, ToolReturnValue
from pathspec import PathSpec
from pydantic import BaseModel, Field

from kaos import glob_pruned
from kimi_cli.soul.agent import BuiltinSystemPromptArgs
from kimi_cli.tools.utils import load_desc
from kimi_cli.utils.path import is_within_directory, list_directory
Expand Down Expand Up @@ -42,14 +45,59 @@ def __init__(self, builtin_args: BuiltinSystemPromptArgs) -> None:
super().__init__()
self._work_dir = builtin_args.KIMI_WORK_DIR

async def _load_gitignore_spec(self) -> PathSpec | None:
"""Return a PathSpec built from the working directory's .gitignore if it exists."""
gitignore_path = self._work_dir / ".gitignore"
if not await gitignore_path.is_file():
return None
try:
contents = await gitignore_path.read_text()
except OSError:
return None
return PathSpec.from_lines("gitwildmatch", contents.splitlines())

def _is_gitignored(self, path: KaosPath, gitignore_spec: PathSpec, is_dir: bool) -> bool:
"""Return True if the path matches the gitignore spec."""
try:
relative = path.relative_to(self._work_dir)
relative_str = str(relative)
except ValueError:
relative_str = str(path)
relative_str = relative_str.replace("\\", "/")
if is_dir and not relative_str.endswith("/"):
relative_str += "/"
return gitignore_spec.match_file(relative_str)

async def _filter_gitignored(
self, paths: list[KaosPath], gitignore_spec: PathSpec
) -> list[KaosPath]:
"""Filter out gitignored paths after a regular glob traversal."""
filtered: list[KaosPath] = []
for path in paths:
try:
is_dir = await path.is_dir()
except OSError:
continue

if self._is_gitignored(path, gitignore_spec, is_dir):
continue

filtered.append(path)
return filtered

async def _validate_pattern(self, pattern: str) -> ToolError | None:
"""Validate that the pattern is safe to use."""
if pattern.startswith("**"):
gitignore_path = self._work_dir / ".gitignore"
if await gitignore_path.is_file():
return None

ls_result = await list_directory(self._work_dir)
return ToolError(
output=ls_result,
message=(
f"Pattern `{pattern}` starts with '**' which is not allowed. "
f"Pattern `{pattern}` starts with '**' which is not allowed because "
"the working directory does not contain a .gitignore to constrain the search. "
"This would recursively search all directories and may include large "
"directories like `node_modules`. Use more specific patterns instead. "
"For your convenience, a list of all files and directories in the "
Expand Down Expand Up @@ -109,10 +157,22 @@ async def __call__(self, params: Params) -> ToolReturnValue:
brief="Invalid directory",
)

gitignore_spec = await self._load_gitignore_spec()

# Perform the glob search - users can use ** directly in pattern
matches: list[KaosPath] = []
async for match in dir_path.glob(params.pattern):
matches.append(match)
normalized_pattern = params.pattern.replace("\\", "/")
if gitignore_spec and normalized_pattern.startswith("**"):
matches = await glob_pruned(
dir_path,
normalized_pattern,
should_ignore=lambda path, is_dir: self._is_gitignored(
path, gitignore_spec, is_dir
),
)
else:
matches = [match async for match in dir_path.glob(params.pattern)]
if gitignore_spec:
matches = await self._filter_gitignored(matches, gitignore_spec)

# Filter out directories if not requested
if not params.include_dirs:
Expand Down
4 changes: 2 additions & 2 deletions src/kimi_cli/tools/shell/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,11 +3,11 @@
from pathlib import Path
from typing import override

import kaos
from kaos import AsyncReadable
from kosong.tooling import CallableTool2, ToolReturnValue
from pydantic import BaseModel, Field

import kaos
from kaos import AsyncReadable
from kimi_cli.soul.approval import Approval
from kimi_cli.tools.display import ShellDisplayBlock
from kimi_cli.tools.utils import ToolRejectedError, ToolResultBuilder, load_desc
Expand Down
39 changes: 39 additions & 0 deletions tests/tools/test_glob.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,9 @@

import pytest
from kaos.path import KaosPath
from kosong.tooling import ToolReturnValue

from kaos import get_current_kaos
from kimi_cli.tools.file.glob import MAX_MATCHES, Glob, Params


Expand Down Expand Up @@ -63,6 +65,43 @@ async def test_glob_recursive_pattern_prohibited(glob_tool: Glob, test_files: Ka
assert "Unsafe pattern" in result.brief


async def test_glob_recursive_pattern_allowed_with_gitignore(glob_tool: Glob, test_files: KaosPath):
"""Allow recursive ** pattern when a .gitignore exists in work dir."""
await (test_files / ".gitignore").write_text("node_modules/\n")

result = await glob_tool(Params(pattern="**/*.py", directory=str(test_files)))

assert isinstance(result, ToolReturnValue)
assert isinstance(result.output, str)
output = result.output.replace("\\", "/")
assert "setup.py" in output
assert "src/main/app.py" in output


async def test_glob_respects_gitignore_on_traversal(
monkeypatch: pytest.MonkeyPatch, glob_tool: Glob, temp_work_dir: KaosPath
):
"""Ensure gitignored directories are not walked when using ** patterns."""
await (temp_work_dir / ".gitignore").write_text("node_modules/\n")
await (temp_work_dir / "node_modules" / "pkg").mkdir(parents=True)
await (temp_work_dir / "node_modules" / "pkg" / "index.py").write_text("ignored")
await (temp_work_dir / "app.py").write_text("app = 1")

# If the gitignore-aware path isn't used, LocalKaos.glob would be called.
kaos_backend = get_current_kaos()

async def failing_glob(*args, **kwargs):
raise AssertionError("KAOS glob should not be used for ** when .gitignore exists.")

monkeypatch.setattr(kaos_backend, "glob", failing_glob)

result = await glob_tool(Params(pattern="**/*.py", directory=str(temp_work_dir)))

assert isinstance(result, ToolReturnValue)
assert "app.py" in result.output
assert "node_modules" not in result.output


async def test_glob_safe_recursive_pattern(glob_tool: Glob, test_files: KaosPath):
"""Test safe recursive glob pattern that doesn't start with **/."""
result = await glob_tool(Params(pattern="src/**/*.py", directory=str(test_files)))
Expand Down
Loading