Skip to content

feat: tower-duckdb crate — sandboxed catalog queries and adversarial tests - #331

Merged
bradhe merged 9 commits into
developfrom
features/catalog-query-sandbox
Jul 27, 2026
Merged

feat: tower-duckdb crate — sandboxed catalog queries and adversarial tests#331
bradhe merged 9 commits into
developfrom
features/catalog-query-sandbox

Conversation

@bradhe

@bradhe bradhe commented Jul 24, 2026

Copy link
Copy Markdown
Contributor

What this is

This PR adds a tower-duckdb crate that owns Tower's usage of DuckDB, and wires its query sandbox into tower catalogs query. It carries Konstantinos's review feedback from #328 (read-only enforcement, session sandboxing, bounded results) forward as a reusable, tested foundation. The MCP tower_catalogs_query tool that also consumes the crate lands in a follow-up on #328.

Why it matters

Once an agent can issue SQL against a customer's catalog, the query text is untrusted input running on the customer's machine with their credentials. Read-only credentials are not enough on their own: a query can still read the local filesystem through read_csv('/etc/passwd'), reach internal network endpoints through table functions, load an arbitrary extension, or smuggle a second statement past a naive single-statement check. This PR closes that surface, puts the whole DuckDB integration in one crate so there is a single place to reason about it, and pins the invariants down with tests so a future change cannot quietly reopen them.

…ests

Agent-issued catalog SQL is untrusted, so before an MCP query tool can run
it we need the query path locked down and proof that the lockdown holds.
This lands that foundation without wiring it to a caller yet.

It adds a `sandbox` module with the gates a future `tower_catalogs_query`
tool applies: reject write/DDL and multi-statement input (the multi-statement
scanner skips separators inside strings and comments, which matters because
duckdb-rs `prepare` executes every statement but the last as a side effect),
and a session hardening set that disables local-filesystem access, blocks
community extensions, and locks the configuration. The hardening disables
only `LocalFileSystem`, so httpfs and the object-store reads an attached
Iceberg catalog depends on keep working. `run_duckdb_query` gains an optional
row cap that flags the result truncated, so a model cannot pull an unbounded
table into memory or its context.

The adversarial suite is the point: each test encodes an attack an agent
query might attempt (data tampering, statement smuggling, host filesystem
reads and writes, configuration escape, arbitrary extension loading, SSRF via
table functions) and asserts the sandbox refuses it. A testcontainers MinIO
test proves the same hardening does not break a real object-store Iceberg read
while local access and config changes stay blocked, and self-skips when no
Docker daemon is present so a plain `cargo test` still passes.
@coderabbitai

coderabbitai Bot commented Jul 24, 2026

Copy link
Copy Markdown

Important

Review skipped

Auto reviews are disabled on base/target branches other than the default branch.

Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: 887c3ea4-1905-4ab7-8a13-e2df5a974b3d

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch features/catalog-query-sandbox

Comment @coderabbitai help to get the list of available commands.

…crate and wire the sandbox in

The sandbox primitives from the previous commit had no caller: the gates,
the session hardening, and the row cap all lived in an inline `#[allow(dead_code)]`
module in catalogs.rs, proven only by tests. This moves Tower's DuckDB usage
into a dedicated `tower-duckdb` crate and makes `tower catalogs query` its first
real consumer, so the hardening runs in production rather than sitting on a shelf.

The crate owns the whole DuckDB surface: opening an in-memory Session, running
trusted setup, locking the session down for untrusted SQL (Session::harden), and
executing a query into JSON rows with an optional row cap. The static text gates
(reject write/DDL, reject multi-statement) live in its `guard` module. The value
conversion, the query execution, and the full adversarial + integration test
suite move with it, so the security invariants are tested next to the code they
protect. tower-cmd no longer depends on duckdb directly; it goes through the crate.

`catalogs query` now splits by mode. Read mode is the default and the sandboxed
path: the SQL is gated before it reaches DuckDB (a smuggled second statement
would otherwise run as a side effect of prepare, and a write gets a clear message
instead of a raw engine error), the session is hardened after attach, and rows
are capped with a truncation notice. Write mode stays the trusted power-user
opt-in and runs as before. Once this lands, #328 will layer the MCP
`tower_catalogs_query` tool on top of the same crate.
@bradhe bradhe changed the title feat(catalogs): DuckDB query sandbox primitives and adversarial tests feat: tower-duckdb crate — sandboxed catalog queries and adversarial tests Jul 24, 2026

