diff --git a/CHANGELOG.md b/CHANGELOG.md index 43ec2780f..ed64cfacd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,7 @@ Only write entries that are worth mentioning to users. ## Unreleased +- Core: Add `--add-dir` CLI option and `/add-dir` slash command to expand the workspace scope with additional directories — added directories are accessible to all file tools (read, write, glob, replace), persisted across sessions, and shown in the system prompt - Shell: Add `Ctrl-O` keyboard shortcut to open the current input in an external editor (`$VISUAL`/`$EDITOR`), with auto-detection fallback to VS Code, Vim, Vi, or Nano - Shell: Add `/editor` slash command to configure and switch the default external editor, with interactive selection and persistent config storage - Shell: Add `/new` slash command to create and switch to a new session without restarting Kimi Code CLI diff --git a/docs/en/configuration/data-locations.md b/docs/en/configuration/data-locations.md index fe383af46..cf7772124 100644 --- a/docs/en/configuration/data-locations.md +++ b/docs/en/configuration/data-locations.md @@ -92,6 +92,7 @@ Session state file, stores the session's runtime state, including: - `approval`: Approval decision state (YOLO mode on/off, auto-approved operation types) - `dynamic_subagents`: Dynamically created subagent definitions +- `additional_dirs`: Additional workspace directories added via `--add-dir` or `/add-dir` When resuming a session, Kimi Code CLI reads this file to restore the session state. This file uses atomic writes to prevent data corruption on crash. diff --git a/docs/en/customization/agents.md b/docs/en/customization/agents.md index 91f01b20f..7def8bb6c 100644 --- a/docs/en/customization/agents.md +++ b/docs/en/customization/agents.md @@ -80,6 +80,7 @@ The system prompt file is a Markdown template that can use `${VAR}` syntax to re | `${KIMI_WORK_DIR_LS}` | Working directory file list | | `${KIMI_AGENTS_MD}` | AGENTS.md file content (if exists) | | `${KIMI_SKILLS}` | Loaded skills list | +| `${KIMI_ADDITIONAL_DIRS_INFO}` | Information about additional directories added via `--add-dir` or `/add-dir` | You can also define custom parameters via `system_prompt_args`: @@ -329,11 +330,11 @@ The following are all built-in tools in Kimi Code CLI. ## Tool security boundaries -**Working directory restrictions** +**Workspace scope** -- 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 +- File reading and writing are typically done within the working directory (and additional directories added via `--add-dir` or `/add-dir`) +- Absolute paths are required when reading files outside the workspace +- Write and edit operations require user approval; absolute paths are required when operating on files outside the workspace **Approval mechanism** diff --git a/docs/en/guides/sessions.md b/docs/en/guides/sessions.md index 6fd8c46bb..ccd990e20 100644 --- a/docs/en/guides/sessions.md +++ b/docs/en/guides/sessions.md @@ -44,6 +44,7 @@ In addition to conversation history, Kimi Code CLI also automatically saves and - **Approval decisions**: YOLO mode on/off status, operation types approved via "allow for this session" - **Dynamic subagents**: Subagent definitions created via the `CreateSubagent` tool during the session +- **Additional directories**: Workspace directories added via `--add-dir` or `/add-dir` This means you don't need to reconfigure these settings each time you resume a session. For example, if you approved auto-execution of certain shell commands in your previous session, those approvals remain in effect after resuming. @@ -78,5 +79,5 @@ The bottom status bar displays the current context usage (`context: xx%`), helpi ::: ::: tip -`/clear` and `/reset` clear the conversation context but do not reset session state (such as approval decisions and dynamic subagents). To start completely fresh, it's recommended to create a new session. +`/clear` and `/reset` clear the conversation context but do not reset session state (such as approval decisions, dynamic subagents, and additional directories). To start completely fresh, it's recommended to create a new session. ::: diff --git a/docs/en/reference/kimi-command.md b/docs/en/reference/kimi-command.md index e5e1d75ff..0fa79868e 100644 --- a/docs/en/reference/kimi-command.md +++ b/docs/en/reference/kimi-command.md @@ -44,9 +44,12 @@ kimi [OPTIONS] COMMAND [ARGS] | Option | Short | Description | |--------|-------|-------------| | `--work-dir PATH` | `-w` | Specify working directory (default current directory) | +| `--add-dir PATH` | | Add an additional directory to the workspace scope, can be specified multiple times | The working directory determines the root directory for file operations. Relative paths work within the working directory; absolute paths are required to access files outside it. +`--add-dir` expands the workspace scope to include directories outside the working directory, making all file tools able to access files in those directories. Added directories are persisted with the session state. You can also add directories at runtime via the [`/add-dir`](./slash-commands.md#add-dir) slash command. + ## Session management | Option | Short | Description | diff --git a/docs/en/reference/slash-commands.md b/docs/en/reference/slash-commands.md index d79d5807e..c843e6e5e 100644 --- a/docs/en/reference/slash-commands.md +++ b/docs/en/reference/slash-commands.md @@ -155,6 +155,21 @@ Flow skills can also be invoked via `/skill:`, which loads the content as See [Agent Skills](../customization/skills.md#flow-skills) for details. +## Workspace + +### `/add-dir` + +Add an additional directory to the workspace scope. Once added, the directory is accessible to all file tools (`ReadFile`, `WriteFile`, `Glob`, `Grep`, `StrReplaceFile`, etc.) and its directory listing is shown in the system prompt. Added directories are persisted with the session state and automatically restored when resuming. + +Usage: + +- `/add-dir `: Add the specified directory to the workspace +- `/add-dir`: Without arguments, list already added additional directories + +::: tip +Directories already within the working directory do not need to be added, as they are already accessible. You can also add directories at startup via the `--add-dir` option. See [`kimi` command](./kimi-command.md#working-directory) for details. +::: + ## Others ### `/init` diff --git a/docs/en/release-notes/changelog.md b/docs/en/release-notes/changelog.md index f9189670c..fd15829c7 100644 --- a/docs/en/release-notes/changelog.md +++ b/docs/en/release-notes/changelog.md @@ -4,6 +4,7 @@ This page documents the changes in each Kimi Code CLI release. ## Unreleased +- Core: Add `--add-dir` CLI option and `/add-dir` slash command to expand the workspace scope with additional directories — added directories are accessible to all file tools (read, write, glob, replace), persisted across sessions, and shown in the system prompt - Shell: Add `Ctrl-O` keyboard shortcut to open the current input in an external editor (`$VISUAL`/`$EDITOR`), with auto-detection fallback to VS Code, Vim, Vi, or Nano - Shell: Add `/editor` slash command to configure and switch the default external editor, with interactive selection and persistent config storage - Shell: Add `/new` slash command to create and switch to a new session without restarting Kimi Code CLI diff --git a/docs/zh/configuration/data-locations.md b/docs/zh/configuration/data-locations.md index fb28da754..face2d402 100644 --- a/docs/zh/configuration/data-locations.md +++ b/docs/zh/configuration/data-locations.md @@ -92,6 +92,7 @@ Wire 消息记录文件,以 JSONL 格式存储会话中的 Wire 事件。用 - `approval`:审批决策状态(YOLO 模式开关、已自动批准的操作类型) - `dynamic_subagents`:动态创建的子 Agent 定义 +- `additional_dirs`:通过 `--add-dir` 或 `/add-dir` 添加的额外工作区目录 恢复会话时,Kimi Code CLI 会读取此文件还原会话状态。此文件使用原子写入,防止崩溃时数据损坏。 diff --git a/docs/zh/customization/agents.md b/docs/zh/customization/agents.md index e4fcb7c76..397b2cd43 100644 --- a/docs/zh/customization/agents.md +++ b/docs/zh/customization/agents.md @@ -80,6 +80,7 @@ agent: | `${KIMI_WORK_DIR_LS}` | 工作目录文件列表 | | `${KIMI_AGENTS_MD}` | AGENTS.md 文件内容(如果存在) | | `${KIMI_SKILLS}` | 加载的 Skills 列表 | +| `${KIMI_ADDITIONAL_DIRS_INFO}` | 通过 `--add-dir` 或 `/add-dir` 添加的额外目录信息 | 你也可以通过 `system_prompt_args` 定义自定义参数: @@ -329,11 +330,11 @@ agent: ## 工具安全边界 -**工作目录限制** +**工作区范围** -- 文件读写通常在工作目录内进行 -- 读取工作目录外文件需使用绝对路径 -- 写入和编辑操作都需要用户审批;操作工作目录外文件时,必须使用绝对路径 +- 文件读写通常在工作目录(及通过 `--add-dir` 或 `/add-dir` 添加的额外目录)内进行 +- 读取工作区外文件需使用绝对路径 +- 写入和编辑操作都需要用户审批;操作工作区外文件时,必须使用绝对路径 **审批机制** diff --git a/docs/zh/guides/sessions.md b/docs/zh/guides/sessions.md index 2a72514de..d65cd8d85 100644 --- a/docs/zh/guides/sessions.md +++ b/docs/zh/guides/sessions.md @@ -44,6 +44,7 @@ kimi --session abc123 - **审批决策**:YOLO 模式的开关状态、通过 "本会话允许" 批准过的操作类型 - **动态子 Agent**:通过 `CreateSubagent` 工具在会话中创建的子 Agent 定义 +- **额外目录**:通过 `--add-dir` 或 `/add-dir` 添加的工作区目录 这意味着你不需要在每次恢复会话时重新配置这些设置。例如,如果你在上次会话中批准了某类 Shell 命令的自动执行,恢复会话后这些批准仍然有效。 @@ -78,5 +79,5 @@ kimi --session abc123 ::: ::: tip 提示 -`/clear` 和 `/reset` 会清空对话上下文,但不会重置会话状态(如审批决策和动态子 Agent)。如需完全重新开始,建议创建一个新会话。 +`/clear` 和 `/reset` 会清空对话上下文,但不会重置会话状态(如审批决策、动态子 Agent 和额外目录)。如需完全重新开始,建议创建一个新会话。 ::: diff --git a/docs/zh/reference/kimi-command.md b/docs/zh/reference/kimi-command.md index 716053436..39b26b0a4 100644 --- a/docs/zh/reference/kimi-command.md +++ b/docs/zh/reference/kimi-command.md @@ -44,9 +44,12 @@ kimi [OPTIONS] COMMAND [ARGS] | 选项 | 简写 | 说明 | |------|------|------| | `--work-dir PATH` | `-w` | 指定工作目录(默认当前目录) | +| `--add-dir PATH` | | 添加额外目录到工作区范围,可多次指定 | 工作目录决定了文件操作的根目录。在工作目录内可使用相对路径,操作工作目录外的文件需使用绝对路径。 +`--add-dir` 可以将工作目录之外的目录纳入工作区范围,使所有文件工具可以访问该目录中的文件。添加的目录会随会话状态持久化。运行中也可以通过 [`/add-dir`](./slash-commands.md#add-dir) 斜杠命令添加。 + ## 会话管理 | 选项 | 简写 | 说明 | diff --git a/docs/zh/reference/slash-commands.md b/docs/zh/reference/slash-commands.md index 92b4b80b5..c3bd85881 100644 --- a/docs/zh/reference/slash-commands.md +++ b/docs/zh/reference/slash-commands.md @@ -155,6 +155,21 @@ Flow Skill 也可以通过 `/skill:` 调用,此时作为普通 Skill 加 详见 [Agent Skills](../customization/skills.md#flow-skills)。 +## 工作区 + +### `/add-dir` + +将额外目录添加到工作区范围。添加后,该目录对所有文件工具(`ReadFile`、`WriteFile`、`Glob`、`Grep`、`StrReplaceFile` 等)可用,并会在系统提示词中展示目录结构。添加的目录会随会话状态持久化,恢复会话时自动还原。 + +用法: + +- `/add-dir `:添加指定目录到工作区 +- `/add-dir`:不带参数时列出已添加的额外目录 + +::: tip 提示 +已在工作目录内的目录无需添加,因为它们已经可访问。也可以在启动时通过 `--add-dir` 参数添加,详见 [`kimi` 命令](./kimi-command.md#工作目录)。 +::: + ## 其他 ### `/init` diff --git a/docs/zh/release-notes/changelog.md b/docs/zh/release-notes/changelog.md index 36baff5be..98308c38e 100644 --- a/docs/zh/release-notes/changelog.md +++ b/docs/zh/release-notes/changelog.md @@ -4,6 +4,7 @@ ## 未发布 +- Core:新增 `--add-dir` CLI 选项和 `/add-dir` 斜杠命令,支持将额外目录添加到工作区范围——添加的目录可被所有文件工具(读取、写入、glob、替换)访问,跨会话持久化保存,并在系统提示词中展示 - Shell:新增 `Ctrl-O` 快捷键,在外部编辑器中编辑当前输入内容(`$VISUAL`/`$EDITOR`),支持自动检测 VS Code、Vim、Vi 或 Nano - Shell:新增 `/editor` 斜杠命令,可交互式配置和切换默认外部编辑器,设置持久保存到配置文件 - Shell:新增 `/new` 斜杠命令,无需重启 Kimi Code CLI 即可创建并切换到新会话 diff --git a/src/kimi_cli/agents/default/system.md b/src/kimi_cli/agents/default/system.md index 4e39417e2..5034a5b87 100644 --- a/src/kimi_cli/agents/default/system.md +++ b/src/kimi_cli/agents/default/system.md @@ -70,6 +70,14 @@ ${KIMI_WORK_DIR_LS} ``` Use this as your basic understanding of the project structure. +{% if KIMI_ADDITIONAL_DIRS_INFO %} + +## Additional Directories + +The following directories have been added to the workspace. You can read, write, search, and glob files in these directories as part of your workspace scope. + +${KIMI_ADDITIONAL_DIRS_INFO} +{% endif %} # Project Information diff --git a/src/kimi_cli/cli/__init__.py b/src/kimi_cli/cli/__init__.py index 77e895538..e40753593 100644 --- a/src/kimi_cli/cli/__init__.py +++ b/src/kimi_cli/cli/__init__.py @@ -93,6 +93,20 @@ def kimi( help="Working directory for the agent. Default: current directory.", ), ] = None, + local_add_dirs: Annotated[ + list[Path] | None, + typer.Option( + "--add-dir", + exists=True, + file_okay=False, + dir_okay=True, + readable=True, + help=( + "Add an additional directory to the workspace scope. " + "Can be specified multiple times." + ), + ), + ] = None, session_id: Annotated[ str | None, typer.Option( @@ -485,6 +499,28 @@ async def _run(session_id: str | None) -> tuple[Session, bool]: session = await Session.create(work_dir) logger.info("Created new session: {session_id}", session_id=session.id) + # Add CLI-provided additional directories to session state + if local_add_dirs: + from kimi_cli.utils.path import is_within_directory + + canonical_work_dir = work_dir.canonical() + changed = False + for d in local_add_dirs: + dir_path = KaosPath.unsafe_from_local_path(d).canonical() + dir_str = str(dir_path) + # Skip dirs within work_dir (already accessible) + if is_within_directory(dir_path, canonical_work_dir): + logger.info( + "Skipping --add-dir {dir}: already within working directory", + dir=dir_str, + ) + continue + if dir_str not in session.state.additional_dirs: + session.state.additional_dirs.append(dir_str) + changed = True + if changed: + session.save_state() + instance = await KimiCLI.create( session, config=config, diff --git a/src/kimi_cli/session_state.py b/src/kimi_cli/session_state.py index ae7585e51..f691acd22 100644 --- a/src/kimi_cli/session_state.py +++ b/src/kimi_cli/session_state.py @@ -29,6 +29,7 @@ class SessionState(BaseModel): version: int = 1 approval: ApprovalStateData = Field(default_factory=ApprovalStateData) dynamic_subagents: list[DynamicSubagentSpec] = Field(default_factory=_default_dynamic_subagents) + additional_dirs: list[str] = Field(default_factory=list) def load_session_state(session_dir: Path) -> SessionState: diff --git a/src/kimi_cli/soul/agent.py b/src/kimi_cli/soul/agent.py index 2b0a4c84f..dfafe6a87 100644 --- a/src/kimi_cli/soul/agent.py +++ b/src/kimi_cli/soul/agent.py @@ -45,6 +45,8 @@ class BuiltinSystemPromptArgs: """The content of AGENTS.md.""" KIMI_SKILLS: str """Formatted information about available skills.""" + KIMI_ADDITIONAL_DIRS_INFO: str + """Formatted information about additional directories in the workspace.""" async def load_agents_md(work_dir: KaosPath) -> str | None: @@ -74,6 +76,7 @@ class Runtime: labor_market: LaborMarket environment: Environment skills: dict[str, Skill] + additional_dirs: list[KaosPath] @staticmethod async def create( @@ -104,6 +107,40 @@ async def create( for skill in skills ) + # Restore additional directories from session state, pruning stale entries + additional_dirs: list[KaosPath] = [] + pruned = False + valid_dir_strs: list[str] = [] + for dir_str in session.state.additional_dirs: + d = KaosPath(dir_str).canonical() + if await d.is_dir(): + additional_dirs.append(d) + valid_dir_strs.append(dir_str) + else: + logger.warning( + "Additional directory no longer exists, removing from state: {dir}", + dir=dir_str, + ) + pruned = True + if pruned: + session.state.additional_dirs = valid_dir_strs + session.save_state() + + # Format additional dirs info for system prompt + additional_dirs_info = "" + if additional_dirs: + parts: list[str] = [] + for d in additional_dirs: + try: + dir_ls = await list_directory(d) + except OSError: + logger.warning( + "Cannot list additional directory, skipping listing: {dir}", dir=d + ) + dir_ls = "[directory not readable]" + parts.append(f"### `{d}`\n\n```\n{dir_ls}\n```") + additional_dirs_info = "\n\n".join(parts) + # Merge CLI flag with persisted session state effective_yolo = yolo or session.state.approval.yolo saved_actions = set(session.state.approval.auto_approve_actions) @@ -130,12 +167,14 @@ def _on_approval_change() -> None: KIMI_WORK_DIR_LS=ls_output, KIMI_AGENTS_MD=agents_md or "", KIMI_SKILLS=skills_formatted or "No skills found.", + KIMI_ADDITIONAL_DIRS_INFO=additional_dirs_info, ), denwa_renji=DenwaRenji(), approval=Approval(state=approval_state), labor_market=LaborMarket(), environment=environment, skills=skills_by_name, + additional_dirs=additional_dirs, ) def copy_for_fixed_subagent(self) -> Runtime: @@ -151,6 +190,8 @@ def copy_for_fixed_subagent(self) -> Runtime: labor_market=LaborMarket(), # fixed subagent has its own LaborMarket environment=self.environment, skills=self.skills, + # Share the same list reference so /add-dir mutations propagate to all agents + additional_dirs=self.additional_dirs, ) def copy_for_dynamic_subagent(self) -> Runtime: @@ -166,6 +207,8 @@ def copy_for_dynamic_subagent(self) -> Runtime: labor_market=self.labor_market, # dynamic subagent shares LaborMarket with main agent environment=self.environment, skills=self.skills, + # Share the same list reference so /add-dir mutations propagate to all agents + additional_dirs=self.additional_dirs, ) diff --git a/src/kimi_cli/soul/slash.py b/src/kimi_cli/soul/slash.py index 43299a99b..35b703642 100644 --- a/src/kimi_cli/soul/slash.py +++ b/src/kimi_cli/soul/slash.py @@ -80,3 +80,78 @@ async def yolo(soul: KimiSoul, args: str): else: soul.runtime.approval.set_yolo(True) wire_send(TextPart(text="You only live once! All actions will be auto-approved.")) + + +@registry.command(name="add-dir") +async def add_dir(soul: KimiSoul, args: str): + """Add a directory to the workspace. Usage: /add-dir . Run without args to list added dirs""" # noqa: E501 + from kaos.path import KaosPath + + from kimi_cli.utils.path import is_within_directory, list_directory + + args = args.strip() + if not args: + if not soul.runtime.additional_dirs: + wire_send(TextPart(text="No additional directories. Usage: /add-dir ")) + else: + lines = ["Additional directories:"] + for d in soul.runtime.additional_dirs: + lines.append(f" - {d}") + wire_send(TextPart(text="\n".join(lines))) + return + + path = KaosPath(args).expanduser().canonical() + + if not await path.exists(): + wire_send(TextPart(text=f"Directory does not exist: {path}")) + return + if not await path.is_dir(): + wire_send(TextPart(text=f"Not a directory: {path}")) + return + + # Check if already added (exact match) + if path in soul.runtime.additional_dirs: + wire_send(TextPart(text=f"Directory already in workspace: {path}")) + return + + # Check if it's within the work_dir (already accessible) + work_dir = soul.runtime.builtin_args.KIMI_WORK_DIR + if is_within_directory(path, work_dir): + wire_send(TextPart(text=f"Directory is already within the working directory: {path}")) + return + + # Check if it's within an already-added additional directory (redundant) + for existing in soul.runtime.additional_dirs: + if is_within_directory(path, existing): + wire_send( + TextPart( + text=f"Directory is already within an added directory `{existing}`: {path}" + ) + ) + return + + # Validate readability before committing any state changes + try: + ls_output = await list_directory(path) + except OSError as e: + wire_send(TextPart(text=f"Cannot read directory: {path} ({e})")) + return + + # Add the directory (only after readability is confirmed) + soul.runtime.additional_dirs.append(path) + + # Persist to session state + soul.runtime.session.state.additional_dirs.append(str(path)) + soul.runtime.session.save_state() + + # Inject a system message to inform the LLM about the new directory + system_message = system( + f"The user has added an additional directory to the workspace: `{path}`\n\n" + f"Directory listing:\n```\n{ls_output}\n```\n\n" + "You can now read, write, search, and glob files in this directory " + "as if it were part of the working directory." + ) + await soul.context.append_message(Message(role="user", content=[system_message])) + + wire_send(TextPart(text=f"Added directory to workspace: {path}")) + logger.info("Added additional directory: {path}", path=path) diff --git a/src/kimi_cli/tools/file/glob.py b/src/kimi_cli/tools/file/glob.py index 6d03e9a78..de9cf8a9c 100644 --- a/src/kimi_cli/tools/file/glob.py +++ b/src/kimi_cli/tools/file/glob.py @@ -7,9 +7,9 @@ from kosong.tooling import CallableTool2, ToolError, ToolOk, ToolReturnValue from pydantic import BaseModel, Field -from kimi_cli.soul.agent import BuiltinSystemPromptArgs +from kimi_cli.soul.agent import Runtime from kimi_cli.tools.utils import load_desc -from kimi_cli.utils.path import is_within_directory, list_directory +from kimi_cli.utils.path import is_within_workspace, list_directory MAX_MATCHES = 1000 @@ -38,9 +38,10 @@ class Glob(CallableTool2[Params]): ) params: type[Params] = Params - def __init__(self, builtin_args: BuiltinSystemPromptArgs) -> None: + def __init__(self, runtime: Runtime) -> None: super().__init__() - self._work_dir = builtin_args.KIMI_WORK_DIR + self._work_dir = runtime.builtin_args.KIMI_WORK_DIR + self._additional_dirs = runtime.additional_dirs async def _validate_pattern(self, pattern: str) -> ToolError | None: """Validate that the pattern is safe to use.""" @@ -63,14 +64,15 @@ async def _validate_directory(self, directory: KaosPath) -> ToolError | None: """Validate that the directory is safe to search.""" resolved_dir = directory.canonical() - # Ensure the directory is within work directory - if not is_within_directory(resolved_dir, self._work_dir): + # Ensure the directory is within the workspace (work_dir or additional dirs) + if not is_within_workspace(resolved_dir, self._work_dir, self._additional_dirs): return ToolError( message=( - f"`{directory}` is outside the working directory. " - "You can only search within the working directory." + f"`{directory}` is outside the workspace. " + "You can only search within the working directory " + "and additional directories." ), - brief="Directory outside working directory", + brief="Directory outside workspace", ) return None diff --git a/src/kimi_cli/tools/file/read.py b/src/kimi_cli/tools/file/read.py index 507cdb3d6..1f0c0da81 100644 --- a/src/kimi_cli/tools/file/read.py +++ b/src/kimi_cli/tools/file/read.py @@ -8,7 +8,7 @@ from kimi_cli.soul.agent import Runtime from kimi_cli.tools.file.utils import MEDIA_SNIFF_BYTES, detect_file_type from kimi_cli.tools.utils import load_desc, truncate_line -from kimi_cli.utils.path import is_within_directory +from kimi_cli.utils.path import is_within_workspace MAX_LINES = 1000 MAX_LINE_LENGTH = 2000 @@ -58,12 +58,16 @@ def __init__(self, runtime: Runtime) -> None: super().__init__(description=description) self._runtime = runtime self._work_dir = runtime.builtin_args.KIMI_WORK_DIR + self._additional_dirs = runtime.additional_dirs async def _validate_path(self, path: KaosPath) -> ToolError | None: """Validate that the path is safe to read.""" resolved_path = path.canonical() - if not is_within_directory(resolved_path, self._work_dir) and not path.is_absolute(): + if ( + not is_within_workspace(resolved_path, self._work_dir, self._additional_dirs) + and not path.is_absolute() + ): # Outside files can only be read with absolute paths return ToolError( message=( diff --git a/src/kimi_cli/tools/file/read_media.py b/src/kimi_cli/tools/file/read_media.py index 796519504..209854985 100644 --- a/src/kimi_cli/tools/file/read_media.py +++ b/src/kimi_cli/tools/file/read_media.py @@ -13,7 +13,7 @@ from kimi_cli.tools.file.utils import MEDIA_SNIFF_BYTES, FileType, detect_file_type from kimi_cli.tools.utils import load_desc from kimi_cli.utils.media_tags import wrap_media_part -from kimi_cli.utils.path import is_within_directory +from kimi_cli.utils.path import is_within_workspace from kimi_cli.wire.types import ImageURLPart, VideoURLPart MAX_MEDIA_MEGABYTES = 100 @@ -66,13 +66,17 @@ def __init__(self, runtime: Runtime) -> None: self._runtime = runtime self._work_dir = runtime.builtin_args.KIMI_WORK_DIR + self._additional_dirs = runtime.additional_dirs self._capabilities = capabilities async def _validate_path(self, path: KaosPath) -> ToolError | None: """Validate that the path is safe to read.""" resolved_path = path.canonical() - if not is_within_directory(resolved_path, self._work_dir) and not path.is_absolute(): + if ( + not is_within_workspace(resolved_path, self._work_dir, self._additional_dirs) + and not path.is_absolute() + ): # Outside files can only be read with absolute paths return ToolError( message=( diff --git a/src/kimi_cli/tools/file/replace.py b/src/kimi_cli/tools/file/replace.py index c5a2a915b..d5bc6b35d 100644 --- a/src/kimi_cli/tools/file/replace.py +++ b/src/kimi_cli/tools/file/replace.py @@ -5,13 +5,13 @@ from kosong.tooling import CallableTool2, ToolError, ToolReturnValue from pydantic import BaseModel, Field -from kimi_cli.soul.agent import BuiltinSystemPromptArgs +from kimi_cli.soul.agent import Runtime from kimi_cli.soul.approval import Approval from kimi_cli.tools.display import DisplayBlock from kimi_cli.tools.file import FileActions from kimi_cli.tools.utils import ToolRejectedError, load_desc from kimi_cli.utils.diff import build_diff_blocks -from kimi_cli.utils.path import is_within_directory +from kimi_cli.utils.path import is_within_workspace class Edit(BaseModel): @@ -40,16 +40,20 @@ class StrReplaceFile(CallableTool2[Params]): description: str = load_desc(Path(__file__).parent / "replace.md") params: type[Params] = Params - def __init__(self, builtin_args: BuiltinSystemPromptArgs, approval: Approval): + def __init__(self, runtime: Runtime, approval: Approval): super().__init__() - self._work_dir = builtin_args.KIMI_WORK_DIR + self._work_dir = runtime.builtin_args.KIMI_WORK_DIR + self._additional_dirs = runtime.additional_dirs self._approval = approval async def _validate_path(self, path: KaosPath) -> ToolError | None: """Validate that the path is safe to edit.""" resolved_path = path.canonical() - if not is_within_directory(resolved_path, self._work_dir) and not path.is_absolute(): + if ( + not is_within_workspace(resolved_path, self._work_dir, self._additional_dirs) + and not path.is_absolute() + ): return ToolError( message=( f"`{path}` is not an absolute path. " @@ -115,7 +119,7 @@ async def __call__(self, params: Params) -> ToolReturnValue: action = ( FileActions.EDIT - if is_within_directory(p, self._work_dir) + if is_within_workspace(p, self._work_dir, self._additional_dirs) else FileActions.EDIT_OUTSIDE ) diff --git a/src/kimi_cli/tools/file/write.py b/src/kimi_cli/tools/file/write.py index aaa0bb2fa..e0a4b36e4 100644 --- a/src/kimi_cli/tools/file/write.py +++ b/src/kimi_cli/tools/file/write.py @@ -5,13 +5,13 @@ from kosong.tooling import CallableTool2, ToolError, ToolReturnValue from pydantic import BaseModel, Field -from kimi_cli.soul.agent import BuiltinSystemPromptArgs +from kimi_cli.soul.agent import Runtime from kimi_cli.soul.approval import Approval from kimi_cli.tools.display import DisplayBlock from kimi_cli.tools.file import FileActions from kimi_cli.tools.utils import ToolRejectedError, load_desc from kimi_cli.utils.diff import build_diff_blocks -from kimi_cli.utils.path import is_within_directory +from kimi_cli.utils.path import is_within_workspace class Params(BaseModel): @@ -37,16 +37,20 @@ class WriteFile(CallableTool2[Params]): description: str = load_desc(Path(__file__).parent / "write.md") params: type[Params] = Params - def __init__(self, builtin_args: BuiltinSystemPromptArgs, approval: Approval): + def __init__(self, runtime: Runtime, approval: Approval): super().__init__() - self._work_dir = builtin_args.KIMI_WORK_DIR + self._work_dir = runtime.builtin_args.KIMI_WORK_DIR + self._additional_dirs = runtime.additional_dirs self._approval = approval async def _validate_path(self, path: KaosPath) -> ToolError | None: """Validate that the path is safe to write.""" resolved_path = path.canonical() - if not is_within_directory(resolved_path, self._work_dir) and not path.is_absolute(): + if ( + not is_within_workspace(resolved_path, self._work_dir, self._additional_dirs) + and not path.is_absolute() + ): return ToolError( message=( f"`{path}` is not an absolute path. " @@ -108,7 +112,7 @@ async def __call__(self, params: Params) -> ToolReturnValue: action = ( FileActions.EDIT - if is_within_directory(p, self._work_dir) + if is_within_workspace(p, self._work_dir, self._additional_dirs) else FileActions.EDIT_OUTSIDE ) diff --git a/src/kimi_cli/utils/path.py b/src/kimi_cli/utils/path.py index 79e7419a4..335b1121b 100644 --- a/src/kimi_cli/utils/path.py +++ b/src/kimi_cli/utils/path.py @@ -3,6 +3,7 @@ import asyncio import os import re +from collections.abc import Sequence from pathlib import Path, PurePath from stat import S_ISDIR @@ -111,3 +112,16 @@ def is_within_directory(path: KaosPath, directory: KaosPath) -> bool: return True except ValueError: return False + + +def is_within_workspace( + path: KaosPath, + work_dir: KaosPath, + additional_dirs: Sequence[KaosPath] = (), +) -> bool: + """ + Check whether *path* is within the workspace (work_dir or any additional directory). + """ + if is_within_directory(path, work_dir): + return True + return any(is_within_directory(path, d) for d in additional_dirs) diff --git a/tests/conftest.py b/tests/conftest.py index 00d1d670c..5397a026d 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -97,6 +97,7 @@ def builtin_args(temp_work_dir: KaosPath) -> BuiltinSystemPromptArgs: KIMI_WORK_DIR_LS="Test ls content", KIMI_AGENTS_MD="Test agents content", KIMI_SKILLS="No skills found.", + KIMI_ADDITIONAL_DIRS_INFO="", ) @@ -177,6 +178,7 @@ def runtime( environment=environment, skills={}, oauth=OAuthManager(config), + additional_dirs=[], ) rt.labor_market.add_fixed_subagent( "mocker", @@ -261,9 +263,9 @@ def read_media_file_tool(runtime: Runtime) -> ReadMediaFile: @pytest.fixture -def glob_tool(builtin_args: BuiltinSystemPromptArgs) -> Glob: +def glob_tool(runtime: Runtime) -> Glob: """Create a Glob tool instance.""" - return Glob(builtin_args) + return Glob(runtime) @pytest.fixture @@ -273,21 +275,17 @@ def grep_tool() -> Grep: @pytest.fixture -def write_file_tool( - builtin_args: BuiltinSystemPromptArgs, approval: Approval -) -> Generator[WriteFile]: +def write_file_tool(runtime: Runtime, approval: Approval) -> Generator[WriteFile]: """Create a WriteFile tool instance.""" with tool_call_context("WriteFile"): - yield WriteFile(builtin_args, approval) + yield WriteFile(runtime, approval) @pytest.fixture -def str_replace_file_tool( - builtin_args: BuiltinSystemPromptArgs, approval: Approval -) -> Generator[StrReplaceFile]: +def str_replace_file_tool(runtime: Runtime, approval: Approval) -> Generator[StrReplaceFile]: """Create a StrReplaceFile tool instance.""" with tool_call_context("StrReplaceFile"): - yield StrReplaceFile(builtin_args, approval) + yield StrReplaceFile(runtime, approval) @pytest.fixture diff --git a/tests/core/test_kimisoul_ralph_loop.py b/tests/core/test_kimisoul_ralph_loop.py index 549e02726..e20511181 100644 --- a/tests/core/test_kimisoul_ralph_loop.py +++ b/tests/core/test_kimisoul_ralph_loop.py @@ -113,6 +113,7 @@ def _runtime_with_llm(runtime: Runtime, llm: LLM) -> Runtime: environment=runtime.environment, skills=runtime.skills, oauth=runtime.oauth, + additional_dirs=runtime.additional_dirs, ) diff --git a/tests/core/test_kimisoul_retry_recovery.py b/tests/core/test_kimisoul_retry_recovery.py index 6a36b38fc..c7d6e6992 100644 --- a/tests/core/test_kimisoul_retry_recovery.py +++ b/tests/core/test_kimisoul_retry_recovery.py @@ -192,6 +192,7 @@ def _runtime_with_llm(runtime: Runtime, llm: LLM) -> Runtime: environment=runtime.environment, skills=runtime.skills, oauth=runtime.oauth, + additional_dirs=runtime.additional_dirs, ) diff --git a/tests/test_additional_dirs_state.py b/tests/test_additional_dirs_state.py new file mode 100644 index 000000000..71a5d3a1c --- /dev/null +++ b/tests/test_additional_dirs_state.py @@ -0,0 +1,46 @@ +"""Tests for additional_dirs session state persistence and restoration.""" + +from __future__ import annotations + +import json +from pathlib import Path + +from kimi_cli.session_state import SessionState, load_session_state, save_session_state + + +def test_session_state_default_additional_dirs(): + """New session state should have empty additional_dirs.""" + state = SessionState() + assert state.additional_dirs == [] + + +def test_session_state_serialization(tmp_path: Path): + """additional_dirs should persist through save/load cycle.""" + state = SessionState() + state.additional_dirs = ["/home/user/lib", "/opt/shared"] + save_session_state(state, tmp_path) + + loaded = load_session_state(tmp_path) + assert loaded.additional_dirs == ["/home/user/lib", "/opt/shared"] + + +def test_session_state_backward_compatibility(tmp_path: Path): + """Old state.json without additional_dirs field should load with empty list.""" + old_state = {"version": 1, "approval": {"yolo": False, "auto_approve_actions": []}} + state_file = tmp_path / "state.json" + state_file.write_text(json.dumps(old_state)) + + loaded = load_session_state(tmp_path) + assert loaded.additional_dirs == [] + + +def test_session_state_preserves_other_fields(tmp_path: Path): + """Saving additional_dirs should not corrupt other fields.""" + state = SessionState() + state.approval.yolo = True + state.additional_dirs = ["/extra"] + save_session_state(state, tmp_path) + + loaded = load_session_state(tmp_path) + assert loaded.approval.yolo is True + assert loaded.additional_dirs == ["/extra"] diff --git a/tests/tools/test_additional_dirs.py b/tests/tools/test_additional_dirs.py new file mode 100644 index 000000000..11b5cf56b --- /dev/null +++ b/tests/tools/test_additional_dirs.py @@ -0,0 +1,200 @@ +"""Tests for additional directories support in file tools.""" + +from __future__ import annotations + +import platform +import tempfile +from collections.abc import Generator +from pathlib import Path + +import pytest +from kaos.path import KaosPath + +from kimi_cli.soul.agent import Runtime +from kimi_cli.soul.approval import Approval +from kimi_cli.tools.file.glob import Glob +from kimi_cli.tools.file.glob import Params as GlobParams +from kimi_cli.tools.file.read import Params as ReadParams +from kimi_cli.tools.file.read import ReadFile +from kimi_cli.tools.file.replace import Edit, StrReplaceFile +from kimi_cli.tools.file.replace import Params as ReplaceParams +from kimi_cli.tools.file.write import Params as WriteParams +from kimi_cli.tools.file.write import WriteFile +from tests.conftest import tool_call_context + + +@pytest.fixture +def additional_dir(temp_work_dir: KaosPath) -> Generator[KaosPath]: + """Create a temporary additional directory outside the work directory.""" + with tempfile.TemporaryDirectory() as tmpdir: + p = Path(tmpdir).resolve() + yield KaosPath.unsafe_from_local_path(p) + + +@pytest.fixture +def runtime_with_additional_dir(runtime: Runtime, additional_dir: KaosPath) -> Runtime: + """Runtime with an additional directory configured.""" + runtime.additional_dirs.append(additional_dir) + return runtime + + +# ── Glob tests ────────────────────────────────────────────────────────────── + + +async def test_glob_in_additional_dir( + runtime_with_additional_dir: Runtime, additional_dir: KaosPath +): + """Glob should be able to search in an additional directory.""" + glob_tool = Glob(runtime_with_additional_dir) + await (additional_dir / "hello.py").write_text("print('hello')") + await (additional_dir / "world.py").write_text("print('world')") + + result = await glob_tool(GlobParams(pattern="*.py", directory=str(additional_dir))) + assert not result.is_error + assert "hello.py" in result.output + assert "world.py" in result.output + + +async def test_glob_in_additional_dir_subdirectory( + runtime_with_additional_dir: Runtime, additional_dir: KaosPath +): + """Glob should work in a subdirectory of an additional directory.""" + glob_tool = Glob(runtime_with_additional_dir) + await (additional_dir / "src").mkdir() + await (additional_dir / "src" / "main.py").write_text("main") + + sub = str(additional_dir / "src") + result = await glob_tool(GlobParams(pattern="*.py", directory=sub)) + assert not result.is_error + assert "main.py" in result.output + + +async def test_glob_outside_all_dirs_rejected( + runtime_with_additional_dir: Runtime, +): + """Glob in a directory outside both work_dir and additional dirs should fail.""" + glob_tool = Glob(runtime_with_additional_dir) + outside = "/tmp/evil" if platform.system() != "Windows" else "C:/tmp/evil" + + result = await glob_tool(GlobParams(pattern="*.py", directory=outside)) + assert result.is_error + assert "outside the workspace" in result.message + + +# ── ReadFile tests ────────────────────────────────────────────────────────── + + +async def test_read_file_in_additional_dir( + runtime_with_additional_dir: Runtime, additional_dir: KaosPath +): + """ReadFile should read files in additional directories.""" + read_tool = ReadFile(runtime_with_additional_dir) + test_file = additional_dir / "readme.txt" + await test_file.write_text("Hello from additional dir\n") + + result = await read_tool(ReadParams(path=str(test_file))) + assert not result.is_error + assert "Hello from additional dir" in result.output + + +async def test_read_file_relative_path_in_additional_dir( + runtime_with_additional_dir: Runtime, additional_dir: KaosPath +): + """Relative paths that resolve outside work_dir but inside additional dir should work.""" + read_tool = ReadFile(runtime_with_additional_dir) + test_file = additional_dir / "data.txt" + await test_file.write_text("data content\n") + + # Absolute path to the file in additional dir should be allowed + result = await read_tool(ReadParams(path=str(test_file))) + assert not result.is_error + + +# ── WriteFile tests ───────────────────────────────────────────────────────── + + +async def test_write_file_in_additional_dir( + runtime_with_additional_dir: Runtime, approval: Approval, additional_dir: KaosPath +): + """WriteFile should write to files in additional directories.""" + with tool_call_context("WriteFile"): + write_tool = WriteFile(runtime_with_additional_dir, approval) + target = additional_dir / "output.txt" + + result = await write_tool(WriteParams(path=str(target), content="new content")) + assert not result.is_error + assert await target.read_text() == "new content" + + +async def test_write_file_in_additional_dir_uses_edit_action( + runtime_with_additional_dir: Runtime, approval: Approval, additional_dir: KaosPath +): + """Writing in additional dir should use EDIT action (not EDIT_OUTSIDE).""" + with tool_call_context("WriteFile"): + write_tool = WriteFile(runtime_with_additional_dir, approval) + target = additional_dir / "in_workspace.txt" + + result = await write_tool(WriteParams(path=str(target), content="content")) + assert not result.is_error + + +# ── StrReplaceFile tests ──────────────────────────────────────────────────── + + +async def test_replace_in_additional_dir( + runtime_with_additional_dir: Runtime, approval: Approval, additional_dir: KaosPath +): + """StrReplaceFile should edit files in additional directories.""" + with tool_call_context("StrReplaceFile"): + replace_tool = StrReplaceFile(runtime_with_additional_dir, approval) + target = additional_dir / "code.py" + await target.write_text("old_value = 1\n") + + result = await replace_tool( + ReplaceParams( + path=str(target), + edit=Edit(old="old_value", new="new_value"), + ) + ) + assert not result.is_error + assert await target.read_text() == "new_value = 1\n" + + +# ── Dynamic mutation tests ────────────────────────────────────────────────── + + +async def test_add_dir_dynamically_affects_tools(runtime: Runtime, approval: Approval): + """Adding a dir to runtime.additional_dirs should immediately affect tool behavior.""" + glob_tool = Glob(runtime) + + with tempfile.TemporaryDirectory() as tmpdir: + extra = KaosPath.unsafe_from_local_path(Path(tmpdir).resolve()) + await (extra / "test.py").write_text("pass") + + # Before adding: should be rejected + result = await glob_tool(GlobParams(pattern="*.py", directory=str(extra))) + assert result.is_error + assert "outside the workspace" in result.message + + # Add the directory to runtime (simulating /add-dir) + runtime.additional_dirs.append(extra) + + # After adding: should work + result = await glob_tool(GlobParams(pattern="*.py", directory=str(extra))) + assert not result.is_error + assert "test.py" in result.output + + +async def test_subagent_shares_additional_dirs(runtime: Runtime): + """Subagent runtime should share the same additional_dirs list.""" + fixed = runtime.copy_for_fixed_subagent() + dynamic = runtime.copy_for_dynamic_subagent() + + # They should be the exact same list object + assert fixed.additional_dirs is runtime.additional_dirs + assert dynamic.additional_dirs is runtime.additional_dirs + + # Mutation on parent should be visible to subagents + runtime.additional_dirs.append(KaosPath("/test/shared")) + assert KaosPath("/test/shared") in fixed.additional_dirs + assert KaosPath("/test/shared") in dynamic.additional_dirs diff --git a/tests/tools/test_glob.py b/tests/tools/test_glob.py index e3609e9cf..7fa005041 100644 --- a/tests/tools/test_glob.py +++ b/tests/tools/test_glob.py @@ -156,7 +156,7 @@ async def test_glob_outside_work_directory(glob_tool: Glob): result = await glob_tool(Params(pattern="*.py", directory=dir)) assert result.is_error - assert "outside the working directory" in result.message + assert "outside the workspace" in result.message async def test_glob_outside_work_directory_with_prefix(glob_tool: Glob, temp_work_dir: KaosPath): @@ -168,7 +168,7 @@ async def test_glob_outside_work_directory_with_prefix(glob_tool: Glob, temp_wor result = await glob_tool(Params(pattern="*.py", directory=str(sneaky_dir))) assert result.is_error - assert "outside the working directory" in result.message + assert "outside the workspace" in result.message async def test_glob_nonexistent_directory(glob_tool: Glob, temp_work_dir: KaosPath): diff --git a/tests/utils/test_is_within_workspace.py b/tests/utils/test_is_within_workspace.py new file mode 100644 index 000000000..3d251b969 --- /dev/null +++ b/tests/utils/test_is_within_workspace.py @@ -0,0 +1,145 @@ +"""Tests for is_within_workspace and is_within_directory utility functions.""" + +from __future__ import annotations + +from pathlib import PurePosixPath, PureWindowsPath + +from kaos.path import KaosPath + +from kimi_cli.utils.path import is_within_directory, is_within_workspace + + +def test_within_work_dir(): + """Path inside work_dir should be accepted.""" + work_dir = KaosPath("/home/user/project") + assert is_within_workspace(KaosPath("/home/user/project/src/main.py"), work_dir) + + +def test_work_dir_itself(): + """Work dir itself should be accepted.""" + work_dir = KaosPath("/home/user/project") + assert is_within_workspace(work_dir, work_dir) + + +def test_outside_work_dir_no_additional(): + """Path outside work_dir with no additional dirs should be rejected.""" + work_dir = KaosPath("/home/user/project") + assert not is_within_workspace(KaosPath("/home/user/other/file.py"), work_dir) + + +def test_within_additional_dir(): + """Path inside an additional dir should be accepted.""" + work_dir = KaosPath("/home/user/project") + additional = [KaosPath("/home/user/lib")] + assert is_within_workspace(KaosPath("/home/user/lib/module.py"), work_dir, additional) + + +def test_additional_dir_itself(): + """The additional dir path itself should be accepted.""" + work_dir = KaosPath("/home/user/project") + additional = [KaosPath("/home/user/lib")] + assert is_within_workspace(KaosPath("/home/user/lib"), work_dir, additional) + + +def test_outside_all_dirs(): + """Path outside both work_dir and additional dirs should be rejected.""" + work_dir = KaosPath("/home/user/project") + additional = [KaosPath("/home/user/lib")] + assert not is_within_workspace(KaosPath("/tmp/evil"), work_dir, additional) + + +def test_multiple_additional_dirs(): + """Path within any of multiple additional dirs should be accepted.""" + work_dir = KaosPath("/home/user/project") + additional = [KaosPath("/home/user/lib"), KaosPath("/opt/shared")] + assert is_within_workspace(KaosPath("/opt/shared/config.json"), work_dir, additional) + + +def test_prefix_attack_work_dir(): + """Path sharing prefix but not actually inside work_dir should be rejected.""" + work_dir = KaosPath("/home/user/project") + assert not is_within_workspace(KaosPath("/home/user/project-evil/hack.py"), work_dir) + + +def test_prefix_attack_additional_dir(): + """Path sharing prefix but not actually inside additional dir should be rejected.""" + work_dir = KaosPath("/home/user/project") + additional = [KaosPath("/home/user/lib")] + assert not is_within_workspace(KaosPath("/home/user/lib-evil/hack.py"), work_dir, additional) + + +def test_empty_additional_dirs(): + """Empty additional_dirs sequence should not cause errors.""" + work_dir = KaosPath("/home/user/project") + assert is_within_workspace(KaosPath("/home/user/project/a.py"), work_dir, []) + assert not is_within_workspace(KaosPath("/tmp/x"), work_dir, []) + + +def test_default_additional_dirs(): + """Default parameter (no additional_dirs) should work.""" + work_dir = KaosPath("/home/user/project") + assert is_within_workspace(KaosPath("/home/user/project/a.py"), work_dir) + assert not is_within_workspace(KaosPath("/tmp/x"), work_dir) + + +# ── Cross-platform path tests ────────────────────────────────────────────── +# +# is_within_directory uses PurePath(str(path)).relative_to(...), which delegates +# to the platform's PurePath implementation. These tests verify the underlying +# logic works with both POSIX and Windows-style paths by testing PurePath +# directly, ensuring no hardcoded "/" comparisons sneak in. + + +def test_purepath_relative_to_posix(): + """PurePosixPath.relative_to correctly detects containment.""" + base = PurePosixPath("/home/user/project") + assert PurePosixPath("/home/user/project/src/main.py").is_relative_to(base) + assert not PurePosixPath("/home/user/project-evil/hack.py").is_relative_to(base) + assert not PurePosixPath("/tmp/other").is_relative_to(base) + + +def test_purepath_relative_to_windows(): + """PureWindowsPath.relative_to correctly detects containment with backslashes.""" + base = PureWindowsPath("C:\\Users\\user\\project") + child = PureWindowsPath("C:\\Users\\user\\project\\src\\main.py") + assert child.is_relative_to(base) + + sneaky = PureWindowsPath("C:\\Users\\user\\project-evil\\hack.py") + assert not sneaky.is_relative_to(base) + + outside = PureWindowsPath("D:\\other") + assert not outside.is_relative_to(base) + + +def test_purepath_windows_forward_slash_normalized(): + """PureWindowsPath treats forward slashes the same as backslashes.""" + base = PureWindowsPath("C:/Users/user/project") + child = PureWindowsPath("C:/Users/user/project/src/main.py") + assert child.is_relative_to(base) + + +def test_is_within_directory_prefix_attack(): + """is_within_directory must not be fooled by shared path prefixes.""" + # This is the exact bug that a naive startswith("/" + ...) check would miss + assert not is_within_directory( + KaosPath("/home/user/project-evil"), KaosPath("/home/user/project") + ) + assert is_within_directory(KaosPath("/home/user/project/sub"), KaosPath("/home/user/project")) + + +def test_is_within_directory_self(): + """A directory is considered within itself.""" + d = KaosPath("/home/user/project") + assert is_within_directory(d, d) + + +def test_is_within_workspace_uses_relative_to_not_string_ops(): + """Verify workspace check is immune to string-prefix false positives.""" + work_dir = KaosPath("/app") + additional = [KaosPath("/app-data")] + + # /app-data is an additional dir, so paths inside it should pass + assert is_within_workspace(KaosPath("/app-data/file.txt"), work_dir, additional) + + # /app-data-evil shares prefix with /app-data but is not inside it + assert not is_within_workspace(KaosPath("/app-data-evil/file.txt"), work_dir, additional) diff --git a/tests/utils/test_pyinstaller_utils.py b/tests/utils/test_pyinstaller_utils.py index edab0d30e..d27c0d51e 100644 --- a/tests/utils/test_pyinstaller_utils.py +++ b/tests/utils/test_pyinstaller_utils.py @@ -13,6 +13,8 @@ def test_pyinstaller_datas(): project_root = Path(__file__).parent.parent.parent python_version = f"{sys.version_info.major}.{sys.version_info.minor}" site_packages = f".venv/lib/python{python_version}/site-packages" + rg_binary = "rg.exe" if platform.system() == "Windows" else "rg" + has_rg_binary = (project_root / "src/kimi_cli/deps/bin" / rg_binary).exists() datas = [ ( Path(path) @@ -26,113 +28,111 @@ def test_pyinstaller_datas(): datas = [(p, d) for p, d in datas if "web/static" not in d] - assert sorted(datas) == snapshot( - [ - ( - f"{site_packages}/dateparser/data/dateparser_tz_cache.pkl", - "dateparser/data", - ), - ( - f"{site_packages}/fastmcp/../fastmcp-2.12.5.dist-info/INSTALLER", - "fastmcp/../fastmcp-2.12.5.dist-info", - ), - ( - f"{site_packages}/fastmcp/../fastmcp-2.12.5.dist-info/METADATA", - "fastmcp/../fastmcp-2.12.5.dist-info", - ), - ( - f"{site_packages}/fastmcp/../fastmcp-2.12.5.dist-info/RECORD", - "fastmcp/../fastmcp-2.12.5.dist-info", - ), - ( - f"{site_packages}/fastmcp/../fastmcp-2.12.5.dist-info/REQUESTED", - "fastmcp/../fastmcp-2.12.5.dist-info", - ), - ( - f"{site_packages}/fastmcp/../fastmcp-2.12.5.dist-info/WHEEL", - "fastmcp/../fastmcp-2.12.5.dist-info", - ), - ( - f"{site_packages}/fastmcp/../fastmcp-2.12.5.dist-info/entry_points.txt", - "fastmcp/../fastmcp-2.12.5.dist-info", - ), - ( - f"{site_packages}/fastmcp/../fastmcp-2.12.5.dist-info/licenses/LICENSE", - "fastmcp/../fastmcp-2.12.5.dist-info/licenses", - ), - ( - "src/kimi_cli/CHANGELOG.md", - "kimi_cli", - ), - ("src/kimi_cli/agents/default/agent.yaml", "kimi_cli/agents/default"), - ("src/kimi_cli/agents/default/sub.yaml", "kimi_cli/agents/default"), - ("src/kimi_cli/agents/default/system.md", "kimi_cli/agents/default"), - ("src/kimi_cli/agents/okabe/agent.yaml", "kimi_cli/agents/okabe"), - ( - f"src/kimi_cli/deps/bin/{'rg.exe' if platform.system() == 'Windows' else 'rg'}", - "kimi_cli/deps/bin", - ), - ("src/kimi_cli/prompts/compact.md", "kimi_cli/prompts"), - ("src/kimi_cli/prompts/init.md", "kimi_cli/prompts"), - ( - "src/kimi_cli/skills/kimi-cli-help/SKILL.md", - "kimi_cli/skills/kimi-cli-help", - ), - ( - "src/kimi_cli/skills/skill-creator/SKILL.md", - "kimi_cli/skills/skill-creator", - ), - ("src/kimi_cli/tools/ask_user/description.md", "kimi_cli/tools/ask_user"), - ( - "src/kimi_cli/tools/dmail/dmail.md", - "kimi_cli/tools/dmail", - ), - ( - "src/kimi_cli/tools/file/glob.md", - "kimi_cli/tools/file", - ), - ( - "src/kimi_cli/tools/file/grep.md", - "kimi_cli/tools/file", - ), - ( - "src/kimi_cli/tools/file/read.md", - "kimi_cli/tools/file", - ), - ( - "src/kimi_cli/tools/file/read_media.md", - "kimi_cli/tools/file", - ), - ( - "src/kimi_cli/tools/file/replace.md", - "kimi_cli/tools/file", - ), - ( - "src/kimi_cli/tools/file/write.md", - "kimi_cli/tools/file", - ), - ("src/kimi_cli/tools/multiagent/create.md", "kimi_cli/tools/multiagent"), - ("src/kimi_cli/tools/multiagent/task.md", "kimi_cli/tools/multiagent"), - ("src/kimi_cli/tools/shell/bash.md", "kimi_cli/tools/shell"), - ("src/kimi_cli/tools/shell/powershell.md", "kimi_cli/tools/shell"), - ( - "src/kimi_cli/tools/think/think.md", - "kimi_cli/tools/think", - ), - ( - "src/kimi_cli/tools/todo/set_todo_list.md", - "kimi_cli/tools/todo", - ), - ( - "src/kimi_cli/tools/web/fetch.md", - "kimi_cli/tools/web", - ), - ( - "src/kimi_cli/tools/web/search.md", - "kimi_cli/tools/web", - ), - ] - ) + expected_datas = [ + ( + f"{site_packages}/dateparser/data/dateparser_tz_cache.pkl", + "dateparser/data", + ), + ( + f"{site_packages}/fastmcp/../fastmcp-2.12.5.dist-info/INSTALLER", + "fastmcp/../fastmcp-2.12.5.dist-info", + ), + ( + f"{site_packages}/fastmcp/../fastmcp-2.12.5.dist-info/METADATA", + "fastmcp/../fastmcp-2.12.5.dist-info", + ), + ( + f"{site_packages}/fastmcp/../fastmcp-2.12.5.dist-info/RECORD", + "fastmcp/../fastmcp-2.12.5.dist-info", + ), + ( + f"{site_packages}/fastmcp/../fastmcp-2.12.5.dist-info/REQUESTED", + "fastmcp/../fastmcp-2.12.5.dist-info", + ), + ( + f"{site_packages}/fastmcp/../fastmcp-2.12.5.dist-info/WHEEL", + "fastmcp/../fastmcp-2.12.5.dist-info", + ), + ( + f"{site_packages}/fastmcp/../fastmcp-2.12.5.dist-info/entry_points.txt", + "fastmcp/../fastmcp-2.12.5.dist-info", + ), + ( + f"{site_packages}/fastmcp/../fastmcp-2.12.5.dist-info/licenses/LICENSE", + "fastmcp/../fastmcp-2.12.5.dist-info/licenses", + ), + ( + "src/kimi_cli/CHANGELOG.md", + "kimi_cli", + ), + ("src/kimi_cli/agents/default/agent.yaml", "kimi_cli/agents/default"), + ("src/kimi_cli/agents/default/sub.yaml", "kimi_cli/agents/default"), + ("src/kimi_cli/agents/default/system.md", "kimi_cli/agents/default"), + ("src/kimi_cli/agents/okabe/agent.yaml", "kimi_cli/agents/okabe"), + ("src/kimi_cli/prompts/compact.md", "kimi_cli/prompts"), + ("src/kimi_cli/prompts/init.md", "kimi_cli/prompts"), + ( + "src/kimi_cli/skills/kimi-cli-help/SKILL.md", + "kimi_cli/skills/kimi-cli-help", + ), + ( + "src/kimi_cli/skills/skill-creator/SKILL.md", + "kimi_cli/skills/skill-creator", + ), + ("src/kimi_cli/tools/ask_user/description.md", "kimi_cli/tools/ask_user"), + ( + "src/kimi_cli/tools/dmail/dmail.md", + "kimi_cli/tools/dmail", + ), + ( + "src/kimi_cli/tools/file/glob.md", + "kimi_cli/tools/file", + ), + ( + "src/kimi_cli/tools/file/grep.md", + "kimi_cli/tools/file", + ), + ( + "src/kimi_cli/tools/file/read.md", + "kimi_cli/tools/file", + ), + ( + "src/kimi_cli/tools/file/read_media.md", + "kimi_cli/tools/file", + ), + ( + "src/kimi_cli/tools/file/replace.md", + "kimi_cli/tools/file", + ), + ( + "src/kimi_cli/tools/file/write.md", + "kimi_cli/tools/file", + ), + ("src/kimi_cli/tools/multiagent/create.md", "kimi_cli/tools/multiagent"), + ("src/kimi_cli/tools/multiagent/task.md", "kimi_cli/tools/multiagent"), + ("src/kimi_cli/tools/shell/bash.md", "kimi_cli/tools/shell"), + ("src/kimi_cli/tools/shell/powershell.md", "kimi_cli/tools/shell"), + ( + "src/kimi_cli/tools/think/think.md", + "kimi_cli/tools/think", + ), + ( + "src/kimi_cli/tools/todo/set_todo_list.md", + "kimi_cli/tools/todo", + ), + ( + "src/kimi_cli/tools/web/fetch.md", + "kimi_cli/tools/web", + ), + ( + "src/kimi_cli/tools/web/search.md", + "kimi_cli/tools/web", + ), + ] + if has_rg_binary: + expected_datas.append((f"src/kimi_cli/deps/bin/{rg_binary}", "kimi_cli/deps/bin")) + + assert sorted(datas) == snapshot(sorted(expected_datas)) def test_pyinstaller_hiddenimports(): diff --git a/tests_e2e/test_wire_protocol.py b/tests_e2e/test_wire_protocol.py index afad359f4..7110c813a 100644 --- a/tests_e2e/test_wire_protocol.py +++ b/tests_e2e/test_wire_protocol.py @@ -56,6 +56,11 @@ def test_initialize_handshake(tmp_path) -> None: "description": "Toggle YOLO mode (auto-approve all actions)", "aliases": [], }, + { + "name": "add-dir", + "description": "Add a directory to the workspace. Usage: /add-dir . Run without args to list added dirs", + "aliases": [], + }, { "name": "skill:kimi-cli-help", "description": "Answer Kimi Code CLI usage, configuration, and troubleshooting questions. Use when user asks about Kimi Code CLI installation, setup, configuration, slash commands, keyboard shortcuts, MCP integration, providers, environment variables, how something works internally, or any questions about Kimi Code CLI itself.", @@ -119,6 +124,11 @@ def test_initialize_external_tool_conflict(tmp_path) -> None: "description": "Toggle YOLO mode (auto-approve all actions)", "aliases": [], }, + { + "name": "add-dir", + "description": "Add a directory to the workspace. Usage: /add-dir . Run without args to list added dirs", + "aliases": [], + }, { "name": "skill:kimi-cli-help", "description": "Answer Kimi Code CLI usage, configuration, and troubleshooting questions. Use when user asks about Kimi Code CLI installation, setup, configuration, slash commands, keyboard shortcuts, MCP integration, providers, environment variables, how something works internally, or any questions about Kimi Code CLI itself.",