Version or commit
5b720ec
Environment
Linux x86_64, Python via uv; library + CLI repro
Minimal reproduction
On main @ 5b720ec, the single-SELECT gate only runs when writing a manifest.
-
Manifest path is gated — _stage_manifest_and_count calls reject_non_single_select(sql) before any DuckDB execute (src/hflow/curation.py ~705–709). The pinned surface in tests/test_catalog_curation.py (_SQL_SURFACE_PAYLOADS) covers multi-statement, DDL-only, PIVOT, PRAGMA/DESCRIBE/SHOW/SUMMARIZE, and the copy-escape shape.
-
Dry-run / report-only path is not gated:
# src/hflow/curation.py ~899–902
case None:
(row_count,) = connection.execute(f"SELECT count(*) FROM ({sql})").fetchone() or (
0,
)
No reject_non_single_select before that execute.
- CLI dry-run uses that path and never calls the gate itself:
# src/hflow/cli.py ~1489–1492
report = curate(
arguments.catalog,
sql,
output=None if arguments.dry_run else arguments.output,
)
-
Contrast: the hosted server always rejects first, then calls curate(..., output=None) (packages/hflow-server/src/hflow_server/_curation.py ~367–391). Library/CLI dry-run skip that boundary.
-
Same injection SQL that manifest-write refuses (SELECT 1; CREATE TABLE pwned AS SELECT 1 AS x, or the _SQL_SURFACE_PAYLOADS copy-escape row that closes the wrapper paren early) is executed on the dry-run branch because the gate never runs. Dry-run wraps SQL as SELECT count(*) FROM ({sql}), so copy-escape shapes that close that paren are especially relevant.
Not covered by the current open board: #558, #545, #528, #498, #491, #481 (open PR #542 is the redundant-parse cleanup, not this missing gate), #376, #287, #246. Open PRs #562 / #542 / #517 / #380 / #142 do not close this seam.
Expected behavior
Every curate() path that executes tenant SQL — including output=None / hflow curate --dry-run — should run reject_non_single_select (or share the same helper) before connection.execute / connection.sql, matching the manifest-write path and the server boundary.
Refused shapes from _SQL_SURFACE_PAYLOADS must raise NonSingleSelectQueryError / ValueError on dry-run the same way they do on manifest write, with no side-effect tables/files.
Actual behavior
reject_non_single_select is only invoked from _stage_manifest_and_count. Report-only curation skips it. HOSTING.md / CATALOG.md tell operators that curate(..., constrained=True) is the tenant SQL posture; that locks file access but does not apply the statement-count gate on the dry-run branch.
Additional context
Definition of done
- Call
reject_non_single_select(sql) before the case None: count query in curate() (preferred: one place so every destination path stays consistent).
- Extend the SQL-surface tests (or a sibling) so
curate(..., output=None) / CLI --dry-run refuse the same _SQL_SURFACE_PAYLOADS injection rows as _stage_manifest_and_count.
- Optional docs: note in HOSTING/CATALOG that the statement gate applies to report-only curate too, not only manifest materialization.
Non-goals
Version or commit
5b720ec
Environment
Linux x86_64, Python via uv; library + CLI repro
Minimal reproduction
On
main@5b720ec, the single-SELECT gate only runs when writing a manifest.Manifest path is gated —
_stage_manifest_and_countcallsreject_non_single_select(sql)before any DuckDB execute (src/hflow/curation.py~705–709). The pinned surface intests/test_catalog_curation.py(_SQL_SURFACE_PAYLOADS) covers multi-statement, DDL-only, PIVOT, PRAGMA/DESCRIBE/SHOW/SUMMARIZE, and the copy-escape shape.Dry-run / report-only path is not gated:
No
reject_non_single_selectbefore that execute.Contrast: the hosted server always rejects first, then calls
curate(..., output=None)(packages/hflow-server/src/hflow_server/_curation.py~367–391). Library/CLI dry-run skip that boundary.Same injection SQL that manifest-write refuses (
SELECT 1; CREATE TABLE pwned AS SELECT 1 AS x, or the_SQL_SURFACE_PAYLOADScopy-escape row that closes the wrapper paren early) is executed on the dry-run branch because the gate never runs. Dry-run wraps SQL asSELECT count(*) FROM ({sql}), so copy-escape shapes that close that paren are especially relevant.Not covered by the current open board: #558, #545, #528, #498, #491, #481 (open PR #542 is the redundant-parse cleanup, not this missing gate), #376, #287, #246. Open PRs #562 / #542 / #517 / #380 / #142 do not close this seam.
Expected behavior
Every
curate()path that executes tenant SQL — includingoutput=None/hflow curate --dry-run— should runreject_non_single_select(or share the same helper) beforeconnection.execute/connection.sql, matching the manifest-write path and the server boundary.Refused shapes from
_SQL_SURFACE_PAYLOADSmust raiseNonSingleSelectQueryError/ValueErroron dry-run the same way they do on manifest write, with no side-effect tables/files.Actual behavior
reject_non_single_selectis only invoked from_stage_manifest_and_count. Report-only curation skips it.HOSTING.md/CATALOG.mdtell operators thatcurate(..., constrained=True)is the tenant SQL posture; that locks file access but does not apply the statement-count gate on the dry-run branch.Additional context
Definition of done
reject_non_single_select(sql)before thecase None:count query incurate()(preferred: one place so every destination path stays consistent).curate(..., output=None)/ CLI--dry-runrefuse the same_SQL_SURFACE_PAYLOADSinjection rows as_stage_manifest_and_count.Non-goals