tiphys.tools.shell
Shell command execution tool for agents.
Provides safe command execution with policy checks.
1""" 2Shell command execution tool for agents. 3 4Provides safe command execution with policy checks. 5""" 6 7import asyncio 8from pathlib import Path 9 10import structlog 11 12from tiphys.config import TiphysConfig, load_config 13from tiphys.security.analyze import RiskLevel, analyze_command 14from tiphys.security.policy import ToolContext, ToolPermission, create_default_policy 15from tiphys.tools.base import ToolDefinition, tool 16 17logger = structlog.get_logger(__name__) 18 19# Maximum output size (1MB) 20MAX_OUTPUT_SIZE = 1024 * 1024 21 22# Default timeout (60 seconds) 23DEFAULT_TIMEOUT = 60.0 24 25 26@tool( 27 name="run_command", 28 description="Execute a shell command and return its output. Use with caution.", 29 requires_permission=True, 30) 31async def run_command( 32 command: str, 33 cwd: str | None = None, 34 timeout: float = DEFAULT_TIMEOUT, 35) -> str: 36 """ 37 Execute a shell command. 38 39 Args: 40 command: The shell command to execute. 41 cwd: Working directory for the command. 42 timeout: Timeout in seconds (max 300). 43 44 Returns: 45 Command output (stdout + stderr) or error message. 46 """ 47 config = load_config() 48 49 # Validate timeout 50 timeout = min(timeout, 300.0) 51 52 # Analyze the command for security 53 analysis = analyze_command(command, config.allowed_commands) 54 55 # Log command analysis 56 await logger.ainfo( 57 "Command analysis", 58 command=command[:100], 59 risk_level=analysis.risk_level.value, 60 binaries=analysis.binaries, 61 is_allowed=analysis.is_allowed, 62 ) 63 64 # Check exec_mode 65 if config.exec_mode == "deny": 66 return "Error: Command execution is disabled (exec_mode=deny)" 67 68 # Check allowlist mode 69 if config.exec_mode == "allowlist" and not analysis.is_allowed: 70 denied_binaries = [b for b in analysis.binaries if b not in config.allowed_commands] 71 return f"Error: Command blocked. Binaries not in allowlist: {denied_binaries}" 72 73 # Block critical risk commands 74 if analysis.risk_level == RiskLevel.CRITICAL: 75 return f"Error: Command blocked due to critical risk. Reasons: {analysis.reasons}" 76 77 # Validate working directory 78 work_dir: Path | None = None 79 if cwd: 80 try: 81 work_dir = Path(cwd).resolve() 82 if not work_dir.is_dir(): 83 return f"Error: Working directory does not exist: {cwd}" 84 except Exception as e: 85 return f"Error: Invalid working directory: {e}" 86 87 # Execute the command 88 try: 89 proc = await asyncio.create_subprocess_shell( 90 command, 91 stdout=asyncio.subprocess.PIPE, 92 stderr=asyncio.subprocess.PIPE, 93 cwd=work_dir, 94 ) 95 96 try: 97 stdout, stderr = await asyncio.wait_for( 98 proc.communicate(), 99 timeout=timeout, 100 ) 101 except TimeoutError: 102 proc.kill() 103 await proc.wait() 104 return f"Error: Command timed out after {timeout} seconds" 105 106 # Decode output 107 stdout_str = stdout.decode("utf-8", errors="replace") 108 stderr_str = stderr.decode("utf-8", errors="replace") 109 110 # Combine output 111 output_parts: list[str] = [] 112 113 if stdout_str: 114 output_parts.append(stdout_str) 115 116 if stderr_str: 117 if output_parts: 118 output_parts.append("\n--- stderr ---\n") 119 output_parts.append(stderr_str) 120 121 output = "".join(output_parts) 122 123 # Truncate if too long 124 if len(output) > MAX_OUTPUT_SIZE: 125 output = output[:MAX_OUTPUT_SIZE] + f"\n... (truncated, {len(output)} total bytes)" 126 127 # Add exit code info 128 if proc.returncode != 0: 129 output += f"\n\n[Exit code: {proc.returncode}]" 130 131 return output if output.strip() else "(no output)" 132 133 except PermissionError: 134 return "Error: Permission denied executing command" 135 except FileNotFoundError: 136 return f"Error: Command not found: {command.split()[0] if command else command}" 137 except Exception as e: 138 await logger.aerror("Command execution failed", command=command[:100], error=str(e)) 139 return f"Error executing command: {e}" 140 141 142async def check_command_permission( 143 command: str, 144 context: ToolContext, 145 config: TiphysConfig | None = None, 146) -> tuple[bool, str]: 147 """ 148 Check if a command is allowed by policy. 149 150 Args: 151 command: The command to check. 152 context: The tool execution context. 153 config: Optional configuration. 154 155 Returns: 156 Tuple of (is_allowed, reason). 157 """ 158 if config is None: 159 config = load_config() 160 161 # Create policy engine 162 engine = create_default_policy(config) 163 164 # Check tool permission 165 permission = engine.check("run_command", context) 166 167 if permission == ToolPermission.DENIED: 168 return False, "Command execution denied by policy" 169 170 # Analyze command 171 analysis = analyze_command(command, config.allowed_commands) 172 173 if config.exec_mode == "deny": 174 return False, "Command execution disabled" 175 176 if config.exec_mode == "allowlist" and not analysis.is_allowed: 177 return False, f"Binaries not in allowlist: {analysis.binaries}" 178 179 if analysis.risk_level == RiskLevel.CRITICAL: 180 return False, f"Critical risk: {analysis.reasons}" 181 182 if permission == ToolPermission.ASK: 183 return True, "Requires user approval" 184 185 return True, "Allowed" 186 187 188# Export tool definitions 189def get_shell_tools() -> list[ToolDefinition]: 190 """Get all shell tool definitions.""" 191 tools: list[ToolDefinition] = [] 192 193 if hasattr(run_command, "_tool_definition"): 194 tools.append(run_command._tool_definition) 195 196 return tools
logger =
<BoundLoggerLazyProxy(logger=None, wrapper_class=None, processors=None, context_class=None, initial_values={}, logger_factory_args=('tiphys.tools.shell',))>
MAX_OUTPUT_SIZE =
1048576
DEFAULT_TIMEOUT =
60.0
@tool(name='run_command', description='Execute a shell command and return its output. Use with caution.', requires_permission=True)
async def
run_command(command: str, cwd: str | None = None, timeout: float = 60.0) -> str:
27@tool( 28 name="run_command", 29 description="Execute a shell command and return its output. Use with caution.", 30 requires_permission=True, 31) 32async def run_command( 33 command: str, 34 cwd: str | None = None, 35 timeout: float = DEFAULT_TIMEOUT, 36) -> str: 37 """ 38 Execute a shell command. 39 40 Args: 41 command: The shell command to execute. 42 cwd: Working directory for the command. 43 timeout: Timeout in seconds (max 300). 44 45 Returns: 46 Command output (stdout + stderr) or error message. 47 """ 48 config = load_config() 49 50 # Validate timeout 51 timeout = min(timeout, 300.0) 52 53 # Analyze the command for security 54 analysis = analyze_command(command, config.allowed_commands) 55 56 # Log command analysis 57 await logger.ainfo( 58 "Command analysis", 59 command=command[:100], 60 risk_level=analysis.risk_level.value, 61 binaries=analysis.binaries, 62 is_allowed=analysis.is_allowed, 63 ) 64 65 # Check exec_mode 66 if config.exec_mode == "deny": 67 return "Error: Command execution is disabled (exec_mode=deny)" 68 69 # Check allowlist mode 70 if config.exec_mode == "allowlist" and not analysis.is_allowed: 71 denied_binaries = [b for b in analysis.binaries if b not in config.allowed_commands] 72 return f"Error: Command blocked. Binaries not in allowlist: {denied_binaries}" 73 74 # Block critical risk commands 75 if analysis.risk_level == RiskLevel.CRITICAL: 76 return f"Error: Command blocked due to critical risk. Reasons: {analysis.reasons}" 77 78 # Validate working directory 79 work_dir: Path | None = None 80 if cwd: 81 try: 82 work_dir = Path(cwd).resolve() 83 if not work_dir.is_dir(): 84 return f"Error: Working directory does not exist: {cwd}" 85 except Exception as e: 86 return f"Error: Invalid working directory: {e}" 87 88 # Execute the command 89 try: 90 proc = await asyncio.create_subprocess_shell( 91 command, 92 stdout=asyncio.subprocess.PIPE, 93 stderr=asyncio.subprocess.PIPE, 94 cwd=work_dir, 95 ) 96 97 try: 98 stdout, stderr = await asyncio.wait_for( 99 proc.communicate(), 100 timeout=timeout, 101 ) 102 except TimeoutError: 103 proc.kill() 104 await proc.wait() 105 return f"Error: Command timed out after {timeout} seconds" 106 107 # Decode output 108 stdout_str = stdout.decode("utf-8", errors="replace") 109 stderr_str = stderr.decode("utf-8", errors="replace") 110 111 # Combine output 112 output_parts: list[str] = [] 113 114 if stdout_str: 115 output_parts.append(stdout_str) 116 117 if stderr_str: 118 if output_parts: 119 output_parts.append("\n--- stderr ---\n") 120 output_parts.append(stderr_str) 121 122 output = "".join(output_parts) 123 124 # Truncate if too long 125 if len(output) > MAX_OUTPUT_SIZE: 126 output = output[:MAX_OUTPUT_SIZE] + f"\n... (truncated, {len(output)} total bytes)" 127 128 # Add exit code info 129 if proc.returncode != 0: 130 output += f"\n\n[Exit code: {proc.returncode}]" 131 132 return output if output.strip() else "(no output)" 133 134 except PermissionError: 135 return "Error: Permission denied executing command" 136 except FileNotFoundError: 137 return f"Error: Command not found: {command.split()[0] if command else command}" 138 except Exception as e: 139 await logger.aerror("Command execution failed", command=command[:100], error=str(e)) 140 return f"Error executing command: {e}"
Execute a shell command.
Args: command: The shell command to execute. cwd: Working directory for the command. timeout: Timeout in seconds (max 300).
Returns: Command output (stdout + stderr) or error message.
async def
check_command_permission( command: str, context: tiphys.security.ToolContext, config: tiphys.TiphysConfig | None = None) -> tuple[bool, str]:
143async def check_command_permission( 144 command: str, 145 context: ToolContext, 146 config: TiphysConfig | None = None, 147) -> tuple[bool, str]: 148 """ 149 Check if a command is allowed by policy. 150 151 Args: 152 command: The command to check. 153 context: The tool execution context. 154 config: Optional configuration. 155 156 Returns: 157 Tuple of (is_allowed, reason). 158 """ 159 if config is None: 160 config = load_config() 161 162 # Create policy engine 163 engine = create_default_policy(config) 164 165 # Check tool permission 166 permission = engine.check("run_command", context) 167 168 if permission == ToolPermission.DENIED: 169 return False, "Command execution denied by policy" 170 171 # Analyze command 172 analysis = analyze_command(command, config.allowed_commands) 173 174 if config.exec_mode == "deny": 175 return False, "Command execution disabled" 176 177 if config.exec_mode == "allowlist" and not analysis.is_allowed: 178 return False, f"Binaries not in allowlist: {analysis.binaries}" 179 180 if analysis.risk_level == RiskLevel.CRITICAL: 181 return False, f"Critical risk: {analysis.reasons}" 182 183 if permission == ToolPermission.ASK: 184 return True, "Requires user approval" 185 186 return True, "Allowed"
Check if a command is allowed by policy.
Args: command: The command to check. context: The tool execution context. config: Optional configuration.
Returns: Tuple of (is_allowed, reason).
190def get_shell_tools() -> list[ToolDefinition]: 191 """Get all shell tool definitions.""" 192 tools: list[ToolDefinition] = [] 193 194 if hasattr(run_command, "_tool_definition"): 195 tools.append(run_command._tool_definition) 196 197 return tools
Get all shell tool definitions.