diff --git a/.agents/plugins/marketplace.json b/.agents/plugins/marketplace.json index eb479ab64..88cdac057 100644 --- a/.agents/plugins/marketplace.json +++ b/.agents/plugins/marketplace.json @@ -87,6 +87,18 @@ "authentication": "ON_INSTALL" }, "category": "设计" + }, + { + "name": "postgres", + "source": { + "source": "local", + "path": "./plugins/codex/plugins/postgres" + }, + "policy": { + "installation": "AVAILABLE", + "authentication": "ON_INSTALL" + }, + "category": "数据分析" } ] } diff --git a/plugins/codex/.agents/plugins/marketplace.json b/plugins/codex/.agents/plugins/marketplace.json index 7e4203e9e..fda6db9de 100644 --- a/plugins/codex/.agents/plugins/marketplace.json +++ b/plugins/codex/.agents/plugins/marketplace.json @@ -87,6 +87,18 @@ "authentication": "ON_INSTALL" }, "category": "设计" + }, + { + "name": "postgres", + "source": { + "source": "local", + "path": "./plugins/postgres" + }, + "policy": { + "installation": "AVAILABLE", + "authentication": "ON_INSTALL" + }, + "category": "数据分析" } ] } diff --git a/plugins/codex/plugins/postgres/.codex-plugin/plugin.json b/plugins/codex/plugins/postgres/.codex-plugin/plugin.json new file mode 100644 index 000000000..f460c218e --- /dev/null +++ b/plugins/codex/plugins/postgres/.codex-plugin/plugin.json @@ -0,0 +1,37 @@ +{ + "name": "postgres", + "version": "1.0.0", + "description": "针对多个PostgreSQL数据库执行 只读 SQL查询。适用于以下场景:(1) 查询PostgreSQL数据库中的数据;(2) 查看数据库架构及表结构;(3) 运行SELECT语句进行数据分析;(4) 检查数据库内容。该工具支持同时连接多个数据库,并提供相应的描述信息以便用户智能选择目标数据库。为确保数据安全,该工具会阻止所有写操作(如INSERT、UPDATE、DELETE、DROP等)。", + "author": { + "name": "EAPIL", + "url": "https://git.playones.com/arechen/EapilSkillMarket" + }, + "homepage": "https://git.playones.com/arechen/EapilSkillMarket", + "repository": "https://git.playones.com/arechen/EapilSkillMarket", + "license": "Proprietary", + "keywords": [ + "eapil", + "codex-skill", + "postgres" + ], + "skills": "./skills/", + "interface": { + "displayName": "postgres", + "shortDescription": "针对多个PostgreSQL数据库执行 只读 SQL查询。适用于以下场景:(1) 查询PostgreSQL数据库中的数据;(2) 查看数据库架构及表结构;(3) 运行SELECT语句进行数据分析;(4) 检查数据库内容。该工具支持同时连接多个数据库,并提供相应的描述信息以便用户智能选择目标数据库。为确保数据安全,该工具会阻止所有写操作(如INSERT、UPDATE、DELETE、DROP等)。", + "longDescription": "针对多个PostgreSQL数据库执行 只读 SQL查询。适用于以下场景:(1) 查询PostgreSQL数据库中的数据;(2) 查看数据库架构及表结构;(3) 运行SELECT语句进行数据分析;(4) 检查数据库内容。该工具支持同时连接多个数据库,并提供相应的描述信息以便用户智能选择目标数据库。为确保数据安全,该工具会阻止所有写操作(如INSERT、UPDATE、DELETE、DROP等)。", + "developerName": "EAPIL", + "category": "数据分析", + "capabilities": [ + "Read", + "Write" + ], + "defaultPrompt": [ + "使用 postgres 帮我完成这个任务。" + ], + "websiteURL": "https://git.playones.com/arechen/EapilSkillMarket", + "privacyPolicyURL": "https://git.playones.com/arechen/EapilSkillMarket", + "termsOfServiceURL": "https://git.playones.com/arechen/EapilSkillMarket", + "brandColor": "#2563EB", + "screenshots": [] + } +} diff --git a/plugins/codex/plugins/postgres/skills/postgres/.gitignore b/plugins/codex/plugins/postgres/skills/postgres/.gitignore new file mode 100644 index 000000000..d99e056f4 --- /dev/null +++ b/plugins/codex/plugins/postgres/skills/postgres/.gitignore @@ -0,0 +1,29 @@ +# Credentials - NEVER commit actual connection configs +connections.json + +# Python +__pycache__/ +*.py[cod] +*$py.class +*.so +.Python +*.egg +*.egg-info/ +dist/ +build/ + +# Virtual environments +.venv/ +venv/ +ENV/ + +# Testing +.pytest_cache/ +.coverage +htmlcov/ + +# IDE +.idea/ +.vscode/ +*.swp +*.swo diff --git a/plugins/codex/plugins/postgres/skills/postgres/README.md b/plugins/codex/plugins/postgres/skills/postgres/README.md new file mode 100644 index 000000000..3b8dacc8a --- /dev/null +++ b/plugins/codex/plugins/postgres/skills/postgres/README.md @@ -0,0 +1,77 @@ +# postgres + +Read-only PostgreSQL query skill. Query multiple databases safely with write protection. + +## Setup + +1. Copy the example config: +```bash +cp connections.example.json connections.json +``` + +2. Add your database credentials: +```json +{ + "databases": [ + { + "name": "prod", + "description": "Production - users, orders, transactions", + "host": "db.example.com", + "port": 5432, + "database": "app_prod", + "user": "readonly", + "password": "secret", + "sslmode": "require" + } + ] +} +``` + +3. Secure the config: +```bash +chmod 600 connections.json +``` + +## Usage + +```bash +# List configured databases +python3 scripts/query.py --list + +# List tables +python3 scripts/query.py --db prod --tables + +# Show schema +python3 scripts/query.py --db prod --schema + +# Run query +python3 scripts/query.py --db prod --query "SELECT * FROM users" --limit 100 +``` + +## Config Fields + +| Field | Required | Default | Description | +|-------|----------|---------|-------------| +| name | Yes | - | Database identifier | +| description | Yes | - | What data it contains (for auto-selection) | +| host | Yes | - | Hostname | +| port | No | 5432 | Port | +| database | Yes | - | Database name | +| user | Yes | - | Username | +| password | Yes | - | Password | +| sslmode | No | prefer | disable, allow, prefer, require, verify-ca, verify-full | + +## Safety Features + +- **Read-only sessions**: PostgreSQL `readonly=True` mode blocks writes at database level +- **Query validation**: Only SELECT, SHOW, EXPLAIN, WITH allowed +- **Single statement**: No multi-statement queries (prevents `SELECT 1; DROP TABLE`) +- **Timeouts**: 30s query timeout, 10s connection timeout +- **Memory cap**: Max 10,000 rows per query +- **Credential protection**: Passwords sanitized from error messages + +## Requirements + +```bash +pip install psycopg2-binary +``` diff --git a/plugins/codex/plugins/postgres/skills/postgres/SKILL.md b/plugins/codex/plugins/postgres/skills/postgres/SKILL.md new file mode 100644 index 000000000..3355580e9 --- /dev/null +++ b/plugins/codex/plugins/postgres/skills/postgres/SKILL.md @@ -0,0 +1,125 @@ +--- +name: postgres +description: "Execute read-only SQL queries against multiple PostgreSQL databases. Use when: (1) querying PostgreSQL databases, (2) exploring database schemas/tables, (3) running SELECT queries for data analysis, (4) checking database contents. Supports multiple database connections with descriptions for intelligent auto-selection. Blocks all write operations (INSERT, UPDATE, DELETE, DROP, etc.) for safety." +--- + +# PostgreSQL Read-Only Query Skill + +Execute safe, read-only queries against configured PostgreSQL databases. + +## Requirements + +- Python 3.8+ +- psycopg2-binary: `pip install -r requirements.txt` + +## Setup + +Create `connections.json` in the skill directory or `~/.config/claude/postgres-connections.json`. + +**Security**: Set file permissions to `600` since it contains credentials: +```bash +chmod 600 connections.json +``` + +```json +{ + "databases": [ + { + "name": "production", + "description": "Main app database - users, orders, transactions", + "host": "db.example.com", + "port": 5432, + "database": "app_prod", + "user": "readonly_user", + "password": "your-password", + "sslmode": "require" + } + ] +} +``` + +### Config Fields + +| Field | Required | Description | +|-------|----------|-------------| +| name | Yes | Identifier for the database (case-insensitive) | +| description | Yes | What data this database contains (used for auto-selection) | +| host | Yes | Database hostname | +| port | No | Port number (default: 5432) | +| database | Yes | Database name | +| user | Yes | Username | +| password | Yes | Password | +| sslmode | No | SSL mode: disable, allow, prefer (default), require, verify-ca, verify-full | + +## Usage + +### List configured databases +```bash +python3 scripts/query.py --list +``` + +### Query a database +```bash +python3 scripts/query.py --db production --query "SELECT * FROM users LIMIT 10" +``` + +### List tables +```bash +python3 scripts/query.py --db production --tables +``` + +### Show schema +```bash +python3 scripts/query.py --db production --schema +``` + +### Limit results +```bash +python3 scripts/query.py --db production --query "SELECT * FROM orders" --limit 100 +``` + +## Database Selection + +Match user intent to database `description`: + +| User asks about | Look for description containing | +|-----------------|--------------------------------| +| users, accounts | users, accounts, customers | +| orders, sales | orders, transactions, sales | +| analytics, metrics | analytics, metrics, reports | +| logs, events | logs, events, audit | + +If unclear, run `--list` and ask user which database. + +## Safety Features + +- **Read-only session**: Connection uses PostgreSQL `readonly=True` mode (primary protection) +- **Query validation**: Only SELECT, SHOW, EXPLAIN, WITH queries allowed +- **Single statement**: Multiple statements per query rejected +- **SSL support**: Configurable SSL mode for encrypted connections +- **Query timeout**: 30-second statement timeout enforced +- **Memory protection**: Max 10,000 rows per query to prevent OOM +- **Column width cap**: 100 char max per column for readable output +- **Credential sanitization**: Error messages don't leak passwords + +## Troubleshooting + +| Error | Solution | +|-------|----------| +| Config not found | Create `connections.json` in skill directory | +| Authentication failed | Check username/password in config | +| Connection timeout | Verify host/port, check firewall/VPN | +| SSL error | Try `"sslmode": "disable"` for local databases | +| Permission warning | Run `chmod 600 connections.json` | + +## Exit Codes + +- **0**: Success +- **1**: Error (config missing, auth failed, invalid query, database error) + +## Workflow + +1. Run `--list` to show available databases +2. Match user intent to database description +3. Run `--tables` or `--schema` to explore structure +4. Execute query with appropriate LIMIT diff --git a/plugins/codex/plugins/postgres/skills/postgres/connections.example.json b/plugins/codex/plugins/postgres/skills/postgres/connections.example.json new file mode 100644 index 000000000..b41c56801 --- /dev/null +++ b/plugins/codex/plugins/postgres/skills/postgres/connections.example.json @@ -0,0 +1,34 @@ +{ + "databases": [ + { + "name": "production", + "description": "Main production database - users, orders, transactions, accounts", + "host": "prod-db.example.com", + "port": 5432, + "database": "app_prod", + "user": "readonly_user", + "password": "your-password-here", + "sslmode": "require" + }, + { + "name": "analytics", + "description": "Analytics warehouse - aggregated metrics, reports, historical data", + "host": "analytics-db.example.com", + "port": 5432, + "database": "analytics", + "user": "analyst", + "password": "your-password-here", + "sslmode": "require" + }, + { + "name": "staging", + "description": "Staging environment - mirrors production for testing", + "host": "localhost", + "port": 5432, + "database": "app_staging", + "user": "dev", + "password": "dev-password", + "sslmode": "prefer" + } + ] +} diff --git a/plugins/codex/plugins/postgres/skills/postgres/requirements.txt b/plugins/codex/plugins/postgres/skills/postgres/requirements.txt new file mode 100644 index 000000000..cbeb94125 --- /dev/null +++ b/plugins/codex/plugins/postgres/skills/postgres/requirements.txt @@ -0,0 +1 @@ +psycopg2-binary>=2.9,<3.0 diff --git a/plugins/codex/plugins/postgres/skills/postgres/scripts/query.py b/plugins/codex/plugins/postgres/skills/postgres/scripts/query.py new file mode 100644 index 000000000..534618581 --- /dev/null +++ b/plugins/codex/plugins/postgres/skills/postgres/scripts/query.py @@ -0,0 +1,262 @@ +#!/usr/bin/env python3 +""" +Read-only PostgreSQL query executor. +Connects to configured databases and executes SELECT queries only. +""" + +import json +import os +import re +import stat +import sys +import argparse +from pathlib import Path +from typing import Optional + +try: + import psycopg2 +except ImportError: + print("Error: psycopg2 not installed. Run: pip install psycopg2-binary") + sys.exit(1) + +# Constants +SCRIPT_DIR = Path(__file__).parent.parent +CONFIG_LOCATIONS = [ + SCRIPT_DIR / "connections.json", + Path.home() / ".config" / "claude" / "postgres-connections.json", +] +MAX_ROWS = 10000 +MAX_COLUMN_WIDTH = 100 +QUERY_TIMEOUT_MS = 30000 +CONNECTION_TIMEOUT_SEC = 10 +NULL_DISPLAY = "" + + +def is_read_only(query: str) -> bool: + """Basic client-side check. Primary protection is readonly=True session.""" + query_upper = query.upper().strip() + safe_starts = ('SELECT', 'SHOW', 'DESCRIBE', 'EXPLAIN', 'WITH', '\\D') + return any(query_upper.startswith(cmd) for cmd in safe_starts) + + +def validate_single_statement(query: str) -> bool: + """Check query contains only one statement.""" + # Remove trailing semicolon and whitespace, then check for remaining semicolons + clean = query.rstrip().rstrip(';') + return ';' not in clean + + +def validate_config_permissions(path: Path) -> None: + """Warn if config file has insecure permissions (Unix only).""" + if os.name != 'nt': # Skip on Windows + mode = path.stat().st_mode + if bool(mode & stat.S_IRWXG) or bool(mode & stat.S_IRWXO): + print(f"WARNING: {path} has insecure permissions!") + print(f"Config contains credentials. Run: chmod 600 {path}") + + +def validate_db_config(db: dict) -> None: + """Validate required fields exist in database config.""" + required = ['name', 'host', 'database', 'user', 'password'] + missing = [f for f in required if f not in db] + if missing: + print(f"Error: Database config missing fields: {', '.join(missing)}") + sys.exit(1) + + +def find_config() -> Optional[Path]: + """Find config file in supported locations.""" + for path in CONFIG_LOCATIONS: + if path.exists(): + return path + return None + + +def load_config(config_path: Optional[Path] = None) -> dict: + """Load database connections from JSON config.""" + path = config_path or find_config() + if not path: + print("Config not found. Searched:") + for loc in CONFIG_LOCATIONS: + print(f" - {loc}") + print("\nCreate connections.json with format:") + print(json.dumps({ + "databases": [{ + "name": "mydb", + "description": "Description of database contents", + "host": "localhost", + "port": 5432, + "database": "mydb", + "user": "user", + "password": "password", + "sslmode": "prefer" + }] + }, indent=2)) + sys.exit(1) + + validate_config_permissions(path) + + with open(path) as f: + return json.load(f) + + +def list_databases(config: dict) -> None: + """List all configured databases.""" + print("Configured databases:\n") + for db in config.get("databases", []): + validate_db_config(db) + print(f" [{db['name']}]") + print(f" Host: {db['host']}:{db.get('port', 5432)}") + print(f" Database: {db['database']}") + print(f" Description: {db.get('description', 'No description')}") + print() + + +def execute_query(db_config: dict, query: str, limit: Optional[int] = None) -> None: + """Execute a read-only query against the specified database.""" + if not is_read_only(query): + print("Error: Only read-only queries (SELECT, SHOW, EXPLAIN) are allowed.") + sys.exit(1) + + if not validate_single_statement(query): + print("Error: Multiple statements not allowed. Execute queries separately.") + sys.exit(1) + + # Apply limit using regex to avoid false positives from string content + if limit and not re.search(r'\bLIMIT\s+\d+', query, re.IGNORECASE): + query = f"{query.rstrip(';')} LIMIT {limit}" + + conn = None + try: + conn = psycopg2.connect( + host=db_config['host'], + port=db_config.get('port', 5432), + database=db_config['database'], + user=db_config['user'], + password=db_config['password'], + sslmode=db_config.get('sslmode', 'prefer'), + connect_timeout=CONNECTION_TIMEOUT_SEC, + options=f'-c statement_timeout={QUERY_TIMEOUT_MS}' + ) + # Primary safety: readonly session prevents any write operations + conn.set_session(readonly=True, autocommit=True) + + with conn.cursor() as cur: + cur.execute(query) + if cur.description: + columns = [desc[0] for desc in cur.description] + rows = cur.fetchmany(MAX_ROWS) + truncated = len(rows) == MAX_ROWS + + # Calculate column widths with cap + widths = [min(len(col), MAX_COLUMN_WIDTH) for col in columns] + for row in rows: + for i, val in enumerate(row): + val_str = str(val) if val is not None else NULL_DISPLAY + widths[i] = min(max(widths[i], len(val_str)), MAX_COLUMN_WIDTH) + + # Print header + header = " | ".join(col[:MAX_COLUMN_WIDTH].ljust(widths[i]) for i, col in enumerate(columns)) + print(header) + print("-" * len(header)) + + # Print rows + for row in rows: + cells = [] + for i, val in enumerate(row): + val_str = str(val) if val is not None else NULL_DISPLAY + if len(val_str) > MAX_COLUMN_WIDTH: + val_str = val_str[:MAX_COLUMN_WIDTH-3] + "..." + cells.append(val_str.ljust(widths[i])) + print(" | ".join(cells)) + + msg = f"\n({len(rows)} rows)" + if truncated: + msg += f" [truncated at {MAX_ROWS}]" + print(msg) + else: + print("Query executed (no result set returned)") + + except psycopg2.Error as e: + error_msg = str(e) + # Sanitize to avoid leaking credentials + if 'password' in error_msg.lower() or 'authentication' in error_msg.lower(): + error_msg = "Authentication failed. Check credentials in connections.json" + print(f"Database error: {error_msg}") + sys.exit(1) + finally: + if conn: + conn.close() + + +def find_database(config: dict, name: str) -> dict: + """Find database config by name (case-insensitive).""" + for db in config.get("databases", []): + if db.get('name', '').lower() == name.lower(): + validate_db_config(db) + return db + available = [db.get('name', 'unnamed') for db in config.get("databases", [])] + print(f"Database '{name}' not found.") + print(f"Available: {', '.join(available)}") + sys.exit(1) + + +def main() -> None: + """Main entry point.""" + parser = argparse.ArgumentParser( + description="Execute read-only PostgreSQL queries", + formatter_class=argparse.RawDescriptionHelpFormatter, + epilog=""" +Examples: + %(prog)s --list + %(prog)s --db mydb --tables + %(prog)s --db mydb --query "SELECT * FROM users" --limit 100 + """ + ) + parser.add_argument("--config", "-c", type=Path, help="Path to config JSON") + parser.add_argument("--db", "-d", help="Database name to query") + parser.add_argument("--query", "-q", help="SQL query to execute") + parser.add_argument("--limit", "-l", type=int, help="Limit rows returned") + parser.add_argument("--list", action="store_true", help="List configured databases") + parser.add_argument("--schema", "-s", action="store_true", help="Show database schema") + parser.add_argument("--tables", "-t", action="store_true", help="List tables") + + args = parser.parse_args() + config = load_config(args.config) + + if args.list: + list_databases(config) + return + + if not args.db: + print("Error: --db required. Use --list to see available databases.") + sys.exit(1) + + db_config = find_database(config, args.db) + + if args.tables: + query = """ + SELECT table_schema, table_name, table_type + FROM information_schema.tables + WHERE table_schema NOT IN ('pg_catalog', 'information_schema') + ORDER BY table_schema, table_name + """ + execute_query(db_config, query, args.limit) + elif args.schema: + query = """ + SELECT c.table_schema, c.table_name, c.column_name, c.data_type, c.is_nullable + FROM information_schema.columns c + JOIN information_schema.tables t ON c.table_name = t.table_name AND c.table_schema = t.table_schema + WHERE c.table_schema NOT IN ('pg_catalog', 'information_schema') + ORDER BY c.table_schema, c.table_name, c.ordinal_position + """ + execute_query(db_config, query, args.limit) + elif args.query: + execute_query(db_config, args.query, args.limit) + else: + print("Error: --query, --tables, or --schema required") + sys.exit(1) + + +if __name__ == "__main__": + main()