@konstantinoscs konstantinoscs 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.

The SQL injection please. The rest is ok!

Comment thread crates/tower-duckdb/src/guard.rs Outdated
Comment thread crates/tower-cmd/src/catalogs.rs Outdated
… keyword denylist

The read-only gate checked only the first SQL keyword against a denylist. That
is not a safe policy: a `--` comment ends at a carriage return as well as a
newline in DuckDB, so a `-- x\rDROP …` payload looked empty to the scanner but
parses as a DROP, and a statement that opens with an allowed keyword (a `WITH`
CTE, for one) can still mutate. Addresses Konstantinos's review on #331.

The gate now runs the SQL through DuckDB's own parser via `json_serialize_sql`,
which parses without executing and serializes only SELECT statements, erroring on
anything else. `classify_read_only` returns Allowed only for exactly one SELECT;
everything else (writes, DDL, PRAGMA/SET, multi-statement, unparseable) is
refused, fail-closed. This is an allowlist of what the executor will actually
run, so the comment-terminator and CTE bypasses are caught, and the SQL is bound
as a parameter rather than spliced into the parser query. The keyword denylist
and the hand-rolled statement scanner are gone, along with their unit tests;
new tests cover the single-SELECT allowlist, the `\r` smuggling case, the
mutating-CTE case, and empty/multiple classification.
@bradhe
bradhe requested a review from konstantinoscs July 24, 2026 12:41
bradhe added 4 commits July 24, 2026 14:08
…ere it can't install

The Windows test job failed because the read-only gate runs `json_serialize_sql`,
which needs the `json` extension. It is not bundled, so DuckDB tried to
auto-download and install it, and the install fails on the Windows runner ("Could
not move file: Access is denied"). That is a real defect, not just a test
problem: the gate would be broken for Windows users too.

Enabling the duckdb `json` feature compiles the extension statically into the
bundled build, so it is available with no autoload or network. Verified it works
with `autoinstall_known_extensions` and `autoload_known_extensions` both off.

The `iceberg_scan` regression test needs the `iceberg` extension, which has no
such feature and must still be fetched at runtime. It now self-skips when that
install cannot happen, the same way the object-store test skips without Docker,
so the Windows job stops failing on an environment it can't satisfy while the
test keeps running where it can.
…d.rs

The gate's unit tests lived in lib.rs, away from the code they cover. This moves
them next to `classify_read_only` in guard.rs and broadens them into a proper
regression suite, so the parser-based read-only policy is pinned down where a
reader of guard.rs will find it.

The suite locks in the current DuckDB classification across the shapes that
matter: the many forms of a single read (plain SELECT, CTEs, set operations,
subqueries, VALUES/TABLE/FROM-first, DESCRIBE/SUMMARIZE/SHOW), that comments and
whitespace and semicolons inside literals don't change the verdict, that writes
and DDL and config/transaction/meta statements are rejected whatever the leading
keyword, that a leading SELECT can't launder a trailing mutation (the `\r`
comment-terminator and data-modifying-CTE bypasses), multi-statement and
empty/comment-only classification, fail-closed rejection of unparseable input,
and the shared-connection reuse path. It also documents one boundary explicitly:
a SELECT that reads a local file or URL is Allowed by the gate because the gate
classifies statement shape only; the session hardening is what refuses the read.

The redundant gate tests are dropped from lib.rs; its adversarial suite keeps the
shared `check` helper.
…nd bound queries

Parsing as a SELECT is a statement shape, not a read-only property, and the gate
was treating the two as the same thing. Verified against the DuckDB we ship
(v1.5.4): `SELECT nextval('s')` classifies as a plain SELECT and really does
advance the sequence, and `query()`/`query_table()` classify as SELECTs while
handing a string to the execution pipeline. Those are now refused by name from
the parsed tree, walking `function_name` nodes rather than matching text, so
comments and quoting cannot hide them.

