Skip to content
Merged
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
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ Only write entries that are worth mentioning to users.

## Unreleased

- Tool: Allow `WriteFile` and `StrReplaceFile` tools to edit/write files outside the working directory when using absolute paths
- Tool: Upload videos to Kimi files API when using Kimi provider, replacing inline data URLs with `ms://` references
- Config: Add `reserved_context_size` setting to customize auto-compaction trigger threshold (default: 50000 tokens)

Expand Down
9 changes: 5 additions & 4 deletions docs/en/customization/agents.md
Original file line number Diff line number Diff line change
Expand Up @@ -233,7 +233,7 @@ The following are all built-in tools in Kimi CLI.
### `WriteFile`

- **Path**: `kimi_cli.tools.file:WriteFile`
- **Description**: Write files. Can only write to files within working directory, must use absolute paths, requires user approval.
- **Description**: Write files. Requires user approval. Absolute paths are required when writing files outside the working directory.

| Parameter | Type | Description |
|-----------|------|-------------|
Expand All @@ -244,7 +244,7 @@ The following are all built-in tools in Kimi CLI.
### `StrReplaceFile`

- **Path**: `kimi_cli.tools.file:StrReplaceFile`
- **Description**: Edit files using string replacement. Can only edit files within working directory, must use absolute paths, requires user approval.
- **Description**: Edit files using string replacement. Requires user approval. Absolute paths are required when editing files outside the working directory.

| Parameter | Type | Description |
|-----------|------|-------------|
Expand Down Expand Up @@ -307,8 +307,9 @@ The following are all built-in tools in Kimi CLI.

**Working directory restrictions**

- File writing and editing can only be done within the working directory
- Files outside working directory can be read, but require absolute paths
- File reading and writing are typically done within the working directory
- Absolute paths are required when reading files outside the working directory
- Write and edit operations require user approval; absolute paths are required when operating on files outside the working directory

**Approval mechanism**

Expand Down
2 changes: 2 additions & 0 deletions docs/en/release-notes/changelog.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@ This page documents the changes in each Kimi CLI release.

## Unreleased

- Tool: Allow `WriteFile` and `StrReplaceFile` tools to edit/write files outside the working directory when using absolute paths
- Tool: Upload videos to Kimi files API when using Kimi provider, replacing inline data URLs with `ms://` references
- Config: Add `reserved_context_size` setting to customize auto-compaction trigger threshold (default: 50000 tokens)

## 0.81 (2026-01-21)
Expand Down
9 changes: 5 additions & 4 deletions docs/zh/customization/agents.md
Original file line number Diff line number Diff line change
Expand Up @@ -233,7 +233,7 @@ agent:
### `WriteFile`

- **路径**:`kimi_cli.tools.file:WriteFile`
- **描述**:写入文件。只能写入工作目录内的文件,必须使用绝对路径,需要用户审批
- **描述**:写入文件。写入操作需要用户审批。写入工作目录外文件时,必须使用绝对路径。

| 参数 | 类型 | 说明 |
|------|------|------|
Expand All @@ -244,7 +244,7 @@ agent:
### `StrReplaceFile`

- **路径**:`kimi_cli.tools.file:StrReplaceFile`
- **描述**:使用字符串替换编辑文件。只能编辑工作目录内的文件,必须使用绝对路径,需要用户审批
- **描述**:使用字符串替换编辑文件。编辑操作需要用户审批。编辑工作目录外文件时,必须使用绝对路径。

| 参数 | 类型 | 说明 |
|------|------|------|
Expand Down Expand Up @@ -307,8 +307,9 @@ agent:

**工作目录限制**

- 文件写入和编辑只能在工作目录内进行
- 工作目录外的文件可以读取,但需使用绝对路径
- 文件读写通常在工作目录内进行
- 读取工作目录外文件需使用绝对路径
- 写入和编辑操作都需要用户审批;操作工作目录外文件时,必须使用绝对路径

**审批机制**

Expand Down
2 changes: 2 additions & 0 deletions docs/zh/release-notes/changelog.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@

## 未发布

- Tool:`WriteFile` 和 `StrReplaceFile` 工具支持使用绝对路径编辑/写入工作目录外的文件
- Tool:使用 Kimi 供应商时,视频文件上传到 Kimi Files API,使用 `ms://` 引用替代 inline data URL
- Config:添加 `reserved_context_size` 配置项,自定义自动压缩触发阈值(默认 50000 tokens)

## 0.81 (2026-01-21)
Expand Down
1 change: 1 addition & 0 deletions src/kimi_cli/tools/file/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ class FileOpsWindow:
class FileActions(str, Enum):
READ = "read file"
EDIT = "edit file"
EDIT_OUTSIDE = "edit file outside of working directory"


from .glob import Glob # noqa: E402
Expand Down
3 changes: 1 addition & 2 deletions src/kimi_cli/tools/file/read.py
Original file line number Diff line number Diff line change
Expand Up @@ -73,7 +73,6 @@ def __init__(self, runtime: Runtime) -> None:

async def _validate_path(self, path: KaosPath) -> ToolError | None:
"""Validate that the path is safe to read."""
# Check for path traversal attempts
resolved_path = path.canonical()

