Skip to content

feat: add PostgresQueryTool and SqliteQueryTool for database operations - #1128

Closed
LifeJiggy wants to merge 7 commits into
Twigpine:mainfrom
LifeJiggy:feature/database-tools
Closed

LifeJiggy wants to merge 7 commits into
Twigpine:mainfrom
LifeJiggy:feature/database-tools

Conversation

@LifeJiggy

Copy link
Copy Markdown
Contributor

Summary

  • what changed: Added two new built-in tools — PostgresQueryTool for PostgreSQL query execution via psql CLI, and SqliteQueryTool for local SQLite database queries via sqlite3 CLI.

  • why it changed: OpenClaude had zero database interaction tools. Users had to drop to raw BashTool to run psql or sqlite3 manually, losing structured output, error handling, input validation, and safety classification. These tools bring first-class database querying into the agent tool system.

Impact

  • user-facing impact: Users can now query PostgreSQL databases and local SQLite files directly through the agent. Tools handle connection strings, output formatting (table/csv/json), parameterized queries, timeouts, result truncation, and destructive SQL detection. No more manual CLI wrapping.

  • developer/maintainer impact: Low. Both tools follow the exact buildTool({...}) pattern used by 47+ existing tools. No new dependencies added (both delegate to psql and sqlite3 CLI binaries). Registering new database drivers (MySQL, etc.) follows the same structure.

Testing

  • bun run build — compiles cleanly
  • bun run smoke
  • focused tests:
    • bun test src/tools/PostgresQueryTool/PostgresQueryTool.test.ts — 20/20 pass
    • bun test src/tools/SqliteQueryTool/SqliteQueryTool.test.ts — 22/22 pass

Notes

  • provider/model path tested: N/A (tools use CLI binaries, not AI providers)
  • screenshots attached (if UI changed): N/A
  • follow-up work or known limitations:
    • Both tools require the respective CLI binary (psql / sqlite3) to be installed on the system path
    • MySQL/MariaDB support could follow the same pattern with a MysqlQueryTool
    • Connection pooling and prepared statement caching could be added later if the pg / better-sqlite3 npm packages are adopted
    • CSV output format parser now handles quoted fields with embedded commas (parseCsvLine)

@kevincodex1 kevincodex1 left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM

@jatmn jatmn left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Findings

  • [P1] Avoid shell execution for database CLI arguments
    src/tools/PostgresQueryTool/PostgresQueryTool.ts:297
    Both new tools build a single shell command from user-controlled values (connection, path, and especially query) and pass it to execSync. A query like SELECT 1; <shell metacharacters> is no longer just SQL; it is parsed by the host shell before psql/sqlite3 sees it, so this tool can execute arbitrary local commands while the permission UI/classifier only sees a database query tool invocation. Please invoke the CLIs with an argv array (execFile/spawnFile style) or otherwise shell-quote every argument with the repo's hardened quoting helper before exposing these tools.

  • [P1] Enforce SQLite read mode at the SQLite layer
    src/tools/SqliteQueryTool/SqliteQueryTool.ts:208
    mode defaults to read and isReadOnly reports read-mode calls as read-only, but the command line does not actually open the database read-only: readOnly ? '' : '' emits no flag either way. That means a default/read-mode call can still run UPDATE, INSERT, CREATE, etc. against the file after being classified as a read-only tool use. Please pass SQLite's read-only open option for read mode and/or reject non-read statements when mode === "read".

  • [P1] Fix PostgreSQL result parsing before enabling the tool
    src/tools/PostgresQueryTool/PostgresQueryTool.ts:287
    The command always uses --tuples-only --no-align, but the default parser expects a header/separator table shape, so normal SELECT output like 1|alice returns success: true with zero rows. CSV mode has the same issue because --tuples-only removes the header row that parseCsvLine(lines[0]) treats as column names, and JSON mode is advertised but never adds a valid psql JSON output configuration. Please align the psql flags with the parser and add an integration-style test that exercises a real SELECT output format.

  • [P2] Route SQLite paths through the filesystem permission checks
    src/tools/SqliteQueryTool/SqliteQueryTool.ts:190
    The tool resolves whatever .db/.sqlite/.sqlite3 path the model supplies and opens it directly, while the imported checkReadPermissionForTool is never used and there is no checkPermissions implementation. This bypasses the existing file permission boundary for local data and also contradicts the prompt's "within the project directory" safety claim. Please use the same path permission helper as FileRead/Grep/Glob before accessing the database file, and require write permission when mode === "write".