The session lockdown gains the settings it was missing: no implicit extension
install or load, no unsigned extensions, secrets kept redacted, and optional
memory and temp-size ceilings, with `lock_configuration` still last because it
freezes every later SET. `Hardening` is now a struct so a caller that needs no
object storage can set `deny_external_access` and close network egress. The
default cannot: an attached Iceberg catalog is made of S3 reads, so egress stays
open on the catalog path and the docs now say so plainly rather than implying the
lockdown is total.

A row cap turned out to bound almost nothing, since one row can carry a whole
column (`string_agg`, `list`, `to_json`). Results are now bounded by rows, total
bytes, and wall-clock time together, via `Limits`; the byte ceiling is what
actually holds, and the timeout exists because DuckDB has no statement timeout of
its own, so it is enforced by interrupting the connection. `QueryResult.truncated`
carries which ceiling was hit so the CLI can say which one.

The docs now state that none of this is the security boundary: the read-only
credential and the READ_ONLY attach are, and everything here is defence in depth
in front of them. Tests pin each judgement against the shipped DuckDB, including
canaries that fail loudly on upgrade if data-modifying CTEs start parsing as
SELECTs or `prepare` stops executing leading statements.
Read queries are bounded by default so a runaway result cannot flood a terminal
or a model's context, but there was no way to ask for more. That makes the bound
a wall rather than a default: an agent (or a person) with a legitimate need for a
large extract had no option short of --write, which vends read-write credentials
for what is still only a read.

--max-rows sets the row ceiling and lifts the size ceiling with it, because a
caller who asks for a million rows should not then be cut short by a byte budget
they never set. --max-rows 0 removes the ceilings entirely. The help text says
plainly that a large result can exhaust memory, since that is the trade being
made. The read-only gate and the session hardening are untouched: this widens how
much data a read may return, not what a query is allowed to do.

The decision is factored into `query_limits` so it is unit-tested directly rather
than only through argument parsing.

@konstantinoscs konstantinoscs 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.

Add the function but otherwise lgtm! Thanks

Comment thread crates/tower-duckdb/src/guard.rs Outdated
/// catalog query legitimately reads Parquet and Iceberg over object storage.
/// Reaching the *local* filesystem through them is refused by the session
/// hardening instead.
pub const MUTATING_OR_DYNAMIC_FUNCTIONS: &[&str] = &["nextval", "currval", "query", "query_table"];

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.

You should add json_execute_serialized_sql here.

…s real

Review found three ways the sandbox did less than it claimed, and two ways the
tests hid it.

The function denylist was not fail-closed. `json_execute_serialized_sql` runs
whatever SQL it is handed and ships with the very `json` extension this crate
enables to build the gate, so the gate supplied its own bypass: the outer
statement is an ordinary SELECT and the effectful call hides in a string.
`enable_logging`, `checkpoint`, `setseed` and friends were allowed for the same
reason, that naming dangerous functions one at a time can never be complete.

The gate now allowlists the table-function position, which is where the danger
lives, so an unknown function is refused rather than waved through. Scalars are
too numerous to allowlist, so it asks the engine instead: DuckDB's
`has_side_effects` flags `nextval`, `setseed` and the rest, and keeps up with new
versions on its own. It is NULL for every table function, which is why those are
allowlisted rather than queried. An explicit list covers what neither reaches:
the dynamic-SQL executors and the effectful table functions. This tightens the
read path, since `read_csv`/`read_parquet` and the rest of that family are no
longer permitted; a catalog query reads base tables, which are not functions.

The byte ceiling measured a row after admitting it, so `SELECT repeat('x', 5e7)`
returned fifty megabytes and labelled it truncated. It now measures first and
withholds the row, which means a single over-budget row yields an empty truncated
result. That is blunt but honest, and it is what the ceiling was for. Because
that still only bounds what the caller is handed, the read path now hardens with
`Hardening::agent()`, which sets an engine memory limit for what a query spends
before the first row exists.

The SSRF test passed for a reason production does not share: its helper never
loaded `httpfs`, so there was no HTTP filesystem to block. It now loads the same
extensions `attach_statements` does, and counts connections to a listener it owns
rather than trusting an error string, with a control run so it cannot pass
vacuously. Note the hardening blocks these today only because disabling
LocalFileSystem breaks path resolution first; egress is still not closed by
design, and that gap is documented rather than papered over.

