Skip to content

feat(sql-optim-env): add execution-grounded DuckDB SQL query optimization environment#1006

Open
OfficialAbhinavSingh wants to merge 3 commits into
huggingface:mainfrom
OfficialAbhinavSingh:feat/sql-optim-env
Open

feat(sql-optim-env): add execution-grounded DuckDB SQL query optimization environment#1006
OfficialAbhinavSingh wants to merge 3 commits into
huggingface:mainfrom
OfficialAbhinavSingh:feat/sql-optim-env

Conversation

@OfficialAbhinavSingh

@OfficialAbhinavSingh OfficialAbhinavSingh commented Jul 23, 2026

Copy link
Copy Markdown
Contributor

Summary

Adds sql_optim_env, an execution-grounded SQL query optimization environment. The agent receives a slow SQL query plus its schema and returns a rewritten query; reward is not a keyword heuristic — the environment executes both the original and the optimized query against an in-memory DuckDB seeded with synthetic data (10k users, 500k orders, 1k products, 1M events) and scores the agent on measured speedup and result-correctness across five anti-pattern tasks (basic anti-patterns → correlated subqueries → wildcard scans → implicit joins → window-function audits).

Fills a gap in the current catalog: there are coding, reasoning, and finance-QA environments, but none that grounds reward in real database execution.

Type of Change

  • Bug fix
  • New feature
  • Breaking change
  • Documentation
  • New environment
  • Refactoring

Alignment Checklist

Before submitting, verify:

  • I have read .claude/docs/PRINCIPLES.md and this PR aligns with our principles (reward is computed inside the environment from real execution; no domain logic leaks to the trainer)
  • I have checked .claude/docs/INVARIANTS.md and no invariants are violated (Gym-like reset/step/state; simulation controls are training-orchestration only, not agent-facing; the client never imports from server/)
  • I have run bash .claude/hooks/lint.sh and tests and addressed all issues

RFC Status

  • Not required (bug fix, docs, minor refactoring)
  • RFC exists: #___
  • RFC needed (will create before merge)

This is an additive community environment (new envs/ directory only), not a change to src/openenv/core/, so per the contribution guide it shouldn't need an RFC. Happy to write a short one if you'd prefer for a new environment.

Test Plan

  • PYTHONPATH=src:envs uv run pytest tests/envs/test_sql_optim_environment.py13 passed (env driven against real DuckDB: reset loads each task, step scores speedup/correctness, state round-trips; model serialization; client parse helpers).
  • End-to-end transport check (uvicorn server + real WebSocket client): SQLOptimEnv(base_url=...).sync()reset(task_id="task_1_basic_antipatterns") loads the query, step(...) returns a graded reward (0.68), state() round-trips. Round-trip passes.
  • docker build -f envs/sql_optim_env/server/Dockerfile . on ghcr.io/huggingface/openenv-base:latest — builds successfully.
  • python scripts/sync_env_docs.py --check, ruff check, ruff format --check — all clean.

Reviewers can drive it with:

with SQLOptimEnv(base_url="http://localhost:8000").sync() as env:
    obs = env.reset(task_id="task_1_basic_antipatterns").observation
    result = env.step(SQLOptimAction(
        optimized_query="SELECT id FROM orders WHERE customer_id = 5000",
        summary="Dropped SELECT * and the non-sargable CAST.",
    ))
    print(result.reward, result.done)

Claude Code Review

Self-check against the two-tier model: no bugs, uninitialized state, or debug code; reward stays inside the environment (PRINCIPLES.md), and the Gym reset/step/state boundary, agent/infra separation, and client↔server import rule (INVARIANTS.md) are all preserved. No alignment flags.


Context: I built the original for the Meta PyTorch OpenEnv hackathon and adapted it to the current envs/ conventions (models/client/server, connect4_env shape). Follows on from the sync-bootstrap work in #935 / #959.


Note

Medium Risk
Agent-authored SQL runs on the server, so sandbox correctness matters; the PR invests heavily in DuckDB isolation, read-only execution, and regression tests, but it remains a large new execution surface with no changes to OpenEnv core.

Overview
Introduces sql_optim_env, a new OpenEnv environment for SQL query optimization. Agents get a slow query and schema, submit suggestions plus an optimized_query, and receive a composite reward computed inside the server from actual DuckDB runs—not static heuristics alone.