if not is_within_directory(resolved_path, self._work_dir) and not path.is_absolute():
Expand Down Expand Up @@ -142,9 +141,9 @@ async def __call__(self, params: Params) -> ToolReturnValue:

try:
p = KaosPath(params.path).expanduser()

if err := await self._validate_path(p):
return err
p = p.canonical()

if not await p.exists():
return ToolError(
Expand Down
55 changes: 30 additions & 25 deletions src/kimi_cli/tools/file/replace.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,12 @@ class Edit(BaseModel):


class Params(BaseModel):
path: str = Field(description="The absolute path to the file to edit.")
path: str = Field(
description=(
"The path to the file to edit. Absolute paths are required when editing files "
"outside the working directory."
)
)
edit: Edit | list[Edit] = Field(
description=(
"The edit(s) to apply to the file. "
Expand All @@ -42,17 +47,16 @@ def __init__(self, builtin_args: BuiltinSystemPromptArgs, approval: Approval):

async def _validate_path(self, path: KaosPath) -> ToolError | None:
"""Validate that the path is safe to edit."""
# Check for path traversal attempts
resolved_path = path.canonical()

# Ensure the path is within work directory
if not is_within_directory(resolved_path, self._work_dir):
if not is_within_directory(resolved_path, self._work_dir) and not path.is_absolute():
return ToolError(
message=(
f"`{path}` is outside the working directory. "
"You can only edit files within the working directory."
f"`{path}` is not an absolute path. "
"You must provide an absolute path to edit a file "
"outside the working directory."
),
brief="Path outside working directory",
brief="Invalid path",
)
return None

Expand All @@ -65,22 +69,17 @@ def _apply_edit(self, content: str, edit: Edit) -> str:

@override
async def __call__(self, params: Params) -> ToolReturnValue:
try:
p = KaosPath(params.path)

if not p.is_absolute():
return ToolError(
message=(
f"`{params.path}` is not an absolute path. "
"You must provide an absolute path to edit a file."
),
brief="Invalid path",
)
if not params.path:
return ToolError(
message="File path cannot be empty.",
brief="Empty file path",
)

# Validate path safety
path_error = await self._validate_path(p)
if path_error:
return path_error
try:
p = KaosPath(params.path).expanduser()
if err := await self._validate_path(p):
return err
p = p.canonical()
Comment on lines +80 to +82

Copilot AI Jan 21, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

There is redundant path canonicalization. The path is canonicalized inside _validate_path at line 50, but then canonicalized again at line 82 after validation. Additionally, the validation is called with the expanded but non-canonical path, which could lead to inconsistent behavior. Consider either canonicalizing before validation or removing the duplicate canonicalization after validation.

Suggested change
if err := await self._validate_path(p):
return err
p = p.canonical()
p = p.canonical()
if err := await self._validate_path(p):
return err

Copilot uses AI. Check for mistakes.

if not await p.exists():
return ToolError(
Expand Down Expand Up @@ -111,14 +110,20 @@ async def __call__(self, params: Params) -> ToolReturnValue:
)

diff_blocks: list[DisplayBlock] = list(
build_diff_blocks(params.path, original_content, content)
build_diff_blocks(str(p), original_content, content)
)

action = (
FileActions.EDIT
if is_within_directory(p, self._work_dir)
else FileActions.EDIT_OUTSIDE
)

# Request approval
if not await self._approval.request(
self.name,
FileActions.EDIT,
f"Edit file `{params.path}`",
action,
f"Edit file `{p}`",
display=diff_blocks,
):
return ToolRejectedError()
Expand Down
55 changes: 30 additions & 25 deletions src/kimi_cli/tools/file/write.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,12 @@


class Params(BaseModel):
path: str = Field(description="The absolute path to the file to write")
path: str = Field(
description=(
"The path to the file to write. Absolute paths are required when writing files "
"outside the working directory."
)
)
content: str = Field(description="The content to write to the file")
mode: Literal["overwrite", "append"] = Field(
description=(
Expand All @@ -39,41 +44,35 @@ def __init__(self, builtin_args: BuiltinSystemPromptArgs, approval: Approval):

async def _validate_path(self, path: KaosPath) -> ToolError | None:
"""Validate that the path is safe to write."""
# Check for path traversal attempts
resolved_path = path.canonical()

# Ensure the path is within work directory
if not is_within_directory(resolved_path, self._work_dir):
if not is_within_directory(resolved_path, self._work_dir) and not path.is_absolute():
return ToolError(
message=(
f"`{path}` is outside the working directory. "
"You can only write files within the working directory."
f"`{path}` is not an absolute path. "
"You must provide an absolute path to write a file "
"outside the working directory."
),
brief="Path outside working directory",
brief="Invalid path",
)
return None

@override
async def __call__(self, params: Params) -> ToolReturnValue:
# TODO: checks:
# - check if the path may contain secrets
# - check if the file format is writable
try:
p = KaosPath(params.path)
if not params.path:
return ToolError(
message="File path cannot be empty.",
brief="Empty file path",
)

if not p.is_absolute():
return ToolError(
message=(
f"`{params.path}` is not an absolute path. "
"You must provide an absolute path to write a file."
),
brief="Invalid path",
)
try:
p = KaosPath(params.path).expanduser()

# Validate path safety
path_error = await self._validate_path(p)
if path_error:
return path_error
if err := await self._validate_path(p):
return err
p = p.canonical()
Comment on lines +71 to +75

Copilot AI Jan 21, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

There is redundant path canonicalization. The path is canonicalized inside _validate_path at line 47, but then canonicalized again at line 75 after validation. Additionally, the validation is called with the expanded but non-canonical path, which could lead to inconsistent behavior. Consider either canonicalizing before validation or removing the duplicate canonicalization after validation.

Copilot uses AI. Check for mistakes.

if not await p.parent.exists():
return ToolError(
Expand Down Expand Up @@ -101,17 +100,23 @@ async def __call__(self, params: Params) -> ToolReturnValue:
)
diff_blocks: list[DisplayBlock] = list(
build_diff_blocks(
params.path,
str(p),
old_text or "",
new_text,
)
)

action = (
FileActions.EDIT
if is_within_directory(p, self._work_dir)
else FileActions.EDIT_OUTSIDE
)

# Request approval
if not await self._approval.request(
self.name,
FileActions.EDIT,
f"Write file `{params.path}`",
action,
f"Write file `{p}`",
display=diff_blocks,
):
return ToolRejectedError()
Expand Down
8 changes: 3 additions & 5 deletions tests/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -293,9 +293,7 @@ def fetch_url_tool(config: Config) -> FetchURL:


@pytest.fixture
def outside_file() -> Path:
def outside_file() -> Generator[Path]:
"""Return a path to a file outside the working directory."""
if platform.system() == "Windows":
return Path("C:/outside_file.txt")
else:
return Path("/outside_file.txt")
with tempfile.TemporaryDirectory() as tmpdir:
yield Path(tmpdir) / "outside_file.txt"
4 changes: 2 additions & 2 deletions tests/test_default_agent.py
Original file line number Diff line number Diff line change
Expand Up @@ -475,7 +475,7 @@ async def test_default_agent(runtime: Runtime):
parameters={
"properties": {
"path": {
"description": "The absolute path to the file to write",
"description": "The path to the file to write. Absolute paths are required when writing files outside the working directory.",
"type": "string",
},
"content": {
Expand Down Expand Up @@ -507,7 +507,7 @@ async def test_default_agent(runtime: Runtime):
parameters={
"properties": {
"path": {
"description": "The absolute path to the file to edit.",
"description": "The path to the file to edit. Absolute paths are required when editing files outside the working directory.",
"type": "string",
},
"edit": {
Expand Down
30 changes: 20 additions & 10 deletions tests/test_str_replace_file.py
Original file line number Diff line number Diff line change
Expand Up @@ -127,32 +127,42 @@ async def test_replace_no_match(str_replace_file_tool: StrReplaceFile, temp_work
assert await file_path.read_text() == original_content # Content unchanged


async def test_replace_with_relative_path(str_replace_file_tool: StrReplaceFile):
"""Test replacing with a relative path (should fail)."""
async def test_replace_with_relative_path(
str_replace_file_tool: StrReplaceFile, temp_work_dir: KaosPath
):
"""Test replacing with a relative path inside the work directory."""
relative_dir = temp_work_dir / "relative" / "path"
await relative_dir.mkdir(parents=True, exist_ok=True)
file_path = relative_dir / "file.txt"
await file_path.write_text("old content")

result = await str_replace_file_tool(
Params(path="relative/path/file.txt", edit=Edit(old="old", new="new"))
)

assert result.is_error
assert "not an absolute path" in result.message
assert not result.is_error
assert await file_path.read_text() == "new content"


async def test_replace_outside_work_directory(
str_replace_file_tool: StrReplaceFile, outside_file: Path
):
"""Test replacing outside the working directory (should fail)."""
"""Test replacing outside the working directory with an absolute path."""
outside_file.write_text("old content", encoding="utf-8")

result = await str_replace_file_tool(
Params(path=str(outside_file), edit=Edit(old="old", new="new"))
)

assert result.is_error
assert "outside the working directory" in result.message
assert not result.is_error
assert outside_file.read_text(encoding="utf-8") == "new content"


async def test_replace_outside_work_directory_with_prefix(
str_replace_file_tool: StrReplaceFile, temp_work_dir: KaosPath
):
"""Paths sharing the work dir prefix but outside should be blocked."""
"""Paths sharing the work dir prefix but outside should still be editable
with absolute paths."""
base = Path(str(temp_work_dir))
sneaky_dir = base.parent / f"{base.name}-sneaky"
sneaky_dir.mkdir(parents=True, exist_ok=True)
Expand All @@ -163,8 +173,8 @@ async def test_replace_outside_work_directory_with_prefix(
Params(path=str(sneaky_file), edit=Edit(old="content", new="new"))
)

assert result.is_error
assert "outside the working directory" in result.message
assert not result.is_error
assert sneaky_file.read_text() == "new"


async def test_replace_nonexistent_file(
Expand Down
Loading
Loading