Also: the MinIO image is pinned to a release tag instead of `latest` and only
skips when Docker is genuinely unreachable, so a registry problem fails loudly
instead of silently disabling a security test; the iceberg skip is narrowed the
same way; and the workspace `rust-version` is corrected to 1.88, which the
toolchain and the dependency tree have required for some time.
@bradhe

bradhe commented Jul 27, 2026

Copy link
Copy Markdown
Contributor Author

Review fixes landed in a23beed

All five findings are addressed. One correction on the SSRF finding, with data.

P1 json_execute_serialized_sql — confirmed, fixed

Reproduced exactly as reported:

gate verdict for json_execute_serialized_sql  => Allowed
executed through json_execute_serialized_sql  => Ok("42")
gate verdict "SELECT * FROM enable_logging()" => Allowed
gate verdict "SELECT checkpoint()"            => Allowed
gate verdict "SELECT setseed(0.5)"            => Allowed

Self-inflicted, too: the json extension enabled to build the gate is what supplies the bypass. The deeper point, that a name denylist cannot be fail-closed, is the one I acted on. The gate now allowlists the table-function position, so an unknown function is refused rather than admitted, and asks DuckDB's own has_side_effects metadata about scalars so it keeps up with new versions without us maintaining a list. has_side_effects is NULL for every table function, which is exactly why that position is allowlisted instead. An explicit list covers what neither mechanism reaches: the dynamic-SQL executors and the effectful table functions.

This tightens the read path: read_csv, read_parquet, glob and that family are no longer permitted, since a catalog query reads base tables, which are not functions at all.

P1 byte ceiling — confirmed, fixed

limit=1MiB, actually returned 50,000,000 bytes in one row, truncated=Some(Bytes)

Measured after admitting the row. Now measured first and the row is withheld, so an over-budget first row yields an empty truncated result. Blunt, but it is what the ceiling was for. Since that only bounds what the caller is handed, the read path now hardens with Hardening::agent(), which sets an engine memory limit for what a query spends before the first row exists.

P1 SSRF — does not reproduce, but the test criticism was right

I could not reproduce an allowed read_text('http://…') making successful requests. I counted TCP connections to a listener I controlled rather than reading error strings. Control first, to prove the harness works:

harden=false  httpfs_loaded=true  net_reached=true   => OK rows=1
harden=true   httpfs_loaded=true  net_reached=false  => Permission Error: LocalFileSystem disabled

Then eleven vectors under hardening (read_csv, read_text, read_blob, read_json_auto, read_csv_auto, read_parquet, parquet_scan, iceberg_scan, replacement scan), including with the full production extension set of httpfs + iceberg + parquet loaded:

TOTAL internal connections under hardening: 0

If you have a working repro I would like it, since we may be on different hardening revisions or DuckDB builds.

That said, both of your other points stand and I fixed them. The block is incidental: it comes from disabled_filesystems='LocalFileSystem' breaking path resolution before HTTPFileSystem is consulted, not from any egress control, so it is not something to rely on. And the test genuinely passed for the wrong reason, since its helper never loaded httpfs. It now loads the same extensions attach_statements does, counts connections to a listener it owns, and includes a control run so it cannot pass vacuously. Egress remains open by design, because an attached Iceberg catalog is made of S3 reads; that is documented rather than papered over, and closing it needs a network boundary outside the process.

P2 both confirmed, fixed

MinIO is pinned to RELEASE.2025-09-07T16-13-09Z instead of latest, and the test now skips only when Docker is genuinely unreachable, so a registry outage or broken image fails loudly instead of silently disabling a security test. The iceberg skip is narrowed the same way. Workspace rust-version is corrected to 1.88, which rust-toolchain.toml and the dependency tree have required for a while; the old 1.81 was a promise the workspace could not keep.

…inux

CI caught what my local testing could not. The SSRF I reported as
non-reproducible does reproduce on Linux: three hardened queries reached the
probe listener there. The block I measured on macOS was incidental, disabling
LocalFileSystem breaks HTTP path resolution before the HTTP filesystem is
consulted, and Linux does not do that. Relying on it was the mistake; the
reviewer was right that the hardening is not an egress control.

Two holes are closed at the gate, which is platform-independent.

`SELECT * FROM 'http://…'` is a replacement scan: it parses as an ordinary base
table with the URL as its name, so the table-function allowlist never saw it.
Base-table references that name a file or a URL are now refused outright, with
their own verdict so the error can say what is actually wrong.

