IMPORTANT
Every PR MUST have exactly one of these labels, the merge is blocked otherwise:
major/minor/patchpublishes a new release on merge with the corresponding semver bump.skip-releasemerges without publishing a release (use for docs, CI changes, refactors, etc.).
📝 Description
An http.sql block makes an http lane read the SQL inside JSON request bodies: Databricks' Statement Execution API (statement) and the workspace SQL MCP server (tools/call of execute_sql). Each SQL string becomes a statement of its own, so operation and table rules, the analyzer and the audit trail judge it as SQL. Before, a DROP passed a lane written to refuse it.
It also fixes two defects on the same path. A JSON response too large to mask is now refused; before, it was forwarded unmasked or it crashed the process. An operation rule naming unknown now matches client statements only; before, it refused every response.
📣 User-facing impact
The sidecar can block destructive SQL that agents send to Databricks through its SQL API and MCP server, and it refuses result downloads that would skip masking. This release also ships the Spark SQL dialect (ENG-823, #2012).
On an http lane with masking, a result the Sidecar cannot mask, such as a ClickHouse FORMAT JSON result over 8 MiB, now gets a 403 instead of crashing the Sidecar. A gzip result over 16 MiB once inflated is refused instead of passing unmasked.
🔗 Related Issue
ENG-817. It absorbs ENG-822, ENG-819 and ENG-893, and fixes the first half of ENG-545.
🚀 Type of Change
- 🐛 Bug fix (non-breaking change which fixes an issue)
- ✨ New feature (non-breaking change which adds functionality)
- 💥 Breaking change (fix or feature that would cause existing functionality to not work as expected)
- 📚 Documentation update
- 🎨 Style/UI update
- ♻️ Code refactor
- ⚡ Performance improvement
- ✅ Test update
- 🔧 Build configuration change
- 🧹 Chore
📋 Changes Made
sidecar/jsonsql(new, protocol-neutral): reads the SQL a JSON document carries, at a dotted path or in a JSON-RPCtools/call, and classifies it in the dialect. It knows no protocol, so another protocol that carries JSON can reuse it.sidecar/codec/http/sqlbody.go(new): the HTTP seam overjsonsql. It reads the SQL for configured routes. The route matches the resource thatcleanResource(ENG-895) already resolved, because Databricks routes.,..,//and\like the canonical path; the SQL route also drops a trailing slash. An MCPtools/callcarries SQL only for the named tools, and a JSON-RPC batch gives one statement per call.refuserejectsfield=valuepairs, and a non-string value at such a field, before forwarding. A body with no readable SQL isunknown. Only the bodies of the configured routes are kept.sidecar/daemon/httpsql.go,analyzer.go,config.go,httpcodec.go,schema.json: thehttp.sqlconfig block (cap:"http_sql", UI tags) and its validation. It is refused together with an analyzer that holds for approval (see Additional Notes).sidecar/analyzer: the SQL renders as SQL, at one model call per query. A resource trigger matches the SQL by the route that carried it (http.sql_resource).sidecar/policy/policy.go: anoperationrule namingunknownnever matches a response, on every protocol (ENG-893, ENG-545). Theoperationsscope of other rule types is unchanged.- libhoop pinned to
fd5e0b4on libhoopmain(hoophq/libhoop#141, merged):RefuseUnmaskable, andFlush(nil)discards (the ENG-822 crash).sidecar/codec/http/http.goturns it on. sidecar/gate,sidecar/proxy: a response refused after part of it reached the client is cut, without a 403 inside it (Decision.NoFrame). A close-delimited body refused at connection end gets the 403 (FlushResponseChecked). Both came from the Qodo review of libhoop#141. The gate asks the codec through two optional interfaces (responseStarter,checkedFlusher) and holds no protocol rule; a codec without them keeps the deny frame.sidecar/README.md: "SQL in request bodies: Databricks", the Spark dialect's config key, the masking paragraph, andunknownon responses.
🧪 Testing
Result: a DROP sent through the Statement Execution API or the workspace SQL MCP server is refused before Databricks runs it. Emails stay masked, and a request for results that would skip the sidecar is refused. Tested on acdce66c9 against origin/main 2c2f6ba45, against a real Databricks trial workspace, with no license (1 guardrail, 1 mask).
1. A DROP through the Statement Execution API and through MCP
- Bottom of each panel. On main both
DROPs return 200 andtables after: []. On the branch both return 403, and the tables remain. SELECT, EXTERNAL_LINKS. Main answers with a link to cloud storage, where the rows are not masked. The branch refuses it and namesINLINE.- The
SELECTlines. Emails are redacted on both sides.
2. A JSON result too large to mask
Same config and stand-in upstream on both sides, only the binary changes. On main, a 17 MiB gzip result goes out with 186,843 emails unmasked, and a 9 MiB plain result gets 403 and then the process dies (nil-pointer panic). The branch refuses both with 403 and masks the next response. A real workspace sends inline results in chunks of about 5.5 MB, so it does not reach this path.
3. The fail-closed unknown rule on an http lane
Both sides run the same rule. Main refuses every response, a SELECT included, but still runs the EXECUTE IMMEDIATE DROP. The branch lets reads through and refuses the hidden DROP before it leaves.
What it solves: Databricks customers who run the sidecar alone, on the free tier, get a deterministic read-only guardrail on the SQL their agents send through the Statement Execution API and the workspace SQL MCP server. Setup: hoophq/documentation#244.
Definition of done (independent QA, PASS on round 3):
- SEA
POST /api/2.0/sql/statementsproduces a statement with the body'sstatement; anoperationrule deniesDROP TABLE x, on 15 path spellings too. - The same for MCP
execute_sqlon/ai-gateway/mcp-services/*and/api/2.0/mcp/sql. - The analyzer receives the SQL as SQL, at one call per query.
-
dispositionEXTERNAL_LINKS,INLINE_OR_EXTERNAL_LINKSor2is denied, and the message namesINLINE. - A JSON response over the mask limit is refused, a gzip body over 16 MiB inflated included.
- An uncompressed JSON response over 8 MiB gets 403, the process stays up, and the next request works.
- On an http lane, a rule naming
unknownrefuses an unreadable client statement and lets responses through. - Docs page: hoophq/documentation#244.
- Masking as in ENG-777 T2 and T3, pinned by a test.
- A lane without the new fields behaves as today (same flow on main and the branch, identical normalized audit).
-
omitempty,cap:and UI tags;schema.jsonregenerated;make test-sidecarandmake test-sidecar-compatpass. - Works with no license.
Tests that guard it (each new behavior fails on main):
proxy:TestHTTPSQLBodyIsJudgedBeforeItIsForwarded,TestHTTPSQLNonCanonicalPathIsJudged,TestHTTPSQLRefusesAPaddedBody,TestHTTPSQLRefusesExternalLinks,TestHTTPSQLLaneMasksDatabricksResults,TestHTTPUnknownRuleJudgesRequestsNotResponses,TestOversizedJSONResponseIsRefusedAndTheRelayKeepsServing,TestGzipJSONInflatingPastTheMaskLimitIsRefused,TestChunkedStreamRefusedMidResponseIsCut,TestCloseDelimitedGzipTooLargeToMaskGets403.jsonsql:TestReadFindsTheSQL,TestRefuse,TestToolArgument.codec/http:TestSQLBody*(includingTestSQLBodyIgnoresWebSocketMessages).analyzer:TestHTTPSQLRequestIsOneModelCall,TestHTTPBuilderRendersBodySQLAsSQL.daemon:TestHTTPSQL*.policy:TestUnknownRuleDoesNotMatchResponses,TestUnknownScopeStillReachesResponses,TestValidResourcePattern.gate:TestARefusedResponseKeepsTheOneBeforeIt.
Re-run:
make test-sidecar && make test-sidecar-compat
cd sidecar && go test ./proxy ./codec/http ./analyzer ./daemon ./policy -run 'SQL|Unknown|Oversized|Gzip'Against a workspace: follow the docs page (hoophq/documentation#244). Its config and Verify commands are the ones this test used.
Test Configuration:
- Browser(s): n/a
- OS: macOS 26 (sidecar binaries built with
CGO_ENABLED=0)
Tests performed:
- Unit tests pass
- Integration tests pass
- Manual testing completed
📸 Screenshots (if applicable)
See Testing.
✅ Checklist
- My code follows the style guidelines of this project
- I have performed a self-review of my own code
- I have commented my code, particularly in hard-to-understand areas
- My changes generate no new warnings
- New and existing unit tests pass locally with my changes
- I have checked my code and corrected any misspellings
📄 Additional Notes
- Rebased 2026-10-29 on main
03b28d94b, then on2e9756300: only the libhoop lines ofclient/go.sumconflicted with #2090, and no other commit changed (git range-diff). The first rebase's only conflicts were the libhoop pins;dadd269b5re-pins libhoop and makes the SQL route reuse ENG-895's resource cleaning instead of its own.make test-sidecarandmake test-sidecar-compatpass on it. - After QA:
53e938e65and9b2e3ba33fix the two Qodo findings on libhoop#141, andcfc624081moves the JSON reading intojsonsqlwith no change in behavior (the existing tests pass unchanged). All three came after the round-3 PASS.make test-sidecarpasses oncfc624081andmake test-sidecar-compaton9b2e3ba33(rebased on main99eecdb8d); the new tests fail onacdce66c9. - Qodo review on this PR:
7838b17b4. Ajsonrpcmember hid a body's SQL (a real bypass: the Statement Execution API runs such a body); now it counts only where the path is underparams.arguments, which needstools. Tied patterns naming two paths refuse the request. Only theoperationrule exempts responses fromunknown. A later Qodo pass found that a WebSocket message on a SQL route was read as a request body;2956e59f1skips it. A third pass,d1482774a: responses finished before a refused one go out ahead of the deny frame (on main they were dropped withbr/zstdtoo), a route pattern that matches nothing is refused at load, and a trigger on the carrying request's method selects its SQL. Through the sidecar on the workspace, the DROP withjsonrpcnow gets 403. The new tests fail ondadd269b5. - Merge order: hoophq/libhoop#141 is merged;
3a140b17cpins itsmaincommitfd5e0b4in the 9go.modfiles, the same tree this PR tested. Merge hoophq/documentation#244 only after use.hoop.dev runs the release. - Feature flag: none.
http.sqlis opt-in, and a lane without it behaves as today. The two behavior changes fix a leak and a crash; a flag would keep both. - Sidecar ↔ Control Plane: the block carries
cap:"http_sql". An older sidecar refuses the key (unknown field "sql"), and the plane does not serve it to 1.267.x (checked by QA). The other way: a sidecar withhttp.sqland a token does not start on a plane without this change (refused the imported config: ... unknown field "sql", seen on use.hoop.dev). Upgrade the plane first (ADR-0022). On a plane built from this branch, the token flow runs the lane on the real workspace. No gateway ↔ agent change. - Departures from the approved design note:
- a flat block, because the listener form cannot render a list of objects;
- the request stays a statement of its own, which identity and
http_resourcerules need; - an approval-holding analyzer is refused with
http.sqlfor now, because an approval on body SQL would never be spent (noted on ENG-818); - route paths are cleaned.
- Risks: an
operationrule namingunknownnow never matches a response on any protocol. A Postgres or MySQL lane with anunknownrule stops refusing its responses, which is the ENG-545 fix. A JSON body too large to mask now gets a 403. - Follow-ups: ENG-818 (the Control Plane form). ENG-895 merged in #2058; this branch reuses its resource cleaning.
🤖 Generated with Claude Code
Activity
56✅ Build Completed with Success, Version=2057.0.0-g3cbfc91
✅ Build Completed with Success, Version=2057.0.0-g9b2e3ba
Code review by qodo was updated up to the latest commit https://github.com/hoophq/hoop/commit/d1482774a981f03ea183126266fbabf0d5a080bf
✅ Build Completed with Success, Version=2057.0.0-gd148277
Code review by qodo was updated up to the latest commit https://github.com/hoophq/hoop/commit/f9f945174e2c867dd0f26f2f2aff2c2b384003ea
✅ Build Completed with Success, Version=2057.0.0-gf9f9451
✅ Build Completed with Success, Version=2057.0.0-gacdce66