Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,7 @@ The MVP MUST support:
- PostgreSQL
- MySQL (primary target)
- SQLite
- DuckDB

All engines are first-class and equally constrained.

Expand Down
4 changes: 3 additions & 1 deletion Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,7 @@ postgres-native-tls = { version = "0.5", optional = true } # TLS for tokio-post
native-tls = { version = "0.2", optional = true, features = ["vendored"] } # Platform TLS (OpenSSL/Secure Transport/SChannel)
mysql_async = { version = "0.34", default-features = false, features = ["default-rustls"], optional = true } # MySQL native async driver
rusqlite = { version = "0.32", features = ["bundled"], optional = true } # SQLite native driver with bundled lib
duckdb = { version = "1.10504.0", features = ["bundled"], optional = true } # DuckDB native driver with bundled lib

# BLOB encoding (Base64) - used by SQLite and PostgreSQL
base64 = "0.22"
Expand All @@ -74,7 +75,8 @@ default = ["all-engines"] # Enable all database engines by default
postgres = ["dep:tokio-postgres", "dep:chrono", "dep:uuid", "dep:postgres-native-tls", "dep:native-tls"]
mysql = ["dep:mysql_async"]
sqlite = ["dep:rusqlite"]
all-engines = ["postgres", "mysql", "sqlite"]
duckdb = ["dep:duckdb", "dep:chrono"]
all-engines = ["postgres", "mysql", "sqlite", "duckdb"]

[dev-dependencies]
# Testing utilities
Expand Down
22 changes: 13 additions & 9 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,10 +19,10 @@ Plenum is exposed via a local MCP (Model Context Protocol) server, making it sea
## Key Features

- **Agent-First Design**: JSON-only output, no interactive UX, deterministic behavior
- **Vendor-Specific SQL**: No query abstraction layer - PostgreSQL SQL ≠ MySQL SQL ≠ SQLite SQL
- **Vendor-Specific SQL**: No query abstraction layer - PostgreSQL SQL ≠ MySQL SQL ≠ SQLite SQL ≠ DuckDB SQL
- **Strictly Read-Only**: All write and DDL operations are rejected - guaranteed safe for AI agents
- **Stateless Execution**: No persistent connections, no caching, no implicit state
- **Three Database Engines**: PostgreSQL, MySQL, and SQLite support (first-class, equally constrained)
- **Four Database Engines**: PostgreSQL, MySQL, SQLite, and DuckDB support (first-class, equally constrained)

## Installation

Expand Down Expand Up @@ -55,7 +55,7 @@ Manage database connection configurations (interactive or non-interactive).
| `--list` | — | List saved connections for the project as JSON (no secrets emitted) |
| `--name <NAME>` | `"default"` | Connection name |
| `--project-path <PATH>` | current directory | Project path for connection lookup |
| `--engine <ENGINE>` | — | Database engine: `postgres`, `mysql`, or `sqlite` |
| `--engine <ENGINE>` | — | Database engine: `postgres`, `mysql`, `sqlite`, or `duckdb` |
| `--host <HOST>` | — | Hostname (postgres/mysql) |
| `--port <PORT>` | — | Port (postgres/mysql) |
| `--user <USER>` | — | Username (postgres/mysql) |
Expand All @@ -65,7 +65,7 @@ Manage database connection configurations (interactive or non-interactive).
| `--keychain-service <SVC>` | — | OS keychain service name (pair with `--keychain-account`) |
| `--keychain-account <ACCT>` | — | OS keychain account name (pair with `--keychain-service`) |
| `--database <DATABASE>` | — | Database name (postgres/mysql) |
| `--file <FILE>` | — | SQLite file path |
| `--file <FILE>` | — | SQLite/DuckDB file path |
| `--save <LOCATION>` | — | Save location: `local` (`.plenum/config.json`) or `global` (`~/.config/plenum/connections.json`) |
| `--ssl-mode <MODE>` | — | TLS/SSL mode: `disable`, `require`, `verify-ca`, or `verify-full` (postgres/mysql) |
| `--ssl-ca <PATH>` | — | PEM CA certificate for TLS verification (required for `verify-ca`/`verify-full`) |
Expand Down Expand Up @@ -102,6 +102,9 @@ plenum connect --name vault --engine postgres --host localhost \
# Save a SQLite connection
plenum connect --name dev --engine sqlite --file ./dev.db --save local

# Save a DuckDB connection
plenum connect --name analytics --engine duckdb --file ./analytics.duckdb --save local

# Test a connection without saving
plenum connect --engine postgres --host localhost --user dev \
--password-env DEV_DB_PASSWORD --database mydb --test
Expand Down Expand Up @@ -151,16 +154,16 @@ Inspect database schema and return structured JSON.