The server seeds a temp DuckDB with synthetic tables (up to 1M rows), runs original vs. rewrite on a read-only, sandboxed connection (enable_external_access=false, locked config), measures speed via EXPLAIN ANALYZE (with bounded materialization for large results), and checks equivalence with row/sorted or SQL checksum paths. grade() blends execution speedup (only when results match), correctness, keyword-based issue detection, approval, summary, and severity; episodes are multi-step with last_execution feedback and end at max steps or score ≥ 0.95.

Five tasks (task_1task_5) progress from sargability/SELECT * through correlated subqueries, wildcard scans, implicit joins, and window-function audits. Packaging follows other envs: Pydantic models, SQLOptimEnv client, FastAPI create_app, openenv.yaml, Dockerfile, and synced docs (environments/sql_optim, catalog card). tests/envs/test_sql_optim_environment.py covers reset/step, sandbox escapes, grading edge cases, and large-result measurement.

Reviewed by Cursor Bugbot for commit 2bf81ee. Bugbot is set up for automated code reviews on this repo. Configure here.

Copilot AI review requested due to automatic review settings July 23, 2026 14:49
Comment thread envs/sql_optim_env/server/executor.py
Comment thread envs/sql_optim_env/server/tasks.py Outdated
Comment thread envs/sql_optim_env/server/tasks.py Outdated
Comment thread envs/sql_optim_env/server/executor.py Outdated
Comment thread envs/sql_optim_env/server/sql_optim_environment.py Outdated
Comment thread envs/sql_optim_env/server/tasks.py Outdated
Comment thread envs/sql_optim_env/server/sql_optim_environment.py Outdated

Copilot AI 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.

Pull request overview

Adds a new community environment, sql_optim_env, where agents rewrite slow SQL and are rewarded based on execution-grounded measurements from an in-memory DuckDB (speedup + result correctness), plus auxiliary rubric components (issue detection, approval, summary, severity). The PR follows the standard OpenEnv env layout (models/client/server), ships Docker + openenv.yaml, and wires the environment into the docs catalog.

Changes:

  • Introduces sql_optim_env server runtime (tasks, DuckDB executor, graders, FastAPI app) and client/models for typed transport.
  • Adds pytest coverage for reset/step/state and client parsing helpers.
  • Adds documentation page + environments index/toctree entries for the new environment.

Reviewed changes

Copilot reviewed 18 out of 18 changed files in this pull request and generated 4 comments.

Show a summary per file
File Description
tests/envs/test_sql_optim_environment.py Adds unit tests for env reset/step/state and client parsing helpers.
envs/sql_optim_env/server/tasks.py Defines five SQL anti-pattern tasks and metadata used by the environment.
envs/sql_optim_env/server/sql_optim_environment.py Implements the server-side Environment reset/step/state logic.
envs/sql_optim_env/server/scoring_models.py Adds internal Reward model returned by the grader.
envs/sql_optim_env/server/graders.py Implements execution-grounded grading and score breakdown.
envs/sql_optim_env/server/executor.py Implements DuckDB-backed query execution, timing, and correctness checks.
envs/sql_optim_env/server/Dockerfile Provides container build/run path for the environment server.
envs/sql_optim_env/server/app.py Creates the FastAPI app via create_app for WebSocket sessions.
envs/sql_optim_env/server/init.py Exposes SQLOptimEnvironment from the server package.
envs/sql_optim_env/README.md Environment README with quick start, schema, reward breakdown, and usage.
envs/sql_optim_env/pyproject.toml Declares the env package, dependencies (incl. duckdb), and server entrypoint.
envs/sql_optim_env/openenv.yaml Registers the environment spec for deployment/runtime.
envs/sql_optim_env/models.py Defines wire Action/Observation/State models.
envs/sql_optim_env/client.py Adds SQLOptimEnv client with payload/parse helpers.
envs/sql_optim_env/init.py Package exports and top-level docs/example.
docs/source/environments/sql_optim.md Adds docs page for the environment.
docs/source/environments.md Adds sql_optim_env to the environments catalog page.
docs/source/_toctree.yml Adds the environment docs page to the docs toctree.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment on lines +127 to +136
# Real execution comparison, carried back to the agent for its next step.
opt_q = (action.optimized_query or "").strip()
if opt_q:
try:
self._last_execution = get_executor().compare(
self._task_data["sql_query"], opt_q
)
except Exception:
self._last_execution = None

