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).

def get_shell_tools() -> list[tiphys.tools.base.ToolDefinition]:
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.