`iceberg_scan` is out of the allowlist. It takes a location, so allowing it left
a way to reach internal services even with the rest of the family refused. Every
remaining entry is a pure generator that accepts no path and no URL, which is the
property to preserve: adding anything there that takes a location reopens this.

The test now mirrors production by running the gate first and executing only what
it allows, because asserting that hardening alone blocks the network was
asserting something this crate's own documentation says is untrue. A companion
test records that hardening does not close egress, so the next person does not
rediscover it in CI.

Separately, the MinIO test failed on Windows because MinIO publishes Linux images
only, and my previous change had turned that into a hard failure. It now skips
when no Linux container runtime is available, decided from the platform rather
than inferred from a pull error, and still fails loudly for anything else.
@bradhe

bradhe commented Jul 27, 2026

Copy link
Copy Markdown
Contributor Author

Correction: the SSRF finding was right, and I was wrong

I said above it did not reproduce. That was a false negative from testing only on macOS. CI failed on Linux and proved it:

assertion `left == right` failed: a hardened query reached the network
  left: 6
 right: 3

Three hardened queries reached the probe listener on Ubuntu. The block I measured on macOS was incidental: disabling LocalFileSystem breaks HTTP path resolution before the HTTP filesystem is consulted, and Linux does not do that. So the data I posted was accurate and the conclusion drawn from it was not, because one platform is not the platform. Apologies for the pushback.

Your underlying point was the correct one throughout: the hardening is not an egress control, and treating it as one was the mistake.

Fixed in e337d08

Two real holes, both closed at the gate, which is platform-independent:

Replacement scans. SELECT * FROM 'http://…' parses as an ordinary base table carrying the URL as its name, so the table-function allowlist never saw it. This was the live vector, and it is exactly the case you named. Base-table references that name a file or URL are now refused, with their own verdict so the message can say what is actually wrong.

iceberg_scan accepted a location and was on my allowlist, leaving another way to reach internal services. It is out. Every remaining entry (range, generate_series, unnest) is a pure generator taking no path and no URL, and the doc comment now states that adding anything which takes a location reopens this.

The test also asserted the wrong thing. It executed SQL directly, so it was testing the hardening in isolation, which this crate's own docs say does not close egress. It now mirrors production by running the gate first and executing only what the gate allows. A companion test, hardening_alone_does_not_close_network_egress, records the platform-dependent behaviour so the next person does not rediscover it in CI.

Verified on Linux: 45 passed, 0 failed, 0 ignored in tower-duckdb, with the SSRF test doing real work rather than skipping, and the MinIO integration test running.

Note for reviewers

Read mode is now noticeably tighter than earlier in this PR. No read_csv/read_parquet/glob, no iceberg_scan, and no FROM 'some/path'. A read-only catalog query reads the attached catalog's own tables; anything else needs --write. Deliberate, and the right default for an agent-facing path, but worth flagging as a behaviour change.

@bradhe
bradhe merged commit 1e22a91 into develop Jul 27, 2026
29 checks passed
@bradhe
bradhe deleted the features/catalog-query-sandbox branch July 27, 2026 10:40
bradhe added a commit that referenced this pull request Jul 27, 2026
Resolves the conflict in catalogs.rs, which was entirely in that one file.

Two things had landed on develop since this branch was cut. #329 added the
`--full` overview to `catalogs show`, which this branch also carries its own copy
of, and #331 moved the DuckDB plumbing out into the tower-duckdb crate. The
overlap on `--full` is what made the conflict large: the same feature existed on
both sides.

Resolved by taking develop's catalogs.rs and re-applying only what is unique to
this branch, the `knowledge` command and its helpers, rather than hand-merging
hunks of duplicated work. So the `--full` implementation is develop's, which is
the version that was reviewed and merged and has since been through the
tower-duckdb refactor; `run_duckdb_query` and `duckdb_value_to_json` are gone
from this file because they now live in tower-duckdb, and their tests moved with
them; and `knowledge` is unchanged from this branch, with all eleven of its tests
restored.

Verified: 112 tests pass (develop's 101 plus this branch's 11), `catalogs
knowledge` and `catalogs query --max-rows` both work in the built binary, and
catalogs.rs has no clippy warnings.
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.

2 participants