Skip to content

feat(duckdb-analyst): add sandbox-free SQL analysis toolset - #22

Draft
yellowcap wants to merge 2 commits into
mainfrom
feature/duckdb-analyst-toolset
Draft

feat(duckdb-analyst): add sandbox-free SQL analysis toolset#22
yellowcap wants to merge 2 commits into
mainfrom
feature/duckdb-analyst-toolset

Conversation

@yellowcap

@yellowcap yellowcap commented Aug 10, 2026

Copy link
Copy Markdown
Member

Origin

The idea comes from gazet, a natural-language geocoder. Its src/gazet/sql.py module holds a small "code-act" loop:

  1. An LLM writes one DuckDB SELECT statement.
  2. The code runs the statement.
  3. If the statement fails, the code sends the error back to the LLM.
  4. The LLM tries again, up to a fixed number of attempts.

That loop works well, but it is narrow. It queries two fixed parquet schemas. It keeps the schema in a hard-coded prompt string (config.py, SCHEMA_INFO). It also implements its own retry logic.

This PR makes the same pattern general, and moves it out of gazet into a toolset.

Motivation

A DuckDB SQL surface does not need a code sandbox. This is the main reason to build the toolset this way. A Python REPL gives an agent arbitrary imports, processes, and file access. To run one safely, you need a sandbox. DuckDB SQL is much smaller. It cannot import modules or start processes. DuckDB is also good at the target workload: it reads remote parquet over HTTP, pushes down predicates, and reads only the byte ranges that it needs.

The MCP host already has a retry loop. An MCP client calls tools over several turns. If a query fails, the host LLM sees the error and writes a corrected query. The toolset therefore does not need the retry logic from sql.py. The host provides it at no cost.

Charts belong to the caller. The toolset returns data, not images. It returns a complete Vega-Lite spec. Any client can then render that spec. This keeps the server free of plot libraries, and keeps the output declarative.

Together these three points give a lightweight design. The toolset is one process, with one DuckDB connection, and no sandbox.

What this adds

Three tools in toolsets/duckdb-analyst:

Tool Purpose
list_sources() Lists the curated views and their columns. Marks which columns suit the x, y, and color chart channels. This replaces the static SCHEMA_INFO string from gazet.
query(sql, limit) Runs one read-only SELECT. Returns rows as JSON records.
chart(sql, spec, limit) Runs the query, then puts the rows into a caller-supplied Vega-Lite spec. Returns {**spec, "data": {"values": rows}}.

Curated views: natural_earth_countries and natural_earth_places (Natural Earth 1:110m). Both are small, so a full GROUP BY over either one returns in well under a second.

query and chart are not limited to those views. They read any public https:// or s3:// parquet or CSV URL through read_parquet/read_csv. A new curated dataset is one CREATE VIEW in connection.py, not a fourth tool.

Scope: small datasets only

This is a measured limit, not an untested guess.

An earlier revision of this PR also advertised Overture Maps and a STAC table function. Both are now removed. Neither worked in practice, and no query test covered either one.

Overture Maps. The themes are hundreds of GB. The public parquet layout gives no partition pruning for the fields an analyst filters on, such as country and locality. Any aggregate query therefore scans the whole theme. Measured against this toolset's memory and thread caps, and the 30-second watchdog:

Query Time
SELECT ... FROM overture_divisions LIMIT 3 2.8s
... WHERE country = 'PT' LIMIT 3 17.4s
GROUP BY subtype WHERE country = 'PT' timeout at 30s
GROUP BY category WHERE locality = 'Lisboa' timeout at 33s

A dataset of that size needs a local copy. gazet reached the same conclusion, which is why it ships local Overture extracts. A local copy is just another CREATE VIEW here. It needs no change to tools.py and no change to the security model.

STAC. STAC_Search against Planetary Computer fails outright. The Item Search route returns HTTP 422. The catalog root returns HTTP 405. The argument names are correct — duckdb_functions() reports col0, intersects, max_items, bbox, datetime, ids, collections — so the problem is the catalog URL contract, not the call. The stac community extension was also the only unpinnable dependency in the toolset, because INSTALL ... FROM community always resolves to the latest build for the installed DuckDB version.

Removing it has a security benefit. SET allow_community_extensions = false now runs before the first INSTALL, so community extensions are refused outright, instead of being allowed once and then closed off.

Both datasets are worth revisiting as follow-ups: Overture with a local-copy story, STAC once somebody establishes what catalog URL the extension expects.

Security model

Read the module docstring in connection.py for the full detail. There are four layers.

The first layer is the deployment, not the code. The process must hold no local secrets, in its filesystem or its environment. No Python code can replace this control.

The second layer blocks local disk in DuckDB. This layer needs care, because one common assumption is wrong:

DuckDB's file-reading table functions run inside a normal SELECT. For example, SELECT * FROM read_text('/proc/self/environ') is a valid read-only SELECT. It also prints the process environment.

A check that allows only SELECT statements does not stop this. The unsafe part is a function argument, not the statement type.

