diff --git a/docs/gns3-copilot/command-security.md b/docs/gns3-copilot/command-security.md new file mode 100644 index 000000000..6744457f8 --- /dev/null +++ b/docs/gns3-copilot/command-security.md @@ -0,0 +1,433 @@ +# Command Security Configuration + +## Overview + +GNS3-Copilot includes a command filtering system to prevent execution of commands that may cause timeout or console availability issues in the lab environment. This helps: + +- **Prevent tool timeouts**: Commands like `traceroute` may run longer than the tool timeout +- **Maintain console availability**: Long-running commands can leave the device console unavailable for subsequent commands +- **Ensure reliable execution**: Filtering problematic commands ensures the remaining commands can execute properly + +## Implementation Status + +**Status**: ✅ **Implemented and Verified** + +The command filtering system is fully implemented and has been tested in a live GNS3 environment with actual network devices. Key features: + +- ✅ Simple text-based configuration file +- ✅ Substring matching (case-insensitive) +- ✅ Non-blocking filtering (allowed commands execute normally) +- ✅ Detailed blocking feedback in tool results +- ✅ Multi-device support +- ✅ Verified with real Cisco IOS devices + +See the [Implementation Verification](#implementation-verification) section for actual test results. + +## Problem Context + +### Why Filter Commands? + +When GNS3-Copilot tools execute commands on network devices using Nornir/Netmiko, there is a timeout limit (typically 30-60 seconds). If a command exceeds this timeout: + +1. The tool stops waiting and returns a timeout error +2. The device console may still be executing the command +3. Subsequent commands sent to the device fail or produce incorrect results +4. The user may need to manually interrupt the command on the device console + +### Example Scenario + +``` +Time Agent Action Device Console Status +t0 Execute: traceroute 8.8.8.8 [Command starts] +t1 ...waiting... [Tracing...] +t2 ...waiting... [Tracing...] +t30 Timeout! Proceed to next tool [Still tracing!] +t31 Execute: show ip route [Ignored or corrupted] +t32 ❌ Command fails [Console still busy] +``` + +## Current Implementation + +### Forbidden Commands List + +Commands are listed in a simple text file at: +``` +gns3server/agent/gns3_copilot/config/forbidden_commands.txt +``` + +**Format:** +- One command pattern per line +- Simple substring matching (case-insensitive) +- Empty lines and lines starting with `#` are ignored +- Match is performed on the beginning of each command + +**Example:** +``` +# Network diagnostic commands that may timeout +traceroute +tracepath +tracert + +# Debug commands that may destabilize devices +debug + +# Test commands that may affect device stability +test +``` + +### Filter Behavior + +1. **Input Commands**: `["show version", "traceroute 8.8.8.8", "show ip int brief"]` +2. **Filtering**: `traceroute 8.8.8.8` is removed (matches `traceroute`) +3. **Executed**: `["show version", "show ip int brief"]` +4. **Result**: Returns successful output with blocked command information + +### Result Format + +When commands are filtered, the result includes additional fields: + +```json +{ + "device_name": "R-1", + "status": "partial_success", + "output": "R-1#show version\nCisco IOS Software...\nR-1#show ip int brief\nInterface...", + "diagnostic_commands": ["show version", "show ip int brief"], + "blocked_commands": ["traceroute 8.8.8.8"], + "blocked_info": { + "traceroute 8.8.8.8": "Command 'traceroute 8.8.8.8' is not allowed because it matches the forbidden pattern 'traceroute'. This command may run longer than the tool timeout or leave the device console unavailable for subsequent commands." + } +} +``` + +**Status values:** +- `"success"`: All commands executed successfully +- `"partial_success"`: Some commands were blocked, but remaining commands executed successfully +- `"failed"`: Command execution failed (device not found, connection error, etc.) + +## Module Structure + +### Command Filter Module + +**File:** `gns3server/agent/gns3_copilot/utils/command_filter.py` + +**Functions:** +- `filter_forbidden_commands(commands: list[str]) -> tuple[list[str], dict[str, str]]` + - Returns allowed commands and blocked command information +- `is_command_forbidden(command: str) -> bool` + - Check if a single command is forbidden +- `get_forbidden_commands() -> list[str]` + - Get the current list of forbidden patterns +- `reload_forbidden_commands() -> None` + - Reload the forbidden commands list (useful after editing the file) + +### Integration Points + +The filter is integrated into: +- **Display Tools** (`display_tools_nornir.py`): `ExecuteMultipleDeviceCommands` +- **Configuration Tools** (`config_tools_nornir.py`): `ExecuteMultipleDeviceConfigCommands` + +Both tools use the same filtering logic and return format. + +## Configuration + +### Default Forbidden Commands + +If the configuration file is not found, these defaults are used: +- `traceroute` +- `tracepath` +- `tracert` +- `ping -f` +- `debug` +- `test` + +### Customizing the List + +To add or remove forbidden commands: + +1. Edit the configuration file: + ```bash + nano gns3server/agent/gns3_copilot/config/forbidden_commands.txt + ``` + +2. Add your command patterns (one per line): + ``` + # My custom blocked commands + my_dangerous_command + another_pattern + ``` + +3. Restart GNS3 server to apply changes + +### Reloading Without Restart + +To reload the forbidden commands list without restarting the server: + +```python +from gns3server.agent.gns3_copilot.utils.command_filter import reload_forbidden_commands +reload_forbidden_commands() +``` + +## Usage Examples + +### Example 1: All Commands Allowed + +**Input:** +```json +{ + "project_id": "abc-123-def", + "device_configs": [ + { + "device_name": "R-1", + "commands": ["show version", "show ip route"] + } + ] +} +``` + +**Output:** +```json +{ + "device_name": "R-1", + "status": "success", + "output": "...", + "diagnostic_commands": ["show version", "show ip route"] +} +``` + +### Example 2: Some Commands Blocked + +**Input:** +```json +{ + "project_id": "abc-123-def", + "device_configs": [ + { + "device_name": "R-1", + "commands": ["show version", "traceroute 8.8.8.8", "show ip route"] + } + ] +} +``` + +**Output:** +```json +{ + "device_name": "R-1", + "status": "partial_success", + "output": "...", + "diagnostic_commands": ["show version", "show ip route"], + "blocked_commands": ["traceroute 8.8.8.8"], + "blocked_info": { + "traceroute 8.8.8.8": "Command 'traceroute 8.8.8.8' is not allowed because it matches the forbidden pattern 'traceroute'. This command may run longer than the tool timeout or leave the device console unavailable for subsequent commands." + } +} +``` + +### Example 3: All Commands Blocked + +**Input:** +```json +{ + "project_id": "abc-123-def", + "device_configs": [ + { + "device_name": "R-1", + "commands": ["traceroute 8.8.8.8", "debug ip routing"] + } + ] +} +``` + +**Output:** +```json +{ + "device_name": "R-1", + "status": "success", + "output": "", + "diagnostic_commands": [], + "blocked_commands": ["traceroute 8.8.8.8", "debug ip routing"], + "blocked_info": { + "traceroute 8.8.8.8": "Command 'traceroute 8.8.8.8' is not allowed because it matches the forbidden pattern 'traceroute'. This command may run longer than the tool timeout or leave the device console unavailable for subsequent commands.", + "debug ip routing": "Command 'debug ip routing' is not allowed because it matches the forbidden pattern 'debug'. This command may run longer than the tool timeout or leave the device console unavailable for subsequent commands." + } +} +``` + +## Implementation Verification + +### Real-World Test Results + +The command filtering system has been tested in a live GNS3 environment with actual network devices. Below are actual execution results: + +**Test Scenario:** +- Devices: IOU-L2-1, IOU-L2-2 (Cisco IOS Layer 3 switches) +- Commands: Mixed allowed and forbidden commands +- Forbidden pattern: `traceroute` + +**Actual Output:** +```json +{ + "device_name": "IOU-L2-1", + "status": "partial_success", + "diagnostic_commands": [ + "show ip route", + "show ip interface brief", + "ping 10.0.0.1", + "ping 10.0.0.2", + "ping 10.0.0.4" + ], + "blocked_commands": ["traceroute 10.0.0.2"], + "blocked_info": { + "traceroute 10.0.0.2": "Command 'traceroute 10.0.0.2' is not allowed because it matches the forbidden pattern 'traceroute'. This command may run longer than the tool timeout or leave the device console unavailable for subsequent commands." + } +} +``` + +**Key Observations:** +1. ✅ `traceroute` command was successfully filtered +2. ✅ All other commands (`show`, `ping`) executed normally +3. ✅ Status correctly set to `partial_success` +4. ✅ Both `diagnostic_commands` (executed) and `blocked_commands` (filtered) are clearly listed +5. ✅ Detailed blocking reason provided in `blocked_info` +6. ✅ Tool execution continued without timeout or console lockup issues + +### Functionality Verification Matrix + +| Feature | Status | Notes | +|---------|--------|-------| +| Command filtering (substring match) | ✅ Verified | `traceroute` correctly matched and blocked | +| Partial execution | ✅ Verified | Other commands executed successfully | +| Return format consistency | ✅ Verified | Contains all expected fields | +| Multi-device support | ✅ Verified | Each device filtered independently | +| Error messages | ✅ Verified | Clear, informative blocking reasons | +| Status field accuracy | ✅ Verified | `partial_success` set correctly | +| Non-blocking behavior | ✅ Verified | No tool timeouts or console issues | + +### Benefits Confirmed + +1. **Timeout Prevention**: The `traceroute` command that could have taken 30+ seconds was filtered, preventing tool timeout +2. **Console Availability**: Since `traceroute` was not executed, the device console remained available for subsequent commands +3. **Clear Feedback**: The LLM receives clear information about which commands were blocked and why +4. **Partial Execution**: Useful commands (`show`, `ping`) still executed, providing valuable diagnostic information + +## Future Enhancements (TODO) + +### Planned Improvements + +1. **Regex Support**: Allow more sophisticated pattern matching + ```python + # Current: simple substring match + "traceroute" + + # Future: regex patterns + "^traceroute\\s+" + "ping\\s+.*\\s+-f" + ``` + +2. **User Override File**: Allow per-project or user-specific overrides + ``` + /etc/gns3-server/forbidden_commands_override.txt + /forbidden_commands_override.txt + ``` + +3. **Web UI Configuration**: Manage forbidden commands through GNS3 web interface + +4. **Audit Logging**: Log blocked commands for security analysis + +5. **Per-Command Timeouts**: Configure timeouts for specific commands instead of blocking + ```python + "command_timeouts": { + "traceroute.*": 120, + "debug.*": 5 + } + ``` + +6. **Interrupt Mechanism**: Send Ctrl+C to interrupt long-running commands instead of blocking + ```python + def execute_with_timeout(cmd, timeout=30): + try: + return device.execute(cmd, timeout=timeout) + except Timeout: + device.send_break() # Ctrl+C + return f"Command interrupted after {timeout}s" + ``` + +7. **Command State Tracking**: Track device console state to ensure availability + ```python + device_state = { + "console_available": True, + "current_command": None, + "last_prompt_seen": timestamp + } + ``` + +### Advanced Features (Long-term) + +- **Per-Device Filtering**: Different rules for different device types +- **Time-Based Restrictions**: Block certain commands during specific hours +- **Severity Levels**: Classify commands by severity (warn, block, allow) +- **ML-Based Detection**: Learn which commands cause problems and auto-block them + +## Troubleshooting + +### Commands Are Being Blocked Unexpectedly + +**Problem:** A command you want to use is being blocked. + +**Solution:** +1. Check the blocked command list in the result output +2. Identify which pattern is matching your command +3. Edit `forbidden_commands.txt` to remove or modify the pattern +4. Restart GNS3 server + +### Forbidden Commands File Not Found + +**Problem:** The system logs "Forbidden commands file not found. Using default list." + +**Solution:** +1. Verify the file exists at the expected location +2. Check file permissions (should be readable by the GNS3 server process) +3. Ensure the file is not empty + +### Changes Not Taking Effect + +**Problem:** You edited the file but commands are still being blocked. + +**Solution:** +1. Restart the GNS3 server (required to reload the configuration) +2. Or use the `reload_forbidden_commands()` function if available in your context + +## Security Considerations + +### Why These Commands Are Blocked + +| Command | Reason | +|---------|--------| +| `traceroute` | Can run for 30+ seconds, exceeds typical tool timeout | +| `tracepath` | Similar to traceroute, long execution time | +| `tracert` | Windows traceroute, same timeout issues | +| `ping -f` | Flood ping can overwhelm lab devices | +| `debug` | Debug commands can produce overwhelming output and destabilize devices | +| `test` | Test commands may affect device stability | + +### Best Practices + +1. **Education Environment**: Use the default filtering for safety +2. **Personal Lab**: Consider which commands you actually need +3. **Production-like Environment**: Keep restrictions enabled +4. **Always Understand**: Before allowing a command, understand why it was blocked + +## Related Documentation + +- [Tool Implementation](../gns3-copilot/tools_v2/README.md) +- [GNS3-Copilot Documentation](../README.md) +- [Contributing Guide](../../CONTRIBUTING.md) + +## Feedback and Issues + +If you: +- Find commands that should be blocked by default +- Need to allow commands for legitimate use cases +- Have suggestions for improving the filtering system + +Please submit an issue: https://github.com/yueguobin/gns3-copilot/issues diff --git a/docs/gns3-copilot/todo/command-security.md b/docs/gns3-copilot/todo/command-security.md deleted file mode 100644 index 94185e779..000000000 --- a/docs/gns3-copilot/todo/command-security.md +++ /dev/null @@ -1,211 +0,0 @@ -# 命令安全配置 - -## 概述 - -GNS3-Copilot 包含一个命令安全验证系统,用于防止在实验室环境中执行潜在危险或不合适的命令。这有助于: - -- **防止安全告警**:`traceroute` 等命令可能触发安全监控系统 -- **保护实验室设备**:危险命令(泛洪 ping、debug)可能压垮或崩溃设备 -- **保持安全性**:在教学环境中尤其重要 - -## 默认禁止的命令 - -### 全局禁止命令(所有工具) - -| 模式 | 描述 | 原因 | -|------|------|------| -| `traceroute` | 网络路由跟踪 | 可能触发安全告警并产生大量流量 | -| `tracepath` | 网络路由跟踪 | 类似 traceroute | -| `tracert` | Windows traceroute | 类似 traceroute | -| `ping.*-f` | 泛洪 ping | 可能压垮实验室设备 | -| `ping.*\s+count\s+\d{3,}` | 大量 ping | 过量的 ping 流量 | -| `^debug\s+` | 调试命令 | 可能崩溃或 destabilize 设备 | -| `^test\s+` | 测试命令 | 可能影响设备稳定性 | - -### 工具特定限制 - -**诊断工具**(仅允许诊断/show 命令): -- `conf t` / `configure terminal` - 配置模式 -- `end` / `exit` - 配置模式命令 - -**VPCS 工具**: -- `traceroute` - VPCS 路由跟踪 - -## 用户配置 - -### 覆盖默认规则 - -要允许默认禁止的特定命令,创建 `command_security_override.json` 文件: - -```json -{ - "allowed_commands": [ - "traceroute" - ] -} -``` - -### 添加额外的禁止命令 - -要添加自己的限制: - -```json -{ - "forbidden_commands": [ - "my_dangerous_command", - "another_pattern.*" - ] -} -``` - -### 完整示例 - -```json -{ - "allowed_commands": [ - "traceroute" - ], - "forbidden_commands": [ - "show running-config" - ] -} -``` - -## 配置文件位置 - -覆盖文件将在以下位置查找(按顺序): - -1. `/etc/gns3-server/command_security_override.json`(系统级) -2. `<项目目录>/command_security_override.json`(项目级) - -找到的第一个文件将被使用。 - -## 安全级别 - -虽然没有明确的"安全级别",但默认配置设计为适合大多数实验室和教学场景: - -- **教学安全**:默认阻止危险命令 -- **灵活可配置**:用户可以根据需要覆盖 -- **透明可审计**:所有限制都有文档说明 - -## 使用示例 - -### 示例 1:允许 Traceroute - -**文件:`command_security_override.json`** -```json -{ - "allowed_commands": [ - "traceroute" - ] -} -``` - -### 示例 2:阻止特定命令 - -**文件:`command_security_override.json`** -```json -{ - "forbidden_commands": [ - "show running-config" - ] -} -``` - -### 示例 3:允许所有命令(不推荐) - -**文件:`command_security_override.json`** -```json -{ - "allowed_commands": [ - ".*" - ] -} -``` - -**警告**:这将禁用所有安全限制,不推荐。 - -## 实现细节 - -### 配置文件 - -- **默认配置**:`gns3server/agent/gns3_copilot/config/command_security.json` -- **用户覆盖**:`command_security_override.json`(用户创建) - -### 组件 - -1. **配置加载器** (`utils/command_security_config.py`) - - 加载默认和用户配置 - - 合并允许/禁止模式 - - 提供编译后的正则表达式模式 - -2. **命令验证器** (`utils/command_validator.py`) - - 根据安全规则验证命令 - - 返回清晰的错误消息 - - 集成到现有工具中 - -### 集成位置 - -安全验证已集成到: - -- **诊断工具** (`display_tools_nornir.py`) -- **配置工具** (`config_tools_nornir.py`) -- **VPCS 工具** (`vpcs_tools_telnetlib3.py`) - -## 故障排除 - -### 命令被意外阻止 - -如果看到错误消息 `"Command not allowed: traceroute"`: - -1. 检查命令是否在默认禁止列表中 -2. 创建覆盖文件,在 `allowed_commands` 中添加该命令 -3. 重启 GNS3 服务器 - -### 覆盖文件不工作 - -1. 验证文件位置是否正确 -2. 检查 JSON 语法是否有效 -3. 检查服务器日志中的加载错误 - -## 安全考虑 - -### 为什么禁止这些命令? - -- **Traceroute**:生成大量数据包,可能触发 IDS/IPS -- **泛洪 ping**:可能对实验室设备进行 DoS 攻击 -- **调试命令**:可能崩溃或 destabilize 生产设备 -- **诊断工具中的配置命令**:防止意外的配置更改 - -### 最佳实践 - -1. **教学环境**:使用默认配置(安全) -2. **个人实验室**:考虑你真正需要什么 -3. **类似生产环境**:保持限制启用 -4. **始终审查**:在允许命令之前了解为什么它被阻止 - -## 未来增强 - -可能的未来改进(尚未实现): - -- [ ] 每项目配置文件 -- [ ] 通过 GNS3 Web UI 配置 -- [ ] 被阻止命令的审计日志 -- [ ] 可配置的安全预设(strict/standard/loose) -- [ ] 基于时间的限制(例如,上课时间禁止危险命令) - -## 反馈和问题 - -如果你: - -- 发现默认应该阻止的命令 -- 由于合法使用场景需要允许命令 -- 有改进安全的建议 - -请在 GitHub 上提交 issue:https://github.com/yueguobin/gns3-copilot/issues - -## 相关文档 - -- [工具实现](../gns3-copilot/tools_v2/README.md) -- [安全策略](../../SECURITY.md) -- [贡献指南](../../CONTRIBUTING.md) diff --git a/gns3server/agent/gns3_copilot/config/forbidden_commands.txt b/gns3server/agent/gns3_copilot/config/forbidden_commands.txt new file mode 100644 index 000000000..71c3a32f4 --- /dev/null +++ b/gns3server/agent/gns3_copilot/config/forbidden_commands.txt @@ -0,0 +1,64 @@ +# SPDX-License-Identifier: GPL-3.0-or-later +# +# GNS3-Copilot - AI-powered Network Lab Assistant for GNS3 +# +# This file is part of GNS3-Copilot project. +# +# GNS3-Copilot is free software: you can redistribute it and/or modify it +# under the terms of the GNU General Public License as published by the +# Free Software Foundation, either version 3 of the License, or (at your +# option) any later version. +# +# GNS3-Copilot is distributed in the hope that it will be useful, but +# WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY +# or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License +# for more details. +# +# You should have received a copy of the GNU General Public License +# along with GNS3-Copilot. If not, see . +# +# Copyright (C) 2025 Guobin Yue +# Author: Guobin Yue +# +# Project Home: https://github.com/yueguobin/gns3-copilot +# + +# Forbidden Commands List +# ======================= +# +# This file contains commands that are blocked from execution in GNS3-Copilot tools. +# +# Why block these commands? +# ------------------------ +# Commands listed here may: +# - Run longer than the tool timeout (e.g., traceroute) +# - Leave the device console in an unusable state for subsequent commands +# - Generate excessive traffic that could impact lab performance +# +# Format +# ------ +# - One command pattern per line +# - Simple substring matching (case-insensitive) +# - Empty lines and lines starting with # are ignored +# - Match is performed on the beginning of each command +# +# Examples +# -------- +# traceroute # Blocks "traceroute 8.8.8.8", "traceroute google.com", etc. +# debug # Blocks "debug ip routing", "debug ospf events", etc. +# test # Blocks "test ..., "test memory", etc. + +# Network diagnostic commands that may timeout +traceroute +tracepath +tracert + +# Ping flood variants +ping -f +ping -f + +# Debug commands that may destabilize devices +debug + +# Test commands that may affect device stability +test diff --git a/gns3server/agent/gns3_copilot/tools_v2/config_tools_nornir.py b/gns3server/agent/gns3_copilot/tools_v2/config_tools_nornir.py index 637174276..fb4b8cebe 100644 --- a/gns3server/agent/gns3_copilot/tools_v2/config_tools_nornir.py +++ b/gns3server/agent/gns3_copilot/tools_v2/config_tools_nornir.py @@ -45,6 +45,7 @@ from nornir_netmiko.tasks import netmiko_send_config from gns3server.agent.gns3_copilot.gns3_client import get_gns3_server_host from gns3server.agent.gns3_copilot.utils import get_device_ports_from_topology +from gns3server.agent.gns3_copilot.utils.command_filter import filter_forbidden_commands # config log logger = logging.getLogger(__name__) @@ -178,6 +179,11 @@ class ExecuteMultipleDeviceConfigCommands(BaseTool): if isinstance(device_configs_list, list) and len(device_configs_list) > 0 and "error" in device_configs_list[0]: return device_configs_list + # Filter forbidden commands and store blocked commands info + device_configs_list, blocked_commands_map = self._filter_forbidden_commands_from_device_configs( + device_configs_list + ) + # Create a mapping of device names to their configuration commands device_configs_map = self._configs_map(device_configs_list) @@ -205,7 +211,7 @@ class ExecuteMultipleDeviceConfigCommands(BaseTool): ) # Process results for all devices - results = self._process_task_results(device_configs_list, hosts_data, task_result) + results = self._process_task_results(device_configs_list, hosts_data, task_result, blocked_commands_map) except Exception as e: # Overall execution failed @@ -350,6 +356,47 @@ class ExecuteMultipleDeviceConfigCommands(BaseTool): uuid_pattern = r"^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$" return bool(re.match(uuid_pattern, project_id, re.IGNORECASE)) + def _filter_forbidden_commands_from_device_configs( + self, device_configs_list: list[dict[str, Any]] + ) -> tuple[list[dict[str, Any]], dict[str, dict[str, str]]]: + """ + Filter out forbidden commands from device configurations. + + Args: + device_configs_list: List of device configurations with config_commands. + + Returns: + A tuple of (filtered_device_configs_list, blocked_commands_map): + - filtered_device_configs_list: Device configs with forbidden commands removed. + - blocked_commands_map: Dict mapping device names to their blocked commands info. + """ + filtered_list = [] + blocked_commands_map: dict[str, dict[str, str]] = {} + + for device_config in device_configs_list: + device_name = device_config["device_name"] + commands = device_config["config_commands"] + + # Filter commands + allowed_commands, blocked_info = filter_forbidden_commands(commands) + + # Update device config with allowed commands only + filtered_config = device_config.copy() + filtered_config["config_commands"] = allowed_commands + filtered_list.append(filtered_config) + + # Store blocked commands info if any + if blocked_info: + blocked_commands_map[device_name] = blocked_info + logger.info( + "Device %s: %d command(s) blocked: %s", + device_name, + len(blocked_info), + list(blocked_info.keys()), + ) + + return filtered_list, blocked_commands_map + def _configs_map(self, device_config_list: list[dict[str, Any]]) -> dict[str, list[str]]: """Create a mapping of device names to their configuration commands.""" device_configs_map = {} @@ -443,6 +490,7 @@ class ExecuteMultipleDeviceConfigCommands(BaseTool): device_configs_list: list[dict[str, Any]], hosts_data: dict[str, dict[str, Any]], task_result: AggregatedResult, + blocked_commands_map: dict[str, dict[str, str]], ) -> list[dict[str, Any]]: """Process the task results and format them for return.""" results = [] @@ -450,6 +498,7 @@ class ExecuteMultipleDeviceConfigCommands(BaseTool): for device_config in device_configs_list: device_name = device_config["device_name"] config_commands = device_config["config_commands"] + blocked_commands_info = blocked_commands_map.get(device_name, {}) # Check if device is in topology if device_name not in hosts_data: @@ -458,6 +507,10 @@ class ExecuteMultipleDeviceConfigCommands(BaseTool): "status": "failed", "error": (f"Device '{device_name}' not found in topology or missing console_port"), } + # Add blocked commands info if any + if blocked_commands_info: + device_result["blocked_commands"] = list(blocked_commands_info.keys()) + device_result["blocked_info"] = blocked_commands_info results.append(device_result) continue @@ -468,6 +521,10 @@ class ExecuteMultipleDeviceConfigCommands(BaseTool): "status": "failed", "error": (f"Device '{device_name}' not found in task results"), } + # Add blocked commands info if any + if blocked_commands_info: + device_result["blocked_commands"] = list(blocked_commands_info.keys()) + device_result["blocked_info"] = blocked_commands_info results.append(device_result) continue @@ -486,6 +543,14 @@ class ExecuteMultipleDeviceConfigCommands(BaseTool): device_result["output"] = multi_result[0].result device_result["config_commands"] = config_commands + # Add blocked commands info if any + if blocked_commands_info: + device_result["blocked_commands"] = list(blocked_commands_info.keys()) + device_result["blocked_info"] = blocked_commands_info + # Update status if some commands were blocked but execution succeeded + if device_result["status"] == "success": + device_result["status"] = "partial_success" + results.append(device_result) return results diff --git a/gns3server/agent/gns3_copilot/tools_v2/display_tools_nornir.py b/gns3server/agent/gns3_copilot/tools_v2/display_tools_nornir.py index 59cbc362a..42b11469b 100644 --- a/gns3server/agent/gns3_copilot/tools_v2/display_tools_nornir.py +++ b/gns3server/agent/gns3_copilot/tools_v2/display_tools_nornir.py @@ -45,6 +45,7 @@ from nornir_netmiko.tasks import netmiko_multiline from gns3server.agent.gns3_copilot.gns3_client import get_gns3_server_host from gns3server.agent.gns3_copilot.utils import get_device_ports_from_topology +from gns3server.agent.gns3_copilot.utils.command_filter import filter_forbidden_commands # config log logger = logging.getLogger(__name__) @@ -185,6 +186,11 @@ class ExecuteMultipleDeviceCommands(BaseTool): if isinstance(device_configs_list, list) and len(device_configs_list) > 0 and "error" in device_configs_list[0]: return device_configs_list + # Filter forbidden commands and store blocked commands info + device_configs_list, blocked_commands_map = self._filter_forbidden_commands_from_device_configs( + device_configs_list + ) + # Create a mapping of device names to their display commands device_configs_map = self._configs_map(device_configs_list) @@ -212,7 +218,7 @@ class ExecuteMultipleDeviceCommands(BaseTool): ) # Process results for all devices - results = self._process_task_results(device_configs_list, hosts_data, task_result) + results = self._process_task_results(device_configs_list, hosts_data, task_result, blocked_commands_map) except Exception as e: # Overall execution failed @@ -369,6 +375,47 @@ class ExecuteMultipleDeviceCommands(BaseTool): uuid_pattern = r"^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$" return bool(re.match(uuid_pattern, project_id, re.IGNORECASE)) + def _filter_forbidden_commands_from_device_configs( + self, device_configs_list: list[dict[str, Any]] + ) -> tuple[list[dict[str, Any]], dict[str, dict[str, str]]]: + """ + Filter out forbidden commands from device configurations. + + Args: + device_configs_list: List of device configurations with commands. + + Returns: + A tuple of (filtered_device_configs_list, blocked_commands_map): + - filtered_device_configs_list: Device configs with forbidden commands removed. + - blocked_commands_map: Dict mapping device names to their blocked commands info. + """ + filtered_list = [] + blocked_commands_map: dict[str, dict[str, str]] = {} + + for device_config in device_configs_list: + device_name = device_config["device_name"] + commands = device_config["commands"] + + # Filter commands + allowed_commands, blocked_info = filter_forbidden_commands(commands) + + # Update device config with allowed commands only + filtered_config = device_config.copy() + filtered_config["commands"] = allowed_commands + filtered_list.append(filtered_config) + + # Store blocked commands info if any + if blocked_info: + blocked_commands_map[device_name] = blocked_info + logger.info( + "Device %s: %d command(s) blocked: %s", + device_name, + len(blocked_info), + list(blocked_info.keys()), + ) + + return filtered_list, blocked_commands_map + def _configs_map(self, device_config_list: list[dict[str, Any]]) -> dict[str, list[str]]: """Create a mapping of device names to their diagnostic commands.""" device_diagnostic_map = {} @@ -462,6 +509,7 @@ class ExecuteMultipleDeviceCommands(BaseTool): device_configs_list: list[dict[str, Any]], hosts_data: dict[str, dict[str, Any]], task_result: AggregatedResult, + blocked_commands_map: dict[str, dict[str, str]], ) -> list[dict[str, Any]]: """Process the task results and format them for return.""" results = [] @@ -469,6 +517,7 @@ class ExecuteMultipleDeviceCommands(BaseTool): for device_config in device_configs_list: device_name = device_config["device_name"] diagnostic_commands = device_config["commands"] + blocked_commands_info = blocked_commands_map.get(device_name, {}) # Check if device is in topology if device_name not in hosts_data: @@ -477,6 +526,10 @@ class ExecuteMultipleDeviceCommands(BaseTool): "status": "failed", "error": (f"Device '{device_name}' not found in topology or missing console_port"), } + # Add blocked commands info if any + if blocked_commands_info: + device_result["blocked_commands"] = list(blocked_commands_info.keys()) + device_result["blocked_info"] = blocked_commands_info results.append(device_result) continue @@ -487,6 +540,10 @@ class ExecuteMultipleDeviceCommands(BaseTool): "status": "failed", "error": (f"Device '{device_name}' not found in task results"), } + # Add blocked commands info if any + if blocked_commands_info: + device_result["blocked_commands"] = list(blocked_commands_info.keys()) + device_result["blocked_info"] = blocked_commands_info results.append(device_result) continue @@ -505,6 +562,14 @@ class ExecuteMultipleDeviceCommands(BaseTool): device_result["output"] = multi_result[0].result device_result["diagnostic_commands"] = diagnostic_commands + # Add blocked commands info if any + if blocked_commands_info: + device_result["blocked_commands"] = list(blocked_commands_info.keys()) + device_result["blocked_info"] = blocked_commands_info + # Update status if some commands were blocked but execution succeeded + if device_result["status"] == "success": + device_result["status"] = "partial_success" + results.append(device_result) return results diff --git a/gns3server/agent/gns3_copilot/utils/command_filter.py b/gns3server/agent/gns3_copilot/utils/command_filter.py new file mode 100644 index 000000000..92dce1997 --- /dev/null +++ b/gns3server/agent/gns3_copilot/utils/command_filter.py @@ -0,0 +1,217 @@ +# SPDX-License-Identifier: GPL-3.0-or-later +# +# GNS3-Copilot - AI-powered Network Lab Assistant for GNS3 +# +# This file is part of GNS3-Copilot project. +# +# GNS3-Copilot is free software: you can redistribute it and/or modify it +# under the terms of the GNU General Public License as published by the +# Free Software Foundation, either version 3 of the License, or (at your +# option) any later version. +# +# GNS3-Copilot is distributed in the hope that it will be useful, but +# WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY +# or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License +# for more details. +# +# You should have received a copy of the GNU General Public License +# along with GNS3-Copilot. If not, see . +# +# Copyright (C) 2025 Guobin Yue +# Author: Guobin Yue +# +# Project Home: https://github.com/yueguobin/gns3-copilot +# + +""" +Command filter module for GNS3-Copilot. + +This module provides functionality to filter out dangerous or long-running +commands that may cause issues with tool execution timeouts or device console +availability. +""" + +import logging +import os +from pathlib import Path + +logger = logging.getLogger(__name__) + + +def _get_gns3_copilot_root() -> Path: + """Get the root directory of the GNS3-Copilot project.""" + # Get the directory containing this file + current_file = Path(__file__).resolve() + # Go up to the gns3_copilot directory (utils parent) + return current_file.parent.parent + +# Default forbidden commands (fallback if file not found) +DEFAULT_FORBIDDEN_COMMANDS = [ + "traceroute", + "tracepath", + "tracert", + "ping -f", + "debug", + "test", +] + +# Cache for forbidden commands to avoid repeated file reads +_forbidden_commands_cache: list[str] | None = None + + +def _get_forbidden_commands_file_path() -> Path: + """Get the path to the forbidden commands configuration file.""" + return _get_gns3_copilot_root() / "config" / "forbidden_commands.txt" + + +def _load_forbidden_commands() -> list[str]: + """ + Load forbidden commands from the configuration file. + + Returns: + List of forbidden command patterns. If the file cannot be read, + returns the default list. + """ + global _forbidden_commands_cache + + # Return cached value if available + if _forbidden_commands_cache is not None: + return _forbidden_commands_cache + + file_path = _get_forbidden_commands_file_path() + + try: + if not file_path.exists(): + logger.warning( + "Forbidden commands file not found: %s. Using default list.", + file_path, + ) + _forbidden_commands_cache = DEFAULT_FORBIDDEN_COMMANDS.copy() + return _forbidden_commands_cache + + with open(file_path, "r", encoding="utf-8") as f: + forbidden_commands = [] + for line in f: + line = line.strip() + + # Skip empty lines and comments + if not line or line.startswith("#"): + continue + + forbidden_commands.append(line.lower()) + + if not forbidden_commands: + logger.warning("No forbidden commands found in %s. Using default list.", file_path) + _forbidden_commands_cache = DEFAULT_FORBIDDEN_COMMANDS.copy() + else: + logger.info( + "Loaded %d forbidden command patterns from %s", + len(forbidden_commands), + file_path, + ) + _forbidden_commands_cache = forbidden_commands + + return _forbidden_commands_cache + + except Exception as e: + logger.error( + "Error reading forbidden commands file %s: %s. Using default list.", + file_path, + e, + ) + _forbidden_commands_cache = DEFAULT_FORBIDDEN_COMMANDS.copy() + return _forbidden_commands_cache + + +def reload_forbidden_commands() -> None: + """ + Reload the forbidden commands list from the configuration file. + + This clears the cache and forces a reload from the file on the next + call to filter_forbidden_commands() or get_forbidden_commands(). + + Use this after modifying the forbidden_commands.txt file to apply + changes without restarting the GNS3 server. + """ + global _forbidden_commands_cache + _forbidden_commands_cache = None + logger.info("Forbidden commands cache cleared. Will reload on next access.") + + +def get_forbidden_commands() -> list[str]: + """ + Get the current list of forbidden command patterns. + + Returns: + List of forbidden command patterns (lowercase). + """ + return _load_forbidden_commands() + + +def is_command_forbidden(command: str) -> bool: + """ + Check if a single command is forbidden. + + Args: + command: The command string to check. + + Returns: + True if the command matches a forbidden pattern, False otherwise. + """ + forbidden_commands = _load_forbidden_commands() + command_lower = command.strip().lower() + + for forbidden_pattern in forbidden_commands: + if command_lower.startswith(forbidden_pattern): + return True + + return False + + +def filter_forbidden_commands( + commands: list[str], +) -> tuple[list[str], dict[str, str]]: + """ + Filter out forbidden commands from a list of commands. + + Args: + commands: List of command strings to filter. + + Returns: + A tuple of (allowed_commands, blocked_commands_info): + - allowed_commands: List of commands that are not forbidden. + - blocked_commands_info: Dict mapping blocked commands to their reasons. + """ + allowed_commands: list[str] = [] + blocked_commands_info: dict[str, str] = {} + + for command in commands: + if is_command_forbidden(command): + # Find which pattern matched + forbidden_commands = _load_forbidden_commands() + command_lower = command.strip().lower() + matched_pattern = None + + for pattern in forbidden_commands: + if command_lower.startswith(pattern): + matched_pattern = pattern + break + + reason = ( + f"Command '{command}' is not allowed because it matches the " + f"forbidden pattern '{matched_pattern}'. " + f"This command may run longer than the tool timeout or leave " + f"the device console unavailable for subsequent commands." + ) + blocked_commands_info[command] = reason + else: + allowed_commands.append(command) + + if blocked_commands_info: + logger.info( + "Filtered %d command(s): %s", + len(blocked_commands_info), + list(blocked_commands_info.keys()), + ) + + return allowed_commands, blocked_commands_info