| Flag | Default | Description |
|------|---------|-------------|
| `--dsn <DSN>` | — | One-off connection URL (mutually exclusive with `--name` and explicit flags). Accepted schemes: `postgres://`, `postgresql://`, `mysql://`, `sqlite:` |
| `--dsn <DSN>` | — | One-off connection URL (mutually exclusive with `--name` and explicit flags). Accepted schemes: `postgres://`, `postgresql://`, `mysql://`, `sqlite:`, `duckdb:` |
| `--name <NAME>` | `"default"` | Named connection from the saved registry |
| `--project-path <PATH>` | current directory | Project path for connection lookup |
| `--engine <ENGINE>` | — | Engine override: `postgres`, `mysql`, or `sqlite` |
| `--engine <ENGINE>` | — | Engine override: `postgres`, `mysql`, `sqlite`, or `duckdb` |
| `--host <HOST>` | — | Host override |
| `--port <PORT>` | — | Port override |
| `--user <USER>` | — | Username override |
| `--password <PASSWORD>` | — | Password override |
| `--database <DATABASE>` | — | Database override |
| `--file <FILE>` | — | SQLite file override |
| `--file <FILE>` | — | SQLite/DuckDB file override |
| `--ssl-mode <MODE>` | — | TLS/SSL mode: `disable`, `require`, `verify-ca`, or `verify-full` (postgres/mysql) |
| `--ssl-ca <PATH>` | — | PEM CA certificate (required for `verify-ca`/`verify-full`) |
| `--ssl-cert <PATH>` | — | PEM client certificate for mTLS (pair with `--ssl-key`) |
Expand All @@ -171,14 +174,14 @@ Inspect database schema and return structured JSON.
| Flag | Default | Description |
|------|---------|-------------|
| `--list-databases` | — | List all databases (requires a wildcard/no-database connection) |
| `--list-schemas` | — | List all schemas (PostgreSQL only) |
| `--list-schemas` | — | List all schemas (PostgreSQL/DuckDB) |
| `--list-tables` | — | List all table names |
| `--list-views` | — | List all view names |
| `--list-indexes [TABLE]` | — | List all indexes, optionally filtered to a single table |
| `--table <TABLE>` | — | Return full details for a specific table |
| `--view <VIEW>` | — | Return details for a specific view |
| `--target-database <DB>` | — | Switch to a different database before introspecting |
| `--schema <SCHEMA>` | — | Filter results to a specific schema (PostgreSQL/MySQL only) |
| `--schema <SCHEMA>` | — | Filter results to a specific schema (PostgreSQL/MySQL/DuckDB) |
| `--diff-against <NAME>` | — | Structural schema diff against another named connection. Mutually exclusive with all other operation flags. Returns tables/views added, removed, and changed (columns, indexes, foreign keys, primary keys) |
| `--diff-against-project-path <PATH>` | current project | Project path for the `--diff-against` connection (for cross-project comparison) |

Expand Down Expand Up @@ -411,6 +414,7 @@ Plenum uses native, engine-specific drivers (NOT sqlx):
- **PostgreSQL**: `tokio-postgres`
- **MySQL**: `mysql_async`
- **SQLite**: `rusqlite`
- **DuckDB**: `duckdb`

This ensures maximum isolation between engines and preserves vendor-specific behavior.

Expand Down
204 changes: 204 additions & 0 deletions src/capability/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -122,6 +122,7 @@ fn is_read_only(sql: &str, engine: DatabaseType) -> bool {
DatabaseType::Postgres => is_read_only_postgres(sql),
DatabaseType::MySQL => is_read_only_mysql(sql),
DatabaseType::SQLite => is_read_only_sqlite(sql),
DatabaseType::DuckDB => is_read_only_duckdb(sql),
}
}

Expand Down Expand Up @@ -513,6 +514,102 @@ fn is_read_only_sqlite(sql: &str) -> bool {
|| sql.starts_with("RELEASE")
}

/// `DuckDB` PRAGMAs that are read-only when invoked in argument form
/// (`PRAGMA name(arg)`). These treat the argument as a query parameter rather
/// than a setter value, so the parenthesized form does not write state.
///
/// Names are uppercase to match the preprocessed SQL produced by
/// `preprocess_sql`.
const READ_ONLY_DUCKDB_PRAGMAS_WITH_ARGS: &[&str] =
&["TABLE_INFO", "STORAGE_INFO", "SHOW", "DATABASE_SIZE"];