SET enable_external_access = false also does not work here. It is all-or-nothing. It blocks httpfs together with local files, so remote parquet reads stop working.

The setting that works is SET disabled_filesystems = 'LocalFileSystem'. It blocks local disk, and it keeps https:// and s3:// reads.

One DuckDB behaviour affects the order of setup. disabled_filesystems on a cold connection also blocks the first remote read, because the remote filesystem is not yet registered. See duckdb/duckdb#15734. Setup therefore does one warmup remote read before it disables the local filesystem.

The third layer freezes the configuration. SET lock_configuration = true runs last. After that, no query can change any setting above.

The fourth layer checks the statement shape. security.validate_select_only allows one SELECT or WITH statement. It also denies a small list of introspection functions, such as duckdb_secrets(). This layer is defense-in-depth only. It does not stop read_text('/etc/passwd'). Layer two stops that.

The connection also sets memory_limit and threads. A watchdog thread calls interrupt() after 30 seconds, because DuckDB has no native query timeout. The server clamps limit to 10,000 rows, so a caller cannot remove the cap.

Tests

17 tests pass in 3.2 seconds. ./scripts/test passes 36/36. ./scripts/lint (ruff and mypy) is clean.

Every advertised source now has a real query test. test_list_sources_advertises_nothing_untested asserts that the set of sources in list_sources equals the set the tests cover. This is the check that the removed revision lacked: it advertised Overture and STAC to the LLM while testing neither.

The security tests confirm that each of these fails:

  • read_text('/etc/passwd')
  • read_text('/proc/self/environ')
  • SELECT 1; ATTACH ':memory:' AS x
  • INSTALL icu
  • SET enable_external_access = true
  • PRAGMA database_list
  • SELECT * FROM duckdb_secrets()

The first two fail with Permission Error: File system LocalFileSystem has been disabled by configuration. This confirms that DuckDB blocks them, and not the statement-shape check. Manual checks show that glob('/home/**') and read_csv('file:///etc/passwd') fail in the same way.

Other tests confirm that remote reads still work, both from a curated view and from an ad hoc parquet URL. This pair of results is the core of the design: local disk is closed, and the network is open.

Open items before merge

The egress allowlist does not exist yet. A query can still reach link-local and RFC1918 addresses through read_parquet('http://...'). For example, 169.254.169.254 returns cloud instance metadata. A naive filter treats that address as "the internet", but it is SSRF into internal services. The fix belongs in the Helm chart or a NetworkPolicy, not in application code. This repo has no such convention yet, so this toolset is the first that needs one. This is the one real gap in the threat model.

s3_region is fixed at us-east-1. lock_configuration means a caller cannot change it, so an s3:// URL in another region fails. Use that bucket's https:// endpoint instead. Plain https:// reads are unaffected.

Review notes

toolset.yaml raises the memory and CPU limits above the chart's 512Mi default.

Try it locally:

uv sync --package duckdb-analyst
uv run mcp-serve-local
uv run mcp-cli repl --url http://localhost:8000/duckdb-analyst/mcp

Draft, because of the egress allowlist above. Please confirm where that control belongs before merge.

…arth/STAC

Three tools (list_sources, query, chart) over a single, hardened DuckDB
connection: curated views for Overture Maps divisions/places and Natural
Earth countries/populated places, plus STAC search via the community `stac`
extension pointed at Planetary Computer. No code-execution sandbox needed —
callers only ever write read-only SQL.

Security model (see connection.py's module docstring for the full writeup):
statement-shape validation (single SELECT/WITH, denylisted introspection
calls) is defense-in-depth only; the real controls are DuckDB's
disabled_filesystems + lock_configuration (verified empirically against a
real DuckDB limitation where this only works after one remote-read
"warmup" — undocumented upstream, reproduced and worked around here) and,
primarily, the deployment shipping no local secrets for this connection to
ever reach.
Both were advertised in list_sources but neither was usable in practice,
and no query test covered either one.

Overture: the public parquet layout gives no partition pruning on the
fields an analyst filters on, so any aggregate scans the whole theme.
Measured against this toolset's memory/thread caps and the 30s watchdog:
a bare SELECT ... LIMIT 3 took ~2.8s, adding WHERE country = 'PT' took
~17.4s, and a GROUP BY exceeded the timeout. A dataset that size wants a
local copy, which is just another CREATE VIEW here.

STAC: STAC_Search against Planetary Computer fails outright — HTTP 422 on
the Item Search route, 405 on the catalog root. The `stac` community
extension was also the only unpinnable dependency in the toolset, since
INSTALL ... FROM community always resolves to the latest build.

Removing it lets allow_community_extensions = false move ahead of the
first INSTALL, so community extensions are now refused outright rather
than allowed once and closed off afterwards.

Tests now exercise every advertised source with a real query, and
test_list_sources_advertises_nothing_untested keeps that true. Suite
drops from 22.6s to 3.2s; 17 tests, security regression suite unchanged.
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.

1 participant