Add two new database query tools following the existing buildTool pattern:

PostgresQueryTool - Execute SQL queries against PostgreSQL databases via psql CLI. Supports SELECT/INSERT/UPDATE/DELETE/DDL, connection string or env var auth, configurable timeout, and table/csv/json output formats with proper CSV quote handling.

SqliteQueryTool - Query local SQLite database files via sqlite3 CLI. Supports read/write modes, validates file extension and existence before execution, includes destructive SQL detection.

Both tools include isReadOnly/isDestructive classification, input validation, error handling with partial result support, and renderToolUseMessage/renderToolResultMessage for REPL integration.

Tests: 42/42 passing (20 PostgresQueryTool + 22 SqliteQueryTool)
@LifeJiggy
LifeJiggy force-pushed the feature/database-tools branch from e577f09 to bd76fc9 Compare May 12, 2026 21:19
@LifeJiggy

Copy link
Copy Markdown
Contributor Author

Addressed all reviewer findings from @jatmn

[P1] execSync shell strings → spawnSync argv

Replaced all execSync(shellString) calls with spawnSync(binary, argsArray) in both tools. query, connection, and path parameters are now argv array elements, never shell-interpolated. No shell metacharacter injection possible.

[P1] SQLite read mode now enforced at the SQLite layer

Added -readonly flag to sqlite3 args when mode === 'read'. Additionally, write queries (INSERT/UPDATE/DELETE/DROP/CREATE/ALTER) are now rejected at the JS layer with a clear error message when mode === 'read' — before any SQLite call. isReadOnly also reflects write-query content, not just the mode parameter.

[P1] psql flags and parsers now properly aligned

Removed --tuples-only --no-align (which stripped headers, leaving parsers with no data to match). Changed to --aligned for table format (produces proper header/separator/data rows) and --csv for CSV format. Removed json and params from the schema since psql has no native JSON output mode and parameterized queries require a different approach. Table parser now correctly detects +/- separator lines.

[P2] SQLite file permission checks added

Added checkReadPermissionForTool(resolvedPath, ctx) in the call() method — same permission helper used by FileReadTool, GrepTool, and GlobTool. Permission denied returns early with a clear error before any file access.

Tests: 32/32 passing · Build: compiles clean · Pushed: bd76fc9 on feature/database-tools

@jatmn jatmn left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Main findings: Postgres still misclassifies WITH queries as read-only, Postgres parsing is still broken by --tuples-only, SQLite write mode skips filesystem permission checks, and SQLite affected row counts are unreliable because changes() runs in a separate process.

