diff --git a/CHANGELOG.md b/CHANGELOG.md index 9cc077ff1d..e1363a6113 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -19,6 +19,11 @@ Only write entries that are worth mentioning to users. - Core: Fix connection recovery not triggering OAuth refresh when the retry returns 401 — after recreating the HTTP client on `APIConnectionError` or `APITimeoutError`, the retry now re-enters the full recovery path so a subsequent 401 correctly refreshes the OAuth token instead of bubbling to the user as an unrecoverable error - Shell: Echo `/skill:*` and `/flow:*` inputs in the transcript so workflow commands stay visible after enter; operational slash commands like `/usage` and `/model` remain hidden - Core: Raise default `max_steps_per_turn` from 500 to 1000 so long-running agents are less likely to hit the per-turn limit +- Core: Add `default_auto_approve_actions` config option — list of glob patterns for actions to auto-approve by default in every session; merged with any session-specific interactive approvals and supports wildcards such as `mcp:obsidian_*` +- Core: Add `auto_approve_workspace_dirs` config option — list of workspace directory names (relative to `work_dir`) where `WriteFile` and `StrReplaceFile` approvals are skipped automatically; useful for skills, plans, notes, or other directories the agent routinely modifies +- Core: New config files are now created from a commented TOML template with inline documentation and examples instead of a plain serialized dump +- Approval: Auto-approve actions now support glob pattern matching via `fnmatch` — exact match is checked first, then pattern expansion, so action names containing glob-special characters are handled safely + ## 1.39.0 (2026-04-24) diff --git a/docs/en/configuration/config-files.md b/docs/en/configuration/config-files.md index 4e2167028e..d7b5f31d9c 100644 --- a/docs/en/configuration/config-files.md +++ b/docs/en/configuration/config-files.md @@ -32,6 +32,8 @@ The configuration file contains the following top-level configuration items: | `theme` | `string` | Terminal color theme, either `"dark"` or `"light"` (defaults to `"dark"`) | | `show_thinking_stream` | `boolean` | Whether to stream the raw reasoning text in the live area as a 6-line scrolling preview and commit the full reasoning markdown to history when the block ends (defaults to `true`; set to `false` to show only the compact `Thinking ...` indicator and a one-line trace summary) | | `merge_all_available_skills` | `boolean` | Whether to merge skills from all brand directories (defaults to `true`); see [Skills configuration](../customization/skills.md) | +| `default_auto_approve_actions` | `array` | List of action name patterns to auto-approve by default in every session. Supports glob patterns (`*`, `?`). Examples: `["mcp:obsidian_*"]`. Defaults to `[]` | +| `auto_approve_workspace_dirs` | `array` | List of workspace directory names (relative to `work_dir`) for which file write/edit approvals should be skipped automatically. Example: `["skills", "plans"]`. Defaults to `[]` | | `providers` | `table` | API provider configuration | | `models` | `table` | Model configuration | | `loop_control` | `table` | Agent loop control parameters | @@ -51,6 +53,12 @@ theme = "dark" show_thinking_stream = true merge_all_available_skills = true +# Auto-approve all obsidian MCP tools by default +# default_auto_approve_actions = ["mcp:obsidian_*"] + +# Skip approval for file edits inside these workspace directories +# auto_approve_workspace_dirs = ["skills", "plans"] + [providers.kimi-for-coding] type = "kimi" base_url = "https://api.kimi.com/coding/v1" diff --git a/docs/en/release-notes/changelog.md b/docs/en/release-notes/changelog.md index 15598d1e42..ee6b837b19 100644 --- a/docs/en/release-notes/changelog.md +++ b/docs/en/release-notes/changelog.md @@ -12,6 +12,11 @@ This page documents the changes in each Kimi Code CLI release. - Core: Fix connection recovery not triggering OAuth refresh when the retry returns 401 — after recreating the HTTP client on `APIConnectionError` or `APITimeoutError`, the retry now re-enters the full recovery path so a subsequent 401 correctly refreshes the OAuth token instead of bubbling to the user as an unrecoverable error - Shell: Echo `/skill:*` and `/flow:*` inputs in the transcript so workflow commands stay visible after enter; operational slash commands like `/usage` and `/model` remain hidden - Core: Raise default `max_steps_per_turn` from 500 to 1000 so long-running agents are less likely to hit the per-turn limit +- Core: Add `default_auto_approve_actions` config option — list of glob patterns for actions to auto-approve by default in every session; merged with any session-specific interactive approvals and supports wildcards such as `mcp:obsidian_*` +- Core: Add `auto_approve_workspace_dirs` config option — list of workspace directory names (relative to `work_dir`) where `WriteFile` and `StrReplaceFile` approvals are skipped automatically; useful for skills, plans, notes, or other directories the agent routinely modifies +- Core: New config files are now created from a commented TOML template with inline documentation and examples instead of a plain serialized dump +- Approval: Auto-approve actions now support glob pattern matching via `fnmatch` — exact match is checked first, then pattern expansion, so action names containing glob-special characters are handled safely + ## 1.39.0 (2026-04-24) diff --git a/docs/zh/configuration/config-files.md b/docs/zh/configuration/config-files.md index c86818fbbf..bb5d4a4c97 100644 --- a/docs/zh/configuration/config-files.md +++ b/docs/zh/configuration/config-files.md @@ -32,6 +32,8 @@ kimi --config '{"default_model": "kimi-for-coding", "providers": {...}, "models" | `theme` | `string` | 终端配色主题,可选 `"dark"` 或 `"light"`(默认为 `"dark"`) | | `show_thinking_stream` | `boolean` | 是否在 Live 区域以 6 行滚动预览方式实时展示模型的原始思考文本,并在 thinking 块结束时把完整思考内容(Markdown)写入历史记录(默认为 `true`;设为 `false` 则仅显示紧凑的 `Thinking ...` 指示器和一行 trace 总结) | | `merge_all_available_skills` | `boolean` | 是否合并所有品牌目录中的 Skills(默认为 `true`);详见 [Skills 配置](../customization/skills.md) | +| `default_auto_approve_actions` | `array` | 每次会话默认自动审批的操作名称模式列表。支持 glob 通配符(`*`、`?`)。示例:`["mcp:obsidian_*"]`。默认为 `[]` | +| `auto_approve_workspace_dirs` | `array` | 文件写入/编辑操作自动跳过审批的工作区目录名称列表(相对于 `work_dir`)。示例:`["skills", "plans"]`。默认为 `[]` | | `providers` | `table` | API 供应商配置 | | `models` | `table` | 模型配置 | | `loop_control` | `table` | Agent 循环控制参数 | @@ -51,6 +53,12 @@ theme = "dark" show_thinking_stream = true merge_all_available_skills = true +# 默认自动审批所有 Obsidian MCP 工具 +# default_auto_approve_actions = ["mcp:obsidian_*"] + +# 在这些工作区目录内的文件编辑操作跳过审批 +# auto_approve_workspace_dirs = ["skills", "plans"] + [providers.kimi-for-coding] type = "kimi" base_url = "https://api.kimi.com/coding/v1" diff --git a/docs/zh/release-notes/changelog.md b/docs/zh/release-notes/changelog.md index 36c1424cd9..5be6b3e114 100644 --- a/docs/zh/release-notes/changelog.md +++ b/docs/zh/release-notes/changelog.md @@ -12,6 +12,11 @@ - Core:修复连接恢复后重试返回 401 时未能触发 OAuth 刷新的问题——在 `APIConnectionError` 或 `APITimeoutError` 后重建 HTTP 客户端时,重试现在会重新进入完整恢复路径,使得后续的 401 能正确刷新 OAuth token,而不是作为不可恢复的错误直接抛给用户 - Shell:在 transcript 中回显 `/skill:*` 和 `/flow:*` 输入,工作流命令按下回车后不再消失;`/usage`、`/model` 等操作类斜杠命令仍然保持隐藏 - Core:将默认 `max_steps_per_turn` 从 500 提升到 1000,长任务更不容易撞到单轮步数上限 +- Core:新增 `default_auto_approve_actions` 配置项——用于设置每次会话默认自动审批的操作名称模式列表,支持与会话级交互式自动审批合并,可使用 `mcp:obsidian_*` 等 glob 通配符 +- Core:新增 `auto_approve_workspace_dirs` 配置项——用于设置自动跳过文件写入 / 编辑审批的工作区目录名称列表(相对于 `work_dir`),适用于 Skills、plans、notes 等 Agent 经常修改的目录 +- Core:首次生成配置文件时,CLI 现在会写入带注释和示例的 TOML 模板,而不是纯序列化配置 +- Approval:自动审批操作现在支持 glob 模式匹配——优先检查精确匹配,再进行模式扩展,因此包含 glob 特殊字符的操作名也能被安全处理 + ## 1.39.0 (2026-04-24) diff --git a/src/kimi_cli/config.py b/src/kimi_cli/config.py index 61a507e171..26741eaef5 100644 --- a/src/kimi_cli/config.py +++ b/src/kimi_cli/config.py @@ -263,6 +263,20 @@ class Config(BaseModel): default=True, description="Enable anonymous telemetry to help improve kimi-cli. Set to false to disable.", ) + default_auto_approve_actions: list[str] = Field( + default_factory=list, + description=( + "List of action name patterns to auto-approve by default in every session. " + "Supports glob patterns (*, ?). Examples: 'mcp:obsidian_*', 'edit file'." + ), + ) + auto_approve_workspace_dirs: list[str] = Field( + default_factory=list, + description=( + "List of workspace directory names (relative to work_dir) for which " + "file write/edit approvals should be skipped automatically." + ), + ) @model_validator(mode="after") def validate_model(self) -> Self: @@ -279,6 +293,85 @@ def get_config_file() -> Path: return get_share_dir() / "config.toml" +_DEFAULT_CONFIG_TEMPLATE = """\ +# Kimi Code CLI configuration file +# Documentation: https://kimi-cli.github.io/configuration/config-files + +# Default model to use for new sessions. Must match a key in the [models] table. +default_model = "" + +# Default behavior flags +default_thinking = false +default_yolo = false +default_plan_mode = false + +# External editor command (e.g. "vim", "code --wait"). Leave empty for auto-detect. +default_editor = "" + +# Terminal color theme: "dark" or "light" +theme = "dark" + +# Stream reasoning text in the live area? Set to false for a compact indicator only. +show_thinking_stream = true + +# Merge skills from all brand directories (kimi, claude, codex, etc.) +merge_all_available_skills = true + +# ------------------------------------------------------------------------------ +# Auto-approval configuration +# ------------------------------------------------------------------------------ +# Glob patterns for actions that should be auto-approved in EVERY session. +# These are merged with any session-specific approvals you make interactively. +# Examples: +# default_auto_approve_actions = ["mcp:obsidian_*"] +# default_auto_approve_actions = ["mcp:obsidian_*", "mcp:memory_*"] +default_auto_approve_actions = [] + +# Workspace directory names (relative to the current work_dir) where file +# write/edit approvals are skipped automatically. Useful for skills, plans, +# notes, or other directories the agent routinely modifies. +# Examples: +# auto_approve_workspace_dirs = ["skills", "plans"] +# auto_approve_workspace_dirs = ["docs", "notes"] +auto_approve_workspace_dirs = [] + +# Extra directories to discover skills from (absolute, ~-prefixed, or relative) +extra_skill_dirs = [] + +# Enable anonymous telemetry to help improve kimi-cli. Set to false to disable. +telemetry = true + +# Suppress the YOLO mode hint injected into the system prompt. +skip_yolo_prompt_injection = false + +[loop_control] +max_steps_per_turn = 500 +max_retries_per_step = 3 +max_ralph_iterations = 0 +reserved_context_size = 50000 +compaction_trigger_ratio = 0.85 + +[background] +max_running_tasks = 4 +read_max_bytes = 30000 +notification_tail_lines = 20 +notification_tail_chars = 3000 +wait_poll_interval_ms = 500 +worker_heartbeat_interval_ms = 5000 +worker_stale_after_ms = 15000 +kill_grace_period_ms = 2000 +keep_alive_on_exit = false +agent_task_timeout_s = 900 +print_wait_ceiling_s = 3600 + +[notifications] +claim_stale_after_ms = 15000 + +[mcp.client] +tool_call_timeout_ms = 60000 +""" + + def get_default_config() -> Config: """Get the default configuration.""" return Config( @@ -289,6 +382,12 @@ def get_default_config() -> Config: ) +def _write_default_config_file(config_file: Path) -> None: + """Write the default config file with comments and examples.""" + config_file.parent.mkdir(parents=True, exist_ok=True) + config_file.write_text(_DEFAULT_CONFIG_TEMPLATE, encoding="utf-8") + + def load_config(config_file: Path | None = None) -> Config: """ Load configuration from config file. @@ -315,9 +414,30 @@ def load_config(config_file: Path | None = None) -> Config: _migrate_json_config_to_toml() if not config_file.exists(): - config = get_default_config() - logger.debug("No config file found, creating default config: {config}", config=config) - save_config(config, config_file) + logger.debug("No config file found, creating default config at: {file}", file=config_file) + if config_file.suffix.lower() == ".json": + # Write a valid JSON default so subsequent loads via json.loads succeed. + default_config = get_default_config() + config_file.parent.mkdir(parents=True, exist_ok=True) + config_file.write_text( + json.dumps( + default_config.model_dump(mode="json", exclude_none=True), + ensure_ascii=False, + indent=2, + ), + encoding="utf-8", + ) + config = default_config + else: + _write_default_config_file(config_file) + try: + data = tomlkit.loads(_DEFAULT_CONFIG_TEMPLATE) + config = Config.model_validate(data) + except (TOMLKitError, ValidationError) as e: + # This should never happen because the template is static and tested, + # but fall back to the plain default config if it does. + logger.warning("Default config template failed validation: {error}", error=e) + config = get_default_config() config.is_from_default_location = is_default_config_file config.source_file = config_file return config diff --git a/src/kimi_cli/soul/agent.py b/src/kimi_cli/soul/agent.py index f5081f97b5..5e3591d4e1 100644 --- a/src/kimi_cli/soul/agent.py +++ b/src/kimi_cli/soul/agent.py @@ -271,16 +271,21 @@ async def create( # Merge CLI flag with persisted session state effective_yolo = yolo or session.state.approval.yolo + default_actions = set(config.default_auto_approve_actions or []) saved_actions = set(session.state.approval.auto_approve_actions) def _on_approval_change() -> None: session.state.approval.yolo = approval_state.yolo - session.state.approval.auto_approve_actions = set(approval_state.auto_approve_actions) + # Subtract config defaults before persisting so that removing a + # pattern from config actually disables it in existing sessions. + session.state.approval.auto_approve_actions = ( + set(approval_state.auto_approve_actions) - default_actions + ) session.save_state() approval_state = ApprovalState( yolo=effective_yolo, - auto_approve_actions=saved_actions, + auto_approve_actions=default_actions | saved_actions, on_change=_on_approval_change, ) notifications = NotificationManager( diff --git a/src/kimi_cli/soul/approval.py b/src/kimi_cli/soul/approval.py index 78f5246304..c2438df69e 100644 --- a/src/kimi_cli/soul/approval.py +++ b/src/kimi_cli/soul/approval.py @@ -1,5 +1,7 @@ from __future__ import annotations +import fnmatch +import re import uuid from collections.abc import Callable from typing import Literal @@ -52,6 +54,9 @@ def rejection_error(self) -> ToolRejectedError: return ToolRejectedError() +_GLOB_SPECIAL_RE = re.compile(r"[*?\[\]]") + + class ApprovalState: def __init__( self, @@ -60,10 +65,32 @@ def __init__( on_change: Callable[[], None] | None = None, ): self.yolo = yolo - self.auto_approve_actions: set[str] = auto_approve_actions or set() - """Set of action names that should automatically be approved.""" + # Separate exact action names from glob patterns so that fnmatch is + # only applied to entries that actually contain glob-special characters. + self._auto_approve_exact: set[str] = set() + self._auto_approve_patterns: list[str] = [] + for entry in (auto_approve_actions or set()): + if _GLOB_SPECIAL_RE.search(entry): + self._auto_approve_patterns.append(entry) + else: + self._auto_approve_exact.add(entry) self._on_change = on_change + def is_auto_approved(self, action: str) -> bool: + """Return True if *action* matches an exact name or a glob pattern.""" + if action in self._auto_approve_exact: + return True + return any(fnmatch.fnmatch(action, p) for p in self._auto_approve_patterns) + + def add_auto_approve_action(self, action: str) -> None: + """Add *action* as an exact auto-approve name (not a glob pattern).""" + self._auto_approve_exact.add(action) + + @property + def auto_approve_actions(self) -> set[str]: + """All auto-approve entries (exact names + glob patterns) for persistence.""" + return self._auto_approve_exact | set(self._auto_approve_patterns) + def notify_change(self) -> None: if self._on_change is not None: self._on_change() @@ -142,7 +169,7 @@ async def request( ) return ApprovalResult(approved=True) - if action in self._state.auto_approve_actions: + if self._state.is_auto_approved(action): from kimi_cli.telemetry import track track( @@ -195,7 +222,7 @@ async def request( tool_name=tool_call.function.name, approval_mode="manual", ) - self._state.auto_approve_actions.add(action) + self._state.add_auto_approve_action(action) self._state.notify_change() for pending in self._runtime.list_pending(): if pending.action == action: diff --git a/src/kimi_cli/tools/file/replace.py b/src/kimi_cli/tools/file/replace.py index 07af22fdb9..a33b8db533 100644 --- a/src/kimi_cli/tools/file/replace.py +++ b/src/kimi_cli/tools/file/replace.py @@ -14,7 +14,7 @@ from kimi_cli.tools.utils import load_desc from kimi_cli.utils.diff import build_diff_blocks from kimi_cli.utils.logging import logger -from kimi_cli.utils.path import is_within_workspace +from kimi_cli.utils.path import is_within_directory, is_within_workspace _BASE_DESCRIPTION = load_desc(Path(__file__).parent / "replace.md") @@ -50,6 +50,7 @@ def __init__(self, runtime: Runtime, approval: Approval): self._work_dir = runtime.builtin_args.KIMI_WORK_DIR self._additional_dirs = runtime.additional_dirs self._approval = approval + self._auto_approve_dirs = runtime.config.auto_approve_workspace_dirs or [] self._plan_mode_checker: Callable[[], bool] | None = None self._plan_file_path_getter: Callable[[], Path | None] | None = None @@ -60,6 +61,28 @@ def bind_plan_mode( self._plan_mode_checker = checker self._plan_file_path_getter = path_getter + def _is_auto_approved_dir(self, path: KaosPath) -> bool: + """Check if path is within a configured auto-approve workspace directory.""" + if not self._auto_approve_dirs: + return False + for dir_name in self._auto_approve_dirs: + # Reject absolute paths to prevent bypassing workspace boundaries; + # auto-approve dirs must be relative to work_dir. + if KaosPath(dir_name).is_absolute(): + logger.warning("Ignoring absolute auto_approve_workspace_dir: {dir}", dir=dir_name) + continue + resolved = (self._work_dir / dir_name).canonical() + # Reject entries that escape work_dir via ".." traversal. + if not is_within_directory(resolved, self._work_dir): + logger.warning( + "Ignoring auto_approve_workspace_dir that escapes workspace: {dir}", + dir=dir_name, + ) + continue + if is_within_directory(path, resolved): + return True + return False + async def _validate_path(self, path: KaosPath) -> ToolError | None: """Validate that the path is safe to edit.""" resolved_path = path.canonical() @@ -155,8 +178,8 @@ async def __call__(self, params: Params) -> ToolReturnValue: else FileActions.EDIT_OUTSIDE ) - # Plan file edits are auto-approved; all other edits need approval. - if not is_plan_file_edit: + # Plan file edits and auto-approved dirs are skipped; all other edits need approval. + if not is_plan_file_edit and not self._is_auto_approved_dir(p): result = await self._approval.request( self.name, action, diff --git a/src/kimi_cli/tools/file/write.py b/src/kimi_cli/tools/file/write.py index 1b156d5d5e..9bc4aa65d3 100644 --- a/src/kimi_cli/tools/file/write.py +++ b/src/kimi_cli/tools/file/write.py @@ -14,7 +14,7 @@ from kimi_cli.tools.utils import load_desc from kimi_cli.utils.diff import build_diff_blocks from kimi_cli.utils.logging import logger -from kimi_cli.utils.path import is_within_workspace +from kimi_cli.utils.path import is_within_directory, is_within_workspace _BASE_DESCRIPTION = load_desc(Path(__file__).parent / "write.md") @@ -47,6 +47,7 @@ def __init__(self, runtime: Runtime, approval: Approval): self._work_dir = runtime.builtin_args.KIMI_WORK_DIR self._additional_dirs = runtime.additional_dirs self._approval = approval + self._auto_approve_dirs = runtime.config.auto_approve_workspace_dirs or [] self._plan_mode_checker: Callable[[], bool] | None = None self._plan_file_path_getter: Callable[[], Path | None] | None = None @@ -57,6 +58,28 @@ def bind_plan_mode( self._plan_mode_checker = checker self._plan_file_path_getter = path_getter + def _is_auto_approved_dir(self, path: KaosPath) -> bool: + """Check if path is within a configured auto-approve workspace directory.""" + if not self._auto_approve_dirs: + return False + for dir_name in self._auto_approve_dirs: + # Reject absolute paths to prevent bypassing workspace boundaries; + # auto-approve dirs must be relative to work_dir. + if KaosPath(dir_name).is_absolute(): + logger.warning("Ignoring absolute auto_approve_workspace_dir: {dir}", dir=dir_name) + continue + resolved = (self._work_dir / dir_name).canonical() + # Reject entries that escape work_dir via ".." traversal. + if not is_within_directory(resolved, self._work_dir): + logger.warning( + "Ignoring auto_approve_workspace_dir that escapes workspace: {dir}", + dir=dir_name, + ) + continue + if is_within_directory(path, resolved): + return True + return False + async def _validate_path(self, path: KaosPath) -> ToolError | None: """Validate that the path is safe to write.""" resolved_path = path.canonical() @@ -134,8 +157,8 @@ async def __call__(self, params: Params) -> ToolReturnValue: new_text, ) - # Plan file writes are auto-approved; other writes need approval - if not is_plan_file_write: + # Plan file writes and auto-approved dirs are skipped; other writes need approval + if not is_plan_file_write and not self._is_auto_approved_dir(p): action = ( FileActions.EDIT if is_within_workspace(p, self._work_dir, self._additional_dirs) diff --git a/tests/core/test_config.py b/tests/core/test_config.py index f7c0832e97..db30f474b5 100644 --- a/tests/core/test_config.py +++ b/tests/core/test_config.py @@ -60,6 +60,8 @@ def test_default_config_dump(): "extra_skill_dirs": [], "telemetry": True, "skip_yolo_prompt_injection": False, + "default_auto_approve_actions": [], + "auto_approve_workspace_dirs": [], } ) @@ -83,6 +85,23 @@ def test_load_config_sets_source_file(tmp_path): assert not config.is_from_default_location +def test_load_config_creates_template_with_comments(tmp_path): + """New config files should be created from the commented template.""" + config_file = tmp_path / "config.toml" + + config = load_config(config_file) + + text = config_file.read_text(encoding="utf-8") + assert "# Auto-approval configuration" in text + assert "default_auto_approve_actions" in text + assert "auto_approve_workspace_dirs" in text + # Compare excluding metadata fields that differ by load path + assert ( + config.model_dump(exclude={"is_from_default_location", "source_file"}) + == get_default_config().model_dump() + ) + + def test_load_config_text_has_no_source_file(): config = load_config_from_string('{"default_model": ""}') diff --git a/tests/core/test_session_state.py b/tests/core/test_session_state.py index e165e6c0d6..34e2da4c9d 100644 --- a/tests/core/test_session_state.py +++ b/tests/core/test_session_state.py @@ -452,3 +452,30 @@ def test_no_callback_does_not_raise(self): approval = Approval(state=state) approval.set_yolo(True) # should not raise assert state.yolo is True + + @pytest.mark.asyncio + async def test_glob_pattern_auto_approve(self): + from kimi_cli.soul.approval import Approval, ApprovalState + from kimi_cli.soul.toolset import current_tool_call + from kimi_cli.wire.types import ToolCall + + state = ApprovalState(auto_approve_actions={"mcp:obsidian_*", "Shell"}) + approval = Approval(state=state) + + token = current_tool_call.set( + ToolCall( + id="test", function=ToolCall.FunctionBody(name="obsidian_search", arguments=None) + ) + ) + try: + result = await approval.request( + sender="obsidian_search", action="mcp:obsidian_search", description="search" + ) + assert result.approved is True + + result2 = await approval.request( + sender="obsidian_write", action="mcp:obsidian_write", description="write" + ) + assert result2.approved is True + finally: + current_tool_call.reset(token) diff --git a/tests/tools/test_str_replace_file.py b/tests/tools/test_str_replace_file.py index a16dad303b..124b174fc4 100644 --- a/tests/tools/test_str_replace_file.py +++ b/tests/tools/test_str_replace_file.py @@ -246,3 +246,30 @@ async def test_replace_empty_strings( assert not result.is_error assert "successfully edited" in result.message assert await file_path.read_text() == "Hello !" + + +async def test_replace_auto_approved_workspace_dir(runtime, temp_work_dir: KaosPath): + """Test that edits in auto-approved workspace dirs skip approval.""" + from kimi_cli.soul.approval import Approval + from kimi_cli.soul.toolset import current_tool_call + from kimi_cli.wire.types import ToolCall + + runtime.config.auto_approve_workspace_dirs = ["plans"] + approval = Approval(yolo=False) + tool = StrReplaceFile(runtime, approval) + + plans_dir = temp_work_dir / "plans" + await plans_dir.mkdir(parents=True, exist_ok=True) + file_path = plans_dir / "todo.txt" + await file_path.write_text("old content") + + token = current_tool_call.set( + ToolCall(id="test", function=ToolCall.FunctionBody(name="StrReplaceFile", arguments=None)) + ) + try: + result = await tool(Params(path=str(file_path), edit=Edit(old="old", new="new"))) + finally: + current_tool_call.reset(token) + + assert not result.is_error + assert await file_path.read_text() == "new content" diff --git a/tests/tools/test_write_file.py b/tests/tools/test_write_file.py index a117dd13fc..6d93caf8a0 100644 --- a/tests/tools/test_write_file.py +++ b/tests/tools/test_write_file.py @@ -171,3 +171,28 @@ async def test_write_large_content(write_file_tool: WriteFile, temp_work_dir: Ka assert not result.is_error assert await file_path.exists() assert await file_path.read_text() == content + + +async def test_write_auto_approved_workspace_dir(runtime, temp_work_dir: KaosPath): + """Test that writes to auto-approved workspace dirs skip approval.""" + from kimi_cli.soul.approval import Approval + from kimi_cli.soul.toolset import current_tool_call + from kimi_cli.wire.types import ToolCall + + runtime.config.auto_approve_workspace_dirs = ["skills"] + approval = Approval(yolo=False) + tool = WriteFile(runtime, approval) + + skills_dir = temp_work_dir / "skills" + await skills_dir.mkdir(parents=True, exist_ok=True) + + token = current_tool_call.set( + ToolCall(id="test", function=ToolCall.FunctionBody(name="WriteFile", arguments=None)) + ) + try: + result = await tool(Params(path=str(skills_dir / "test.txt"), content="hello")) + finally: + current_tool_call.reset(token) + + assert not result.is_error + assert await (skills_dir / "test.txt").read_text() == "hello"