Comment on lines +123 to +133
# Grade (runs DuckDB internally).
reward = grade(self._task_data, action)
self._cumulative_reward += reward.score

# Real execution comparison, carried back to the agent for its next step.
opt_q = (action.optimized_query or "").strip()
if opt_q:
try:
self._last_execution = get_executor().compare(
self._task_data["sql_query"], opt_q
)
BIT_XOR is commutative+associative — order-independent fingerprint.
Falls back to count-only if the DuckDB version doesn't support the function.
"""
# Try BIT_XOR of a numeric hash (portable across DuckDB versions)
Comment on lines +133 to +137
env.reset(
task_id="task_2_join_optimization"
if "task_2_join_optimization" in TASKS
else DEFAULT_TASK_ID
)
Copilot AI review requested due to automatic review settings July 23, 2026 15:30
@OfficialAbhinavSingh

Copy link
Copy Markdown
Contributor Author

Thanks for the thorough review — all seven were real. Addressed in 34c1564:

Execution safety / correctness

  • Shared DB mutation — agent rewrites now run through a read-only guard (single SELECT/WITH statement only; DDL/DML and multi-statement inputs are rejected) and inside a transaction that is always rolled back, so a rewrite can never persist a change to the shared database. Added a test that DROP/DELETE/SELECT 1; DROP … are rejected and the tables are untouched.
  • Checksum broken by trailing ; — queries are normalized (trailing semicolons stripped) before they're wrapped in the COUNT(*) FROM (…) checksum subquery, so the >50k-row correctness path works. Test drives a ;-terminated 1M-row query.
  • Duplicate compare() per stepgrade() now carries its comparison on Reward.execution; step reuses it instead of executing both queries a second time. Test asserts compare runs exactly once per step.
  • Stale execution on empty rewritestep assigns reward.execution directly, which is None for an empty/failed rewrite, so a skipped step no longer reports the previous step's comparison. Test covers non-empty → empty.

Task definitions

  • Task 3 filter matched all rows — dropped the session_id LIKE 'sess_%' branch (every session_id starts with sess_). The filter is now event_type LIKE '%purchase%', whose result-preserving equality rewrite (event_type = 'purchase') is ~1.6× faster and passes correctness. Projection/SELECT * are reframed as detect-only issues (they'd change the output).
  • Task 5 unsolvable rewrite — the intended rewrite is now result-preserving (consolidate the PARTITION BY user_id windows); pre-filter and the global RANK are reframed as flag-only. Also fixed non-determinism: the ORDER BY occurred_at windows now break ties on id, so a correct rewrite can actually match. Dropped the SELECT * ground-truth issue (the query has no *).
  • Task 4 mislabeled subqueries — the two scalar subqueries are uncorrelated global constants; relabeled as such (types, description, keywords) — no more "correlated / per group".

Verified: 21 passed (13 existing + 8 new regression tests), ruff + sync_env_docs --check clean, and a live WebSocket round-trip on the reworked Task 3 (result-preserving rewrite, correct=true) plus the empty-rewrite path.

Comment thread envs/sql_optim_env/server/graders.py
Comment thread envs/sql_optim_env/server/executor.py Outdated

Copilot AI 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.

Pull request overview

Copilot reviewed 18 out of 18 changed files in this pull request and generated 3 comments.

Comments suppressed due to low confidence (1)

envs/sql_optim_env/server/executor.py:230

  • _checksum() executes queries on the shared DuckDB connection without any locking. Even if _run() is serialized, concurrent calls to _checksum() (from another session) can still race with _run()/compare() and corrupt results or raise driver errors.

Wrap these conn.execute(...) calls with the same lock used elsewhere so the singleton connection is always accessed serially.

            try:
                wrapped = sql_template.format(query=query)
                result = self.conn.execute(wrapped).fetchone()
                return result[0], result[1], None
            except Exception:
                continue
        # Final fallback: count only
        try:
            cnt = self.conn.execute(f"SELECT COUNT(*) FROM ({query}) t").fetchone()[0]
            return cnt, None, None

Comment thread envs/sql_optim_env/server/executor.py Outdated
Comment on lines +29 to +38
_FORBIDDEN_KEYWORDS = (
"insert",
"update",
"delete",
"drop",
"create",
"alter",
"truncate",
"replace",
"attach",
Comment thread envs/sql_optim_env/server/executor.py Outdated
Comment on lines +176 to +191
if isolate:
self.conn.execute("BEGIN TRANSACTION")
try:
for _ in range(runs):
try:
t0 = time.perf_counter()
rows = self.conn.execute(query).fetchall()
timings.append((time.perf_counter() - t0) * 1000.0)
except Exception as exc:
return 99_999.0, None, str(exc)
finally:
if isolate:
try:
self.conn.execute("ROLLBACK")
except Exception:
pass
Comment thread envs/sql_optim_env/server/executor.py Outdated
Comment on lines +162 to +164
def _run(
self, query: str, runs: int = 3, isolate: bool = False
) -> Tuple[float, Optional[List], Optional[str]]:
Copilot AI review requested due to automatic review settings July 23, 2026 15:41

Copilot AI 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.

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.

@OfficialAbhinavSingh

Copy link
Copy Markdown
Contributor Author

Addressed the second round (Bugbot) plus @copilot's remaining points in 55432ec:

  • Read-only filter too strict — dropped the keyword blocklist. _is_read_only now does two structural checks (single statement — no mid-query ; — beginning with SELECT/WITH), backed by the always-rolled-back transaction. A single SELECT/WITH statement can't mutate in DuckDB (no data-modifying CTEs), so the blocklist only produced false rejects on legitimate rewrites using REPLACE(...) or literals like 'set'. Test asserts those now pass while DROP / SELECT 1; DROP … / empty are still rejected.
  • Issue detection scanned the rewrite SQL_suggestions_text now scans only the agent's analysis (summary + structured suggestions), not optimized_query. Otherwise echoing the slow SQL farmed the detection slice without analysis. Test asserts an echo-with-no-analysis scores issue_detection == 0.
  • Test referenced a non-existent task key (@copilot) — test_state_tracks_episode used task_2_join_optimization, which isn't in TASKS (it's task_2_correlated_subqueries), so it silently fell back to the default. Fixed to target task 2 directly.

On @copilot's note about _run() materializing results three times: that's a deliberate median-of-3 timing for stability against noise — happy to make the run count adaptive for the 1M-row tasks if you'd prefer, but I kept the speedup signal stable for now.

Verified: 23 passed (13 original + 10 regression), ruff + sync_env_docs --check clean. Most of @copilot's other comments (empty-rewrite staleness, double compare, checksum ;, singleton DB) were the same issues fixed in 34c1564.

Comment thread envs/sql_optim_env/server/executor.py Outdated
Comment thread envs/sql_optim_env/server/executor.py Outdated
Comment thread envs/sql_optim_env/server/tasks.py Outdated
Comment thread envs/sql_optim_env/server/tasks.py Outdated
Comment thread envs/sql_optim_env/server/graders.py
Copilot AI review requested due to automatic review settings July 23, 2026 16:10

Copilot AI 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.

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.

@OfficialAbhinavSingh

Copy link
Copy Markdown
Contributor Author

Round 3 addressed in feeb579:

Execution engine

  • DML-in-CTE / read-only guarantee — the executor now builds the tables on a writable connection to a temp file, then reopens the database read-only. Every query (trusted task SQL and agent rewrites) runs on that read-only connection, so the DuckDB engine rejects any write regardless of form — including data-modifying CTEs — on any version, with no keyword heuristics. Test drives a WITH … DELETE and asserts it's rejected and the tables are untouched.
  • Shared connection concurrency — a single DuckDB connection isn't safe for the server's worker pool, so all connection access is now serialized under a lock. Added a 6-thread concurrency test.
  • Removed the now-redundant BEGIN/ROLLBACK isolation (the read-only connection is the guarantee) and added atexit cleanup of the temp DB dir.

Scoring

  • Speedup awarded without a correctness gate_speedup_score is now gated on results_match, so a trivially fast but wrong rewrite (e.g. SELECT 1 vs a heavy original) earns 0 for speed. Test asserts this.

Tasks

  • Task 2 last_order_amount unstable tie — added a unique id tie-breaker (ORDER BY created_at DESC, id DESC), so the subquery (and any correct rewrite) is deterministic.
  • Task 1 rewrite couldn't satisfy correctness — reframed like tasks 3/5: the required rewrite makes the CAST/year() predicates sargable while keeping the projection (result-preserving); SELECT * is a detect-only issue. Test asserts the intended rewrite matches.

Verified: 28 passed (13 original + 15 regression), ruff + sync_env_docs --check clean, live WebSocket round-trip green.

Comment thread envs/sql_optim_env/server/executor.py Outdated
Comment thread envs/sql_optim_env/server/executor.py Outdated
@OfficialAbhinavSingh

Copy link
Copy Markdown
Contributor Author

Addressed in HEAD (force-pushed). Both were false-rejections in the structural pre-check, so I removed the pre-check entirely: the read-only connection is the actual guarantee, so any write (DDL/DML, in a CTE, or after a ;) is rejected by the DuckDB engine and surfaces as optimized_error — no up-front SELECT/WITH check that could trip on a leading --//* */ comment or a ; inside a string literal.

Added a test that valid rewrites using REPLACE(...), a 'set' / 'a;b' literal, or a leading comment all run, while DROP / DELETE / SELECT 1; DROP … / a WITH … DELETE are still rejected and leave the tables intact.

Verified: 28 passed, ruff + sync_env_docs --check clean.

Copilot AI review requested due to automatic review settings July 23, 2026 16:20

Copilot AI 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.

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.

…izing rows

_run() fetched every result row into Python via fetchall(), three times over
for the timing median. For a query like SELECT * FROM events (1M rows) that
held the whole result in memory and made the measurement report tuple
construction rather than query execution: fetchall() took 783.6ms of which
DuckDB execution was 25.5ms, so 97% of the reported time was client-side
conversion.

Timing now comes from DuckDB's profiler (EXPLAIN ANALYZE -> Total Time),
which executes the query for real and discards the result inside the engine,
so no rows cross into Python. Rows are materialized only up to the 50k cap
compare() already used to pick its precise row-by-row comparison; above that
the exact row count comes from an in-engine COUNT(*) and correctness from the
existing order-independent checksum. A wall-clock streaming drain is kept as
a fallback for a DuckDB version whose profile footer cannot be parsed, so
timing degrades instead of breaking. The execution lock becomes reentrant so
a whole measurement stays serialized as before.

Comparing SELECT * FROM events goes from a 867.7MB peak and 4212ms reported
to 14.5MB and 18.5ms; task_5_window_functions compare() drops from 13.6s to
5.7s. Small-result queries pay one extra profiled execution (task_1 101ms ->
233ms); the total across all five tasks is still ~45% lower.

Behaviour is otherwise unchanged: writes are still rejected by the read-only
connection, row counts stay exact, and results under the cap are still
compared row by row.
Copilot AI review requested due to automatic review settings July 25, 2026 17:39

Copilot AI 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.

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.

@OfficialAbhinavSingh

Copy link
Copy Markdown
Contributor Author

Addressed the remaining fetchall() finding in 8f0133b.

_run() no longer materializes result sets in order to measure them. Timing now comes from DuckDB's own profiler (EXPLAIN ANALYZETotal Time), which executes the query for real and discards the result inside the engine, so no rows cross into Python. Rows are materialized only up to the 50k cap that compare() already used to pick its precise row-by-row comparison; above that, the exact row count comes from an in-engine COUNT(*) and correctness from the existing order-independent checksum. A wall-clock streaming drain is kept as a fallback for a DuckDB version whose profile footer can't be parsed, so timing degrades instead of breaking. The execution lock became reentrant so a whole measurement stays serialized as it was before.

The finding was measurable. For SELECT * FROM events, fetchall() took 783.6 ms of which DuckDB execution was 25.5 ms — 97% of the reported "execution time" was tuple construction. On this machine (duckdb 1.5.5):

before (b1776b1) after (8f0133b)
SELECT * FROM events peak Python memory 867.7 MB 14.5 MB
SELECT * FROM events reported ms 4212.0 18.5
task_5_window_functions reported ms 1945.4 359.0
task_5_window_functions compare() wall 13,644.8 ms 5,680.1 ms
all five tasks, total wall 18,038 ms 9,912 ms

Worth flagging the tradeoff rather than burying it: small-result queries now pay one extra profiled execution, so task_1_basic_antipatterns went 101 ms → 233 ms and task_4_implicit_join 1403 ms → 1856 ms per comparison. The total across all five tasks is still ~45% lower, and the pathological case is where the reward signal was actually being distorted.

Behaviour is otherwise unchanged: writes are still rejected by the read-only connection (DROP, DELETE, WITH d AS (...) DELETE, SELECT 1; DROP TABLE orders all still surface as optimized_error), row counts stay exact, and results under the cap are still compared row by row.

New TestLargeResultMeasurement class covers it. Against the previous executor the two substantive assertions fail:

assert 804.063 < (702.306 * 0.25)        # timing was reporting materialization
assert 552749658 < ((100 * 1024) * 1024) # 552 MB peak for a 500k-row compare

Env suite: 33 passed. Repo-wide (excluding tests/integration): 1539 passed, 131 skipped. ruff check and ruff format --check clean.

One note in case it comes up in a future review of this file: the over-cap probe leaves a partially-consumed result, and it deliberately isn't closed — in duckdb-python conn.execute() returns the connection itself, so close() there would close the shared read-only connection. The next statement supersedes the pending result instead; RSS stayed flat at 272 MB across 30 consecutive capped 1M-row runs. There's a comment on that in _probe.

Comment thread envs/sql_optim_env/server/executor.py Outdated
Comment thread envs/sql_optim_env/server/executor.py
…verified match credit

Two High-severity review findings on the execution engine.

The read-only connection stops the agent rewrite from changing the database,
but not from reaching the filesystem. On the previous commit an agent-authored
query could run `COPY ... TO` to write an arbitrary file (verified: the file
was created), `read_csv('/etc/passwd')` to read one, plus `read_text`,
`read_blob`, `read_parquet`, `glob`, `COPY FROM`, `EXPORT DATABASE`, `ATTACH`
and `INSTALL`/`LOAD`. Both connections now open with
`enable_external_access=false`, and with `lock_configuration=true` so a
rewrite cannot `SET` or `PRAGMA` its way back out (DuckDB additionally refuses
to re-enable external access on a running database). The escape hatches are
blocked on the `EXPLAIN ANALYZE` timing path as well.

Second, a rewrite ending in a `--` comment silently bought full correctness
credit. The checksum wraps the query in a subquery, and on a single line the
trailing comment swallowed the closing parenthesis, so the checksum failed and
equal row counts alone were reported as a match: `SELECT id FROM events -- c`
against `SELECT id + 1 AS id FROM events -- c` matched on a million rows that
all differed, unlocking the speedup slice too. `_as_subquery()` now puts the
query on its own line, and an over-cap result with no usable checksum reports
`results_match=False` instead of guessing, as does the surrounding except
path.

Note that the review's stated mechanism for the second finding -- `str.format`
raising on braces in the query -- does not reproduce: `format()` does not
rescan substituted values, and a brace-containing query checksums normally.
The trailing-comment path is the one that was exploitable.

TestSandboxAndCorrectnessCredit covers both; five of its six tests fail on the
previous commit. 38 env tests pass, 1544 repo-wide, ruff clean.
Copilot AI review requested due to automatic review settings July 25, 2026 17:58

Copilot AI 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.

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.

@OfficialAbhinavSingh

Copy link
Copy Markdown
Contributor Author

Both round-5 findings addressed in 2bf81ee. Both were real, and the first one contradicts something I claimed earlier in this PR, so I want to be explicit about that rather than present it as a tweak.

1. Untrusted SQL could reach the filesystem — confirmed, and broader than reported.

Across earlier rounds I argued that the read-only connection was "the guarantee" and that no query-level checks were needed. That holds for database writes only. It is not a sandbox. On 8f0133b an agent-authored rewrite could do all of this:

rewrite result on 8f0133b
COPY (SELECT * FROM users) TO '/tmp/exfil.csv' file was actually written
SELECT * FROM read_csv('/etc/passwd', header=false, columns={'line':'VARCHAR'}) returned the contents of /etc/passwd
SELECT * FROM read_text('/etc/hostname') arbitrary file read
SELECT * FROM glob('/home/<user>/*') directory listing
SET extension_directory='/tmp/pwn' accepted

So: arbitrary file read and arbitrary file write in the environment server process, driven by the model's own SQL. ATTACH happened to fail, but only as a side effect of read-only mode.

Both connections now open with enable_external_access=false and lock_configuration=true. Verified blocked after the change: COPY ... TO, COPY ... FROM, EXPORT DATABASE, read_csv, read_parquet, read_text, read_blob, glob, ATTACH, INSTALL, LOAD by path, and every SET/PRAGMA attempt to change enable_external_access, lock_configuration or extension_directory. DuckDB also refuses to re-enable external access on a running database ("Cannot enable external access while database is running"), so the lock is belt-and-braces rather than the only barrier. Table building on the writable connection and EXPLAIN ANALYZE on the read-only one both still work, and the escape hatches are blocked on the EXPLAIN ANALYZE timing path too — worth checking separately, since that path re-executes the agent's query.

2. Checksum fallback could fake a result match — confirmed, but not by the mechanism described.

The reported mechanism was that _checksum embeds the query with str.format, so a rewrite containing { or } raises. That part does not reproduce: format() does not rescan substituted values, so "...({query})...".format(query="SELECT '{a}'") returns the string unharmed, and _checksum("SELECT id, '{a}' AS tag FROM events") returns a normal (1000000, <checksum>, None).

The conclusion was right anyway, through a different door: a trailing -- comment. The checksum wraps the query in a subquery on one line, so SELECT id FROM events -- c became SELECT COUNT(*) ... FROM (SELECT id FROM events -- c) t — the comment swallows the closing parenthesis and it is a parse error. The checksum was lost, _row_count still succeeded via its drain fallback, and equal row counts with a missing checksum were then reported as a match. Verified exploit on 8f0133b:

compare("SELECT id FROM events -- c", "SELECT id + 1 AS id FROM events -- c")
  -> results_match: True   rows: 1000000 / 1000000

A million rows, every one of them different, awarded full correctness credit — and since correctness gates the speedup slice (added in round 3), that unlocked speedup credit as well.

Two changes: _as_subquery() now puts the query on its own line, so a trailing line comment can no longer reach the wrapper; and when an over-cap result has no usable checksum, results_match is False rather than True. Equal row counts are not evidence of equal data at that size, and an unverifiable comparison should earn no credit. The bare except fallback in compare() did the same guessing and now also returns False. With the wrap fixed this fallback is rare, so the conservative choice costs legitimate rewrites nothing I can find — the reordered/identical cases still match.

Verification. New TestSandboxAndCorrectnessCredit class, 6 tests; 5 of them fail on 8f0133b (AssertionError: not blocked: COPY ... for the sandbox ones, assert True is False for the credit ones). The sixth checks the timing path. Env suite 38 passed, repo-wide 1544 passed / 131 skipped (excluding tests/integration), ruff check and ruff format --check clean.

One inference rather than a verified fact, flagged as such: I have only tested this against duckdb 1.5.5 on Linux. The option names are stable across 1.x as far as I know, but I have not verified the behaviour on another version or platform.

@cursor cursor Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Cursor Bugbot has reviewed your changes using default effort and found 1 potential issue.

Fix All in Cursor

❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, enable autofix in the Cursor dashboard.

Reviewed by Cursor Bugbot for commit 2bf81ee. Configure here.

self.conn = duckdb.connect(self._path, read_only=True, config=_DUCKDB_CONFIG)
# Reentrant so `_run` can hold the lock across a whole measurement while
# the helpers it calls still take it individually.
self._exec_lock = threading.RLock()

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Agent SQL can hang shared DB

High Severity

Agent-authored SQL runs on a process-wide singleton connection under a global lock, with no memory_limit or execution timeout in _DUCKDB_CONFIG. A rewrite such as a multi-way cross join can wedge or OOM the whole server and block every other session while _run holds _exec_lock.

Additional Locations (2)
Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 2bf81ee. Configure here.

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