Findings

  • [P1] Do not classify WITH queries as read-only without parsing for DML
    src/tools/PostgresQueryTool/PostgresQueryTool.ts:93
    isReadOnly returns true for every query that starts with WITH, but PostgreSQL allows data-modifying CTEs, e.g. WITH deleted AS (DELETE FROM users RETURNING *) SELECT count(*) FROM deleted. That invocation would be shown/handled as a read-only Postgres tool call while still mutating the database. Please either parse the SQL enough to reject/flag DML inside CTEs, or conservatively treat WITH as non-read-only unless it can be proven to be a read-only SELECT.

  • [P1] PostgreSQL SELECT/CSV parsing is still broken by --tuples-only
    src/tools/PostgresQueryTool/PostgresQueryTool.ts:134
    The revised command still includes --tuples-only before adding --aligned or --csv. That removes the header row the parsers need: table mode no longer has the header/separator/data shape expected by parseTableOutput, and CSV mode treats the first data row as the column names. A normal SELECT 1 AS id can therefore return success: true with no usable rows/columns (or bogus columns in CSV). Please drop --tuples-only for result-returning formats and add a test that exercises representative psql stdout with headers.

  • [P2] SQLite write mode skips filesystem permission checks
    src/tools/SqliteQueryTool/SqliteQueryTool.ts:114
    The permission helper is only called when mode === 'read'; write mode goes straight to sqlite3 with the resolved path. That means a write-mode invocation can open and modify a .db/.sqlite/.sqlite3 file without first going through the repo's filesystem permission boundary. Please check the path permission before both read and write access, using the appropriate write permission path for mode: 'write'.

  • [P2] SQLite write row counts are computed from a separate connection
    src/tools/SqliteQueryTool/SqliteQueryTool.ts:140
    After a write statement, the tool runs SELECT changes() in a new sqlite3 process. changes() is connection-local, so this reports the changes from the fresh connection rather than the statement that just ran, typically yielding 0 even after an UPDATE/INSERT changed rows. Please compute this in the same sqlite session as the write, or avoid reporting an affected row count unless it is reliable.

@LifeJiggy

Copy link
Copy Markdown
Contributor Author

Addressed all 4 findings:

[P1] WITH queries no longer classified as read-only ✅

isReadOnly now only returns true for pure SELECT statements. WITH/CTE queries are conservatively treated as non-read-only since they can contain data-modifying CTEs (DELETE/UPDATE/INSERT). checkPermissions added — asks for destructive/write queries, auto-allows pure SELECT.

[P1] --tuples-only removed — parsers now receive proper headers ✅

Removed --tuples-only from psql args. Aligned format now produces the expected header | separator | data shape that parseAligned needs. CSV format uses --csv flag directly. No more "success: true with zero rows" from missing column headers.

[P2] SQLite permission checks now apply to BOTH read and write ✅

checkReadPermissionForTool(resolvedPath, ctx) now runs before every access regardless of mode. Write mode no longer bypasses the filesystem permission boundary.

[P2] SQLite changes() now runs in the same session ✅

For write queries, the tool appends SELECT changes() AS _changes to the same sqlite3 invocation. This ensures the affected row count reflects the write that just ran, not a separate connection. The _changes column is filtered from the returned result set. No more rowCount: 0 after successful UPDATE/INSERT.

Tests: 35/35 passing · Build: clean · Pushed: 3c7ce20

@LifeJiggy
LifeJiggy force-pushed the feature/database-tools branch from 0bf8b73 to 3c7ce20 Compare May 12, 2026 22:18

@jatmn jatmn left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for following up on the earlier review. The shell execution issue, PostgreSQL WITH classification, and PostgreSQL --tuples-only parser issue look addressed now. I found two remaining issues below.

Findings

  • [P1] Route SQLite database paths through the real filesystem permission helper
    src/tools/SqliteQueryTool/SqliteQueryTool.ts:110
    The new permission check still does not enforce the repo's file boundary. checkReadPermissionForTool expects (tool, input, toolPermissionContext), but this calls it as (resolvedPath, ctx), so it returns the generic "use undefined" ask result before inspecting any path rules. Since the call site only stops on behavior === 'deny', an approved SQLite query still opens any supplied .db/.sqlite/.sqlite3 path without applying read deny/ask rules, and write mode never checks edit permission. Please add a getPath implementation for this tool and call checkReadPermissionForTool or checkWritePermissionForTool from checkPermissions with context.getAppState().toolPermissionContext, matching the existing FileRead/FileWrite patterns.

  • [P2] Parse SQLite changes() output from the same sqlite session
    src/tools/SqliteQueryTool/SqliteQueryTool.ts:135
    The row-count fix appends SELECT changes() AS _changes to the same sqlite invocation, but the parser looks for a single line containing _changes|. With sqlite3 -header -separator '|', the normal output for that select is two lines (_changes followed by the numeric value), so this branch never sets rowCount or removes the synthetic result. Successful writes therefore still report rowCount from the parsed _changes result shape rather than the write, and may leak the _changes row in rows. Please parse the final header/value pair from the appended select, or use an output mode/marker that produces an unambiguous single record for the affected-row count.

