-
Notifications
You must be signed in to change notification settings - Fork 800
feat(security): Add shell command security analysis to approval workflow #1614
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
81fadaa
5fe9d26
1fe4ef4
869b13b
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,28 +1,97 @@ | ||
| from __future__ import annotations | ||
|
|
||
| from typing import Any | ||
|
|
||
|
|
||
| class KimiCLIException(Exception): | ||
| """Base exception class for Kimi Code CLI.""" | ||
| """Base exception class for Kimi Code CLI. | ||
|
|
||
| pass | ||
| Provides structured error context for better debugging and user feedback. | ||
| """ | ||
|
|
||
| def __init__(self, message: str, *, context: dict[str, Any] | None = None): | ||
| super().__init__(message) | ||
| self.message = message | ||
| self.context = context or {} | ||
|
|
||
| class ConfigError(KimiCLIException, ValueError): | ||
| """Configuration error.""" | ||
| def __str__(self) -> str: | ||
| if self.context: | ||
| context_str = ", ".join(f"{k}={v!r}" for k, v in self.context.items()) | ||
| return f"{self.message} ({context_str})" | ||
| return self.message | ||
|
|
||
| pass | ||
|
|
||
| class ConfigError(KimiCLIException, ValueError): | ||
| """Configuration error. | ||
|
|
||
| Attributes: | ||
| config_path: Path to the configuration file that caused the error, if applicable. | ||
| field: Specific configuration field that failed validation, if known. | ||
| """ | ||
|
|
||
| def __init__( | ||
| self, | ||
| message: str, | ||
| *, | ||
| config_path: str | None = None, | ||
| field: str | None = None, | ||
| context: dict[str, Any] | None = None, | ||
| ): | ||
| ctx = context or {} | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🟡 Exception subclass constructors mutate caller's In all exception subclasses ( Prompt for agentsWas this helpful? React with 👍 or 👎 to provide feedback. |
||
| if config_path: | ||
| ctx["config_path"] = config_path | ||
| if field: | ||
| ctx["field"] = field | ||
| super().__init__(message, context=ctx) | ||
| self.config_path = config_path | ||
| self.field = field | ||
|
|
||
| class AgentSpecError(KimiCLIException, ValueError): | ||
| """Agent specification error.""" | ||
|
|
||
| pass | ||
| class AgentSpecError(KimiCLIException, ValueError): | ||
| """Agent specification error. | ||
|
|
||
| Attributes: | ||
| agent_file: Path to the agent specification file, if applicable. | ||
| """ | ||
|
|
||
| def __init__( | ||
| self, | ||
| message: str, | ||
| *, | ||
| agent_file: str | None = None, | ||
| context: dict[str, Any] | None = None, | ||
| ): | ||
| ctx = context or {} | ||
| if agent_file: | ||
| ctx["agent_file"] = agent_file | ||
| super().__init__(message, context=ctx) | ||
| self.agent_file = agent_file | ||
|
|
||
|
|
||
| class InvalidToolError(KimiCLIException, ValueError): | ||
| """Invalid tool error.""" | ||
|
|
||
| pass | ||
| """Invalid tool error. | ||
|
|
||
| Attributes: | ||
| tool_name: Name of the invalid tool. | ||
| reason: Specific reason why the tool is invalid. | ||
| """ | ||
|
|
||
| def __init__( | ||
| self, | ||
| message: str, | ||
| *, | ||
| tool_name: str | None = None, | ||
| reason: str | None = None, | ||
| context: dict[str, Any] | None = None, | ||
| ): | ||
| ctx = context or {} | ||
| if tool_name: | ||
| ctx["tool_name"] = tool_name | ||
| if reason: | ||
| ctx["reason"] = reason | ||
| super().__init__(message, context=ctx) | ||
| self.tool_name = tool_name | ||
| self.reason = reason | ||
|
|
||
|
|
||
| class SystemPromptTemplateError(KimiCLIException, ValueError): | ||
|
|
@@ -32,12 +101,47 @@ class SystemPromptTemplateError(KimiCLIException, ValueError): | |
|
|
||
|
|
||
| class MCPConfigError(KimiCLIException, ValueError): | ||
| """MCP config error.""" | ||
|
|
||
| pass | ||
| """MCP config error. | ||
|
|
||
| Attributes: | ||
| server_name: Name of the MCP server with configuration issues. | ||
| """ | ||
|
|
||
| def __init__( | ||
| self, | ||
| message: str, | ||
| *, | ||
| server_name: str | None = None, | ||
| context: dict[str, Any] | None = None, | ||
| ): | ||
| ctx = context or {} | ||
| if server_name: | ||
| ctx["server_name"] = server_name | ||
| super().__init__(message, context=ctx) | ||
| self.server_name = server_name | ||
|
|
||
|
|
||
| class MCPRuntimeError(KimiCLIException, RuntimeError): | ||
| """MCP runtime error.""" | ||
|
|
||
| pass | ||
| """MCP runtime error. | ||
|
|
||
| Attributes: | ||
| server_name: Name of the MCP server that encountered an error. | ||
| exit_code: Process exit code if the server process terminated. | ||
| """ | ||
|
|
||
| def __init__( | ||
| self, | ||
| message: str, | ||
| *, | ||
| server_name: str | None = None, | ||
| exit_code: int | None = None, | ||
| context: dict[str, Any] | None = None, | ||
| ): | ||
| ctx = context or {} | ||
| if server_name: | ||
| ctx["server_name"] = server_name | ||
| if exit_code is not None: | ||
| ctx["exit_code"] = exit_code | ||
| super().__init__(message, context=ctx) | ||
| self.server_name = server_name | ||
| self.exit_code = exit_code | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -5,7 +5,7 @@ | |
|
|
||
| import kaos | ||
| from kaos import AsyncReadable | ||
| from kosong.tooling import CallableTool2, ToolReturnValue | ||
| from kosong.tooling import BriefDisplayBlock, CallableTool2, ToolReturnValue | ||
| from pydantic import BaseModel, Field, model_validator | ||
|
|
||
| from kimi_cli.background import TaskView, format_task | ||
|
|
@@ -14,6 +14,7 @@ | |
| from kimi_cli.soul.toolset import get_current_tool_call_or_none | ||
| from kimi_cli.tools.display import BackgroundTaskDisplayBlock, ShellDisplayBlock | ||
| from kimi_cli.tools.utils import ToolResultBuilder, load_desc | ||
| from kimi_cli.utils.command_security import analyze_command, format_security_notes | ||
| from kimi_cli.utils.environment import Environment | ||
| from kimi_cli.utils.subprocess_env import get_noninteractive_env | ||
|
|
||
|
|
@@ -82,16 +83,22 @@ async def __call__(self, params: Params) -> ToolReturnValue: | |
| if params.run_in_background: | ||
| return await self._run_in_background(params) | ||
|
|
||
| # Analyze command for security-relevant patterns | ||
| security_notes = analyze_command(params.command) | ||
| display: list[ShellDisplayBlock | BriefDisplayBlock] = [ | ||
| ShellDisplayBlock( | ||
| language="powershell" if self._is_powershell else "bash", | ||
| command=params.command, | ||
| ) | ||
| ] | ||
| if security_notes: | ||
| display.append(BriefDisplayBlock(text=format_security_notes(security_notes))) | ||
|
|
||
| result = await self._approval.request( | ||
| self.name, | ||
| "run command", | ||
| f"Run command `{params.command}`", | ||
| display=[ | ||
| ShellDisplayBlock( | ||
| language="powershell" if self._is_powershell else "bash", | ||
| command=params.command, | ||
| ) | ||
| ], | ||
| display=display, | ||
|
Comment on lines
97
to
+101
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Security notes are currently added only as Useful? React with 👍 / 👎. |
||
| ) | ||
| if not result: | ||
| return result.rejection_error() | ||
|
|
@@ -130,16 +137,22 @@ async def _run_in_background(self, params: Params) -> ToolReturnValue: | |
| brief="No tool call context", | ||
| ) | ||
|
|
||
| # Analyze command for security-relevant patterns | ||
| security_notes = analyze_command(params.command) | ||
| bg_display: list[ShellDisplayBlock | BriefDisplayBlock] = [ | ||
| ShellDisplayBlock( | ||
| language="powershell" if self._is_powershell else "bash", | ||
| command=params.command, | ||
| ) | ||
| ] | ||
| if security_notes: | ||
| bg_display.append(BriefDisplayBlock(text=format_security_notes(security_notes))) | ||
|
|
||
| result = await self._approval.request( | ||
| self.name, | ||
| "run background command", | ||
| f"Run background command `{params.command}`", | ||
| display=[ | ||
| ShellDisplayBlock( | ||
| language="powershell" if self._is_powershell else "bash", | ||
| command=params.command, | ||
| ) | ||
| ], | ||
| display=bg_display, | ||
| ) | ||
| if not result: | ||
| return result.rejection_error() | ||
|
|
@@ -154,7 +167,7 @@ async def _run_in_background(self, params: Params) -> ToolReturnValue: | |
| shell_path=str(self._shell_path), | ||
| cwd=str(self._runtime.session.work_dir), | ||
| ) | ||
| except Exception as exc: | ||
| except (OSError, RuntimeError) as exc: | ||
| builder = ToolResultBuilder() | ||
| return builder.error(f"Failed to start background task: {exc}", brief="Start failed") | ||
|
|
||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -537,8 +537,10 @@ def _handler(): | |
| kwargs["stderr"] = stderr | ||
| proc = await asyncio.create_subprocess_shell(command, env=get_clean_env(), **kwargs) | ||
| await proc.wait() | ||
| except Exception as e: | ||
| logger.exception("Failed to run shell command:") | ||
| except asyncio.CancelledError: | ||
| raise | ||
| except OSError as e: | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Narrowing the handler to Useful? React with 👍 / 👎. |
||
| logger.error("Failed to run shell command: {error}", error=e) | ||
| console.print(f"[red]Failed to run shell command: {e}[/red]") | ||
| finally: | ||
| remove_sigint() | ||
|
|
||
Uh oh!
There was an error while loading. Please reload this page.