/// `DuckDB` PRAGMAs whose bare form (`PRAGMA name`) is a pure read.
///
/// `DuckDB` setter PRAGMAs (`memory_limit`, `threads`, `enable_progress_bar`,
/// …) use the `= value` assignment form, which is rejected unconditionally by
/// `is_safe_duckdb_pragma`. Only pure introspection names are admitted here.
const READ_ONLY_DUCKDB_PRAGMAS_BARE: &[&str] = &[
"DATABASE_LIST",
"DATABASE_SIZE",
"SHOW_TABLES",
"SHOW_TABLES_EXPANDED",
"SHOW_DATABASES",
"FUNCTIONS",
"COLLATIONS",
"VERSION",
"PLATFORM",
"USER_AGENT",
"METADATA_INFO",
];

/// Validate a `PRAGMA …` statement against the `DuckDB` read-only allowlist.
///
/// Same shape as the `SQLite` PRAGMA guard: the `= value` setter form is
/// always rejected, the argument form is allowed only for names in
/// [`READ_ONLY_DUCKDB_PRAGMAS_WITH_ARGS`], and the bare form only for names
/// in [`READ_ONLY_DUCKDB_PRAGMAS_BARE`].
fn is_safe_duckdb_pragma(sql: &str) -> bool {
let Some(rest) = sql.strip_prefix("PRAGMA ") else {
return false;
};
let rest = rest.trim().trim_end_matches(';').trim();
if rest.is_empty() {
return false;
}

// `=` always indicates the assignment / setter form (e.g.
// `PRAGMA memory_limit='1GB'`, `PRAGMA threads=4`). Reject unconditionally.
if rest.contains('=') {
return false;
}

let (name, has_args) = match rest.find('(') {
Some(open) => {
if !rest.ends_with(')') {
return false;
}
(rest[..open].trim(), true)
}
None => (rest, false),
};

if name.is_empty() || name.contains(|c: char| c.is_whitespace()) {
return false;
}

if has_args {
READ_ONLY_DUCKDB_PRAGMAS_WITH_ARGS.contains(&name)
} else {
READ_ONLY_DUCKDB_PRAGMAS_BARE.contains(&name)
}
}

// DuckDB read-only check
fn is_read_only_duckdb(sql: &str) -> bool {
// Strip EXPLAIN prefix (DuckDB supports EXPLAIN and EXPLAIN ANALYZE)
let sql = strip_explain_prefix(sql);
let sql = sql.trim();

if sql.starts_with("SELECT ") {
// Guard against `SELECT ... INTO new_table` (DuckDB supports the
// CREATE-TABLE-AS shorthand) with the same quote-aware keyword scan
// used for Postgres (REF-42) and WITH-CTE queries (REF-41).
return scan_for_write_keyword(sql).is_none();
}

is_safe_cte_query(sql)
|| is_safe_duckdb_pragma(sql)
|| sql.starts_with("SHOW ")
|| sql.starts_with("DESCRIBE ")
|| sql.starts_with("DESC ")
|| sql.starts_with("SUMMARIZE ")
|| sql.starts_with("BEGIN")
|| sql.starts_with("COMMIT")
|| sql.starts_with("ROLLBACK")
|| sql.starts_with("SAVEPOINT")
|| sql.starts_with("RELEASE")
}