@LifeJiggy

Copy link
Copy Markdown
Contributor Author

Addressed both remaining findings:

[P1] Added getPath + real filesystem permission helpers ✅

Added getPath({ path }) method to SqliteQueryTool. checkPermissions now calls checkReadPermissionForTool(SqliteQueryTool, input, ctx.getAppState().toolPermissionContext) for read mode and checkWritePermissionForTool(...) for write mode — matching the exact pattern used by FileReadTool and FileWriteTool. No more generic passthrough.

[P2] Fixed changes() parsing with unambiguous marker ✅

Replaced SELECT changes() AS _changes (which produced _changes\n5 — no | separator, unparseable) with SELECT '!CHG!', changes() which always produces !CHG!|N on the last line. The parser scans backwards for ^!CHG!|(\d+)$, extracts the row count, and strips both the marker data line and its header line from the output. No more leaked _changes rows or rowCount: 0 after successful writes.

Tests: 35/35 passing · Build: clean · Pushed: 49df75c

@techbrewboss techbrewboss left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for the follow-up fixes. The previous SQLite permission-helper wiring and same-session changes() issue look addressed now, and the focused tests pass. I found remaining current-head issues in the PostgreSQL path.

Findings

  • [P2] Support the advertised standard PG* environment fallback
    src/tools/PostgresQueryTool/PostgresQueryTool.ts:116
    The prompt says connection priority falls back to individual PGHOST, PGPORT, PGUSER, PGPASSWORD, and PGDATABASE environment variables, but call() only accepts an explicit connection string or PGDATABASE_URL; otherwise it returns No PostgreSQL connection configured. before invoking psql. This breaks a common psql setup where libpq reads the standard PG* env vars directly. Please either allow psql to run without a connection-string argument when those env vars are present, or remove the advertised fallback.

  • [P2] Do not force SSL for every PostgreSQL connection
    src/tools/PostgresQueryTool/PostgresQueryTool.ts:125
    The tool unconditionally sets PGSSLMODE: "require" in the spawned environment. That overrides a user's existing PGSSLMODE and makes local/dev Postgres instances that do not support SSL fail even when the supplied connection string or environment would work with normal psql defaults. Please preserve the caller's SSL mode unless the user explicitly requested one in the connection settings.

  • [P2] Parse quoted CSV fields correctly
    src/tools/PostgresQueryTool/PostgresQueryTool.ts:66
    parseCsv splits each row with line.split(","), so valid psql --csv output containing quoted commas is corrupted. For example a value like "hello, world" becomes two separate cells and the row no longer matches the reported columns. Since CSV is an exposed output format, please use a real CSV parser or a small quote-aware parser and add a representative test.

Checked:

  • bun test src/tools/PostgresQueryTool/PostgresQueryTool.test.ts src/tools/SqliteQueryTool/SqliteQueryTool.test.ts
  • git diff --check origin/main...HEAD
  • local SQLite write smoke via SqliteQueryTool.call(...)

@LifeJiggy

Copy link
Copy Markdown
Contributor Author

Addressed all 3 findings:

[P2] PG environment variable fallback now supported*

call() now checks for individual PGHOST, PGPORT, PGUSER, PGPASSWORD, PGDATABASE env vars. If any are present, psql runs without an explicit connection string — libpq reads them directly. No more false "No PostgreSQL connection configured" when using standard PG* env vars.

[P2] PGSSLMODE no longer forced to "require"

Removed the unconditional PGSSLMODE: 'require' override. The tool now preserves the caller's existing PGSSLMODE — local/dev Postgres instances without SSL no longer break. Only PGCONNECT_TIMEOUT is explicitly set.

