feat(duckdb-analyst): add sandbox-free SQL analysis toolset - #22
Draft
yellowcap wants to merge 2 commits into
Draft
feat(duckdb-analyst): add sandbox-free SQL analysis toolset#22yellowcap wants to merge 2 commits into
yellowcap wants to merge 2 commits into
Conversation
…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.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Origin
The idea comes from
gazet, a natural-language geocoder. Itssrc/gazet/sql.pymodule holds a small "code-act" loop:SELECTstatement.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
gazetinto 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:list_sources()x,y, andcolorchart channels. This replaces the staticSCHEMA_INFOstring fromgazet.query(sql, limit)SELECT. Returns rows as JSON records.chart(sql, spec, limit){**spec, "data": {"values": rows}}.Curated views:
natural_earth_countriesandnatural_earth_places(Natural Earth 1:110m). Both are small, so a fullGROUP BYover either one returns in well under a second.queryandchartare not limited to those views. They read any publichttps://ors3://parquet or CSV URL throughread_parquet/read_csv. A new curated dataset is oneCREATE VIEWinconnection.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
countryandlocality. Any aggregate query therefore scans the whole theme. Measured against this toolset's memory and thread caps, and the 30-second watchdog:SELECT ... FROM overture_divisions LIMIT 3... WHERE country = 'PT' LIMIT 3GROUP BY subtype WHERE country = 'PT'GROUP BY category WHERE locality = 'Lisboa'A dataset of that size needs a local copy.
gazetreached the same conclusion, which is why it ships local Overture extracts. A local copy is just anotherCREATE VIEWhere. It needs no change totools.pyand no change to the security model.STAC.
STAC_Searchagainst Planetary Computer fails outright. The Item Search route returns HTTP 422. The catalog root returns HTTP 405. The argument names are correct —duckdb_functions()reportscol0, intersects, max_items, bbox, datetime, ids, collections— so the problem is the catalog URL contract, not the call. Thestaccommunity extension was also the only unpinnable dependency in the toolset, becauseINSTALL ... FROM communityalways resolves to the latest build for the installed DuckDB version.Removing it has a security benefit.
SET allow_community_extensions = falsenow runs before the firstINSTALL, 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.pyfor 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:
A check that allows only
SELECTstatements does not stop this. The unsafe part is a function argument, not the statement type.SET enable_external_access = falsealso does not work here. It is all-or-nothing. It blockshttpfstogether with local files, so remote parquet reads stop working.The setting that works is
SET disabled_filesystems = 'LocalFileSystem'. It blocks local disk, and it keepshttps://ands3://reads.One DuckDB behaviour affects the order of setup.
disabled_filesystemson 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 = trueruns last. After that, no query can change any setting above.The fourth layer checks the statement shape.
security.validate_select_onlyallows oneSELECTorWITHstatement. It also denies a small list of introspection functions, such asduckdb_secrets(). This layer is defense-in-depth only. It does not stopread_text('/etc/passwd'). Layer two stops that.The connection also sets
memory_limitandthreads. A watchdog thread callsinterrupt()after 30 seconds, because DuckDB has no native query timeout. The server clampslimitto 10,000 rows, so a caller cannot remove the cap.Tests
17 tests pass in 3.2 seconds.
./scripts/testpasses 36/36../scripts/lint(ruff and mypy) is clean.Every advertised source now has a real query test.
test_list_sources_advertises_nothing_untestedasserts that the set of sources inlist_sourcesequals 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 xINSTALL icuSET enable_external_access = truePRAGMA database_listSELECT * 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 thatglob('/home/**')andread_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.254returns 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_regionis fixed atus-east-1.lock_configurationmeans a caller cannot change it, so ans3://URL in another region fails. Use that bucket'shttps://endpoint instead. Plainhttps://reads are unaffected.Review notes
toolset.yamlraises the memory and CPU limits above the chart's 512Mi default.Try it locally:
Draft, because of the egress allowlist above. Please confirm where that control belongs before merge.