#[cfg(test)]
mod tests {
use super::*;
Expand Down Expand Up @@ -1541,4 +1638,111 @@ mod tests {
fn test_sqlite_explain_query_plan_delete_rejected() {
assert_rejected("EXPLAIN QUERY PLAN DELETE FROM items", DatabaseType::SQLite);
}

// =========================================================================
// DuckDB dialect (REF-290)
// =========================================================================

#[test]
fn test_duckdb_select_allowed() {
assert_allowed("SELECT * FROM users", DatabaseType::DuckDB);
assert_allowed("SELECT count(*) FROM range(10)", DatabaseType::DuckDB);
}

#[test]
fn test_duckdb_cte_select_allowed() {
assert_allowed("WITH t AS (SELECT 1 AS x) SELECT x FROM t", DatabaseType::DuckDB);
}

#[test]
fn test_duckdb_show_describe_summarize_allowed() {
assert_allowed("SHOW TABLES", DatabaseType::DuckDB);
assert_allowed("DESCRIBE users", DatabaseType::DuckDB);
assert_allowed("DESC users", DatabaseType::DuckDB);
assert_allowed("SUMMARIZE users", DatabaseType::DuckDB);
}

#[test]
fn test_duckdb_explain_allowed() {
assert_allowed("EXPLAIN SELECT * FROM users", DatabaseType::DuckDB);
assert_allowed("EXPLAIN ANALYZE SELECT * FROM users", DatabaseType::DuckDB);
assert_allowed("EXPLAIN (FORMAT JSON) SELECT * FROM users", DatabaseType::DuckDB);
}

#[test]
fn test_duckdb_explain_hidden_write_rejected() {
assert_rejected("EXPLAIN DELETE FROM users", DatabaseType::DuckDB);
assert_rejected("EXPLAIN (FORMAT JSON) INSERT INTO t VALUES (1)", DatabaseType::DuckDB);
}

#[test]
fn test_duckdb_transaction_control_allowed() {
assert_allowed("BEGIN", DatabaseType::DuckDB);
assert_allowed("BEGIN TRANSACTION", DatabaseType::DuckDB);
assert_allowed("COMMIT", DatabaseType::DuckDB);
assert_allowed("ROLLBACK", DatabaseType::DuckDB);
}

#[test]
fn test_duckdb_writes_rejected() {
assert_rejected("INSERT INTO users VALUES (1)", DatabaseType::DuckDB);
assert_rejected("UPDATE users SET name = 'x'", DatabaseType::DuckDB);
assert_rejected("DELETE FROM users", DatabaseType::DuckDB);
assert_rejected("CREATE TABLE t (id INTEGER)", DatabaseType::DuckDB);
assert_rejected("DROP TABLE users", DatabaseType::DuckDB);
assert_rejected("ALTER TABLE users ADD COLUMN c INTEGER", DatabaseType::DuckDB);
assert_rejected("TRUNCATE users", DatabaseType::DuckDB);
}

#[test]
fn test_duckdb_file_and_extension_operations_rejected() {
// COPY writes to the filesystem; ATTACH/DETACH mutate catalog state;
// INSTALL/LOAD pull in extensions.
assert_rejected("COPY users TO 'out.csv'", DatabaseType::DuckDB);
assert_rejected("ATTACH 'other.duckdb' AS other", DatabaseType::DuckDB);
assert_rejected("DETACH other", DatabaseType::DuckDB);
assert_rejected("LOAD httpfs", DatabaseType::DuckDB);
assert_rejected("INSTALL httpfs", DatabaseType::DuckDB);
}

#[test]
fn test_duckdb_select_into_rejected() {
// CREATE-TABLE-AS shorthand hidden behind a SELECT prefix
assert_rejected("SELECT * INTO new_table FROM users", DatabaseType::DuckDB);
}

#[test]
fn test_duckdb_cte_hidden_write_rejected() {
assert_rejected(
"WITH t AS (SELECT 1) INSERT INTO users SELECT * FROM t",
DatabaseType::DuckDB,
);
assert_rejected("WITH t AS (SELECT 1) DELETE FROM users", DatabaseType::DuckDB);
}

#[test]
fn test_duckdb_read_only_pragmas_allowed() {
assert_allowed("PRAGMA database_list", DatabaseType::DuckDB);
assert_allowed("PRAGMA show_tables", DatabaseType::DuckDB);
assert_allowed("PRAGMA version", DatabaseType::DuckDB);
assert_allowed("PRAGMA table_info('users')", DatabaseType::DuckDB);
assert_allowed("PRAGMA storage_info('users')", DatabaseType::DuckDB);
assert_allowed("PRAGMA database_size", DatabaseType::DuckDB);
}

#[test]
fn test_duckdb_setter_pragmas_rejected() {
assert_rejected("PRAGMA memory_limit='1GB'", DatabaseType::DuckDB);
assert_rejected("PRAGMA threads=4", DatabaseType::DuckDB);
assert_rejected("PRAGMA enable_progress_bar", DatabaseType::DuckDB);
assert_rejected("PRAGMA enable_profiling", DatabaseType::DuckDB);
assert_rejected("PRAGMA disable_progress_bar", DatabaseType::DuckDB);
}

#[test]
fn test_duckdb_multi_statement_rejected() {
let caps = Capabilities::default();
let result = validate_query("SELECT 1; DROP TABLE users", &caps, DatabaseType::DuckDB);
assert!(result.is_err());
}
}
9 changes: 9 additions & 0 deletions src/diff.rs
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,8 @@ use crate::engine::{
};
use crate::error::{PlenumError, Result};

#[cfg(feature = "duckdb")]
use crate::engine::duckdb::DuckDbEngine;
#[cfg(feature = "mysql")]
use crate::engine::mysql::MySqlEngine;
#[cfg(feature = "postgres")]
Expand Down Expand Up @@ -50,6 +52,13 @@ async fn engine_introspect(
DatabaseType::MySQL => Err(PlenumError::invalid_input(
"MySQL engine not enabled. Build with --features mysql.",
)),

#[cfg(feature = "duckdb")]
DatabaseType::DuckDB => DuckDbEngine::introspect(config, operation, database, schema).await,
#[cfg(not(feature = "duckdb"))]
DatabaseType::DuckDB => Err(PlenumError::invalid_input(
"DuckDB engine not enabled. Build with --features duckdb.",
)),
}
}

Expand Down
Loading
Loading