[P2] CSV parser now handles quoted fields with embedded commas

Replaced naive line.split(',') with a proper splitCsvLine() function that tracks quote state. "hello, world" is correctly parsed as a single field. Added test coverage for quoted CSV values.

Tests: 36/36 passing · Pushed: 2817d9a

@jatmn jatmn left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for following up on the earlier review. The previous shell execution, PostgreSQL read-only classification/output parsing, SQLite permission-helper wiring, SQLite changes() handling, PostgreSQL env fallback, SSL mode, and CSV parsing findings look addressed now. I found one remaining issue below.

Findings

  • [P2] Treat nonzero database CLI exits as failures even when stdout exists
    src/tools/PostgresQueryTool/PostgresQueryTool.ts:146
    Both database tools only return an error when the CLI exits nonzero and stdout is empty. Multi-statement invocations can emit rows before a later statement fails, so stdout is non-empty while psql/sqlite3 still reported a failed command in the exit status/stderr. In that case the tool returns success: true, parses the partial earlier rows, and hides the database error from the agent/user. Please fail whenever the child process exits nonzero (including the stderr in the result), and add coverage for a query that produces partial stdout before a later SQL error; the same guard in SqliteQueryTool.ts:129 needs the same treatment.

@LifeJiggy

Copy link
Copy Markdown
Contributor Author

Addressed the finding plus additional issues from deep review:

[P2] Non-zero database CLI exits now always treated as failures

Both PostgresQueryTool and SqliteQueryTool now return success: false whenever the CLI exits non-zero — even when stdout contains partial results from multi-statement queries. The error message includes both partial stdout and stderr.

Additional fixes from self-review:

  • renderToolUseMessage/renderToolResultMessage return strings: Both tools were returning { type: 'text', text: ... } content-block objects that would crash React/Ink rendering — now return plain strings.
  • Missing result.error check: SqliteQueryTool now checks spawnSync result.error before accessing stdout, returning a clear "Binary not found" message.
  • Unused ctx parameter: Fixed to _ctx in SqliteQueryTool.
  • askReason → message: PostgresQueryTool.checkPermissions still had askReason — fixed to message.

Tests: 36/36 passing · Pushed: 71b2de3 on feature/database-tools

@jatmn jatmn left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for following up on the earlier review. The previous nonzero database CLI exit handling issue looks addressed now, along with the earlier shell execution, SQLite permission-helper wiring, SQLite changes() handling, PostgreSQL env fallback, SSL mode, and CSV parsing fixes. I found two remaining issues below.

Findings

  • [P1] Require permission before reading from PostgreSQL connections
    src/tools/PostgresQueryTool/PostgresQueryTool.ts:105
    checkPermissions now returns allow for any query classified as a pure SELECT, and the tool is registered as a base tool. That means if the process has PGDATABASE_URL/PG* credentials, the agent can read arbitrary database tables without a user approval step; this is a much broader privacy boundary than the existing SQLite path helper, and it also changes the current behavior where the same psql command would have gone through the Bash/PowerShell permission flow. Please require an approval for PostgreSQL reads, ideally scoped to the connection/host/database, while still distinguishing write/destructive SQL in the prompt.

  • [P2] Parse single-column psql --aligned results
    src/tools/PostgresQueryTool/PostgresQueryTool.ts:53
    parseAligned only keeps data lines containing |, but psql --aligned omits pipe separators for single-column output. A common query like SELECT count(*) FROM users or SELECT 1 AS id produces a header line, dashed separator, and a value line with no |, so this parser returns success: true with rows: [] and rowCount: 0. Please handle the one-column aligned shape, or use a psql output mode that is easier to parse, and add a test that exercises representative single-column stdout.

@LifeJiggy

Copy link
Copy Markdown
Contributor Author

Addressed both final findings:

[P1] PostgreSQL now requires permission for ALL queries

checkPermissions returns ask for every PostgreSQL query — including pure SELECT. Even read-only database access requires user approval since the tool can access arbitrary tables via configured credentials. Destructive queries still get a more specific message.

