diff --git a/CHANGELOG.md b/CHANGELOG.md index 80ed7bf81..42890286b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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) diff --git a/docs/en/customization/agents.md b/docs/en/customization/agents.md index 6af504b5d..268aea771 100644 --- a/docs/en/customization/agents.md +++ b/docs/en/customization/agents.md @@ -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 | |-----------|------|-------------| @@ -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 | |-----------|------|-------------| @@ -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** diff --git a/docs/en/release-notes/changelog.md b/docs/en/release-notes/changelog.md index be6e82ca0..ebaaba2ac 100644 --- a/docs/en/release-notes/changelog.md +++ b/docs/en/release-notes/changelog.md @@ -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) diff --git a/docs/zh/customization/agents.md b/docs/zh/customization/agents.md index 215223043..9ce1a1af5 100644 --- a/docs/zh/customization/agents.md +++ b/docs/zh/customization/agents.md @@ -233,7 +233,7 @@ agent: ### `WriteFile` - **路径**:`kimi_cli.tools.file:WriteFile` -- **描述**:写入文件。只能写入工作目录内的文件,必须使用绝对路径,需要用户审批。 +- **描述**:写入文件。写入操作需要用户审批。写入工作目录外文件时,必须使用绝对路径。 | 参数 | 类型 | 说明 | |------|------|------| @@ -244,7 +244,7 @@ agent: ### `StrReplaceFile` - **路径**:`kimi_cli.tools.file:StrReplaceFile` -- **描述**:使用字符串替换编辑文件。只能编辑工作目录内的文件,必须使用绝对路径,需要用户审批。 +- **描述**:使用字符串替换编辑文件。编辑操作需要用户审批。编辑工作目录外文件时,必须使用绝对路径。 | 参数 | 类型 | 说明 | |------|------|------| @@ -307,8 +307,9 @@ agent: **工作目录限制** -- 文件写入和编辑只能在工作目录内进行 -- 工作目录外的文件可以读取,但需使用绝对路径 +- 文件读写通常在工作目录内进行 +- 读取工作目录外文件需使用绝对路径 +- 写入和编辑操作都需要用户审批;操作工作目录外文件时,必须使用绝对路径 **审批机制** diff --git a/docs/zh/release-notes/changelog.md b/docs/zh/release-notes/changelog.md index 4c54e936d..0a370c7de 100644 --- a/docs/zh/release-notes/changelog.md +++ b/docs/zh/release-notes/changelog.md @@ -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) diff --git a/src/kimi_cli/tools/file/__init__.py b/src/kimi_cli/tools/file/__init__.py index 5f7be2067..dde05ef8c 100644 --- a/src/kimi_cli/tools/file/__init__.py +++ b/src/kimi_cli/tools/file/__init__.py @@ -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 diff --git a/src/kimi_cli/tools/file/read.py b/src/kimi_cli/tools/file/read.py index 987ae7332..9128b4952 100644 --- a/src/kimi_cli/tools/file/read.py +++ b/src/kimi_cli/tools/file/read.py @@ -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(): @@ -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( diff --git a/src/kimi_cli/tools/file/replace.py b/src/kimi_cli/tools/file/replace.py index ea5a0b99b..c5a2a915b 100644 --- a/src/kimi_cli/tools/file/replace.py +++ b/src/kimi_cli/tools/file/replace.py @@ -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. " @@ -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 @@ -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() if not await p.exists(): return ToolError( @@ -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() diff --git a/src/kimi_cli/tools/file/write.py b/src/kimi_cli/tools/file/write.py index 99001ae4f..aaa0bb2fa 100644 --- a/src/kimi_cli/tools/file/write.py +++ b/src/kimi_cli/tools/file/write.py @@ -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=( @@ -39,17 +44,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 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 @@ -57,23 +61,18 @@ async def _validate_path(self, path: KaosPath) -> ToolError | None: 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() if not await p.parent.exists(): return ToolError( @@ -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() diff --git a/tests/conftest.py b/tests/conftest.py index 1692d491d..d8aa8c476 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -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" diff --git a/tests/test_default_agent.py b/tests/test_default_agent.py index 42b0573f8..3995d3fe0 100644 --- a/tests/test_default_agent.py +++ b/tests/test_default_agent.py @@ -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": { @@ -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": { diff --git a/tests/test_str_replace_file.py b/tests/test_str_replace_file.py index 1149da74e..a16dad303 100644 --- a/tests/test_str_replace_file.py +++ b/tests/test_str_replace_file.py @@ -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) @@ -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( diff --git a/tests/test_tool_schemas.py b/tests/test_tool_schemas.py index 7b127e86a..2d1caadfc 100644 --- a/tests/test_tool_schemas.py +++ b/tests/test_tool_schemas.py @@ -283,7 +283,7 @@ def test_write_file_params_schema(write_file_tool: WriteFile): { "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": { @@ -309,7 +309,7 @@ def test_str_replace_file_params_schema(str_replace_file_tool: StrReplaceFile): { "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": { diff --git a/tests/test_write_file.py b/tests/test_write_file.py index 95069af3f..a117dd13f 100644 --- a/tests/test_write_file.py +++ b/tests/test_write_file.py @@ -96,26 +96,29 @@ async def test_write_multiline_content(write_file_tool: WriteFile, temp_work_dir assert await file_path.read_text() == content -async def test_write_with_relative_path(write_file_tool: WriteFile): - """Test writing with a relative path (should fail).""" +async def test_write_with_relative_path(write_file_tool: WriteFile, temp_work_dir: KaosPath): + """Test writing with a relative path inside the work directory.""" + relative_dir = temp_work_dir / "relative" / "path" + await relative_dir.mkdir(parents=True, exist_ok=True) + result = await write_file_tool(Params(path="relative/path/file.txt", content="content")) - assert result.is_error - assert "not an absolute path" in result.message + assert not result.is_error + assert await (temp_work_dir / "relative" / "path" / "file.txt").read_text() == "content" async def test_write_outside_work_directory(write_file_tool: WriteFile, outside_file: Path): - """Test writing outside the working directory (should fail).""" + """Test writing outside the working directory with an absolute path.""" result = await write_file_tool(Params(path=str(outside_file), content="content")) - assert result.is_error - assert "outside the working directory" in result.message + assert not result.is_error + assert outside_file.read_text() == "content" async def test_write_outside_work_directory_with_prefix( write_file_tool: WriteFile, temp_work_dir: KaosPath ): - """Paths sharing the same prefix as work dir should still be rejected.""" + """Paths sharing the same prefix as work dir should still be writable 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) @@ -123,8 +126,8 @@ async def test_write_outside_work_directory_with_prefix( result = await write_file_tool(Params(path=str(sneaky_file), content="content")) - assert result.is_error - assert "outside the working directory" in result.message + assert not result.is_error + assert sneaky_file.read_text() == "content" async def test_write_to_nonexistent_directory(write_file_tool: WriteFile, temp_work_dir: KaosPath):