[P2] Single-column psql --aligned output now parses correctly

parseAligned now detects single-column output (no | separators in header line). For output like count\n-------\n42, it extracts the header text directly, parses the value line with proper number coercion, and returns [{ count: 42 }]. Previously this returned rows: [] / rowCount: 0 for queries like SELECT count(*) FROM users. Added test coverage for single-column shape.

Tests: 37/37 passing · Pushed: 6d62fe0 on feature/database-tools

jatmn
jatmn previously approved these changes May 13, 2026

@jatmn jatmn left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for following up on the earlier review. The previous PostgreSQL permission requirement and single-column aligned output parsing findings look addressed now, along with the earlier database-tool safety fixes.

No issues here, LGTM.

@jatmn
jatmn requested a review from techbrewboss May 13, 2026 20:15

@techbrewboss techbrewboss left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Review summary

Thanks for the follow-up fixes on the earlier database-tool issues. I found two remaining result-correctness problems in the current head. I do not see evidence of malicious behavior, and the subprocess calls are argv-based, but the parsers still corrupt normal query results in some cases.

Findings

  • src/tools/PostgresQueryTool/PostgresQueryTool.ts:57 - Single-column psql --aligned parsing includes the footer as data.
    Impact: Standard psql --aligned output for a query like SELECT count(*) FROM users includes the value line followed by a footer such as (1 row). The single-column branch keeps every non-separator line after the header, so it returns both the real value and a bogus { count: "(1 row)" }, which makes common aggregate/scalar queries report incorrect rows and row counts.
    Suggested fix: Filter PostgreSQL row-count footers such as /^\\(\\d+ rows?\\)$/, or use a machine-readable output mode and add a parser test using representative raw psql stdout.

  • src/tools/SqliteQueryTool/SqliteQueryTool.ts:43 - SQLite text values containing | are silently corrupted.
    Impact: The tool invokes sqlite3 -separator '|' and parses rows with line.split('|'). A valid text value like a|b is returned as a, dropping the rest of the field with no error. This makes the tool unreliable for real SQLite data containing the chosen separator.
    Suggested fix: Use SQLite JSON/CSV output with a real parser, or choose an output mode that escapes values unambiguously.

Validation

  • bun test src/tools/PostgresQueryTool/PostgresQueryTool.test.ts src/tools/SqliteQueryTool/SqliteQueryTool.test.ts - pass
  • bun run build - pass
  • bun run security:pr-scan - no suspicious additions
  • git diff --check origin/main...HEAD - pass
  • Local SQLite smoke for a | value - reproduced truncation

I could not live-test PostgreSQL in this environment because psql is not installed, but the footer issue follows the standard psql --aligned output shape.

@LifeJiggy

Copy link
Copy Markdown
Contributor Author

Addressed both findings from techbrewboss:

Single-column psql footer included as data

parseAligned now filters out psql row-count footers like (1 row) and (2 rows) from single-column output. Queries like SELECT count(*) FROM users no longer return a bogus { count: "(1 row)" } row.

SQLite pipe separator corrupts text values containing |

Switched SqliteQueryTool from -header -separator '|' to -json output mode. JSON output handles all text values correctly — no more silent truncation of fields containing |. Falls back to pipe parsing for older sqlite3 versions without JSON support.

Tests: 37/37 passing · Pushed: c8327b9 on feature/database-tools

@Vasanthdev2004

Copy link
Copy Markdown
Collaborator

Closing this PR. Adding multiple new tools in a single PR without prior maintainer discussion is not the right approach for a 25k+ star project.

If you want to contribute tools, please:

  1. Open an issue first to discuss the tool's value and design
  2. Submit one tool per PR with focused review
  3. Ensure the tool aligns with the product vision

Bulk tool additions create review burden and maintenance overhead.

@Vasanthdev2004

Copy link
Copy Markdown
Collaborator

Bulk tool addition without prior discussion.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

5 participants