Skip to content

Releases: lagaam-ai/lagaam

v0.2.4 — listed in the MCP Registry

Choose a tag to compare

@7Mudit 7Mudit released this 29 Sep 11:37
e66ff6c

Lagaam is now listed in the official MCP Registry as io.github.lagaam-ai/lagaam, so clients and directories that read the registry can find it. Installing works as before:

TRINO_HOST=trino.internal LAGAAM_ALLOWED_TABLES=hive.sales.orders uvx lagaam

What changed

  • The MCP handshake reports Lagaam's version. Until now serverInfo.version was the MCP SDK's version (e.g. 1.30.0), which is what clients and registries would have shown for Lagaam. It now says 0.2.4 (#46).
  • Registry listing (#46): a server.json describes the PyPI package, stdio transport and all 18 env vars (LAGAAM_ALLOWED_TABLES required, PINOT_PASSWORD secret). The PyPI README carries the registry's ownership marker. A glama.json claims the Glama listing.
  • Each release publishes the listing on its own. After the PyPI upload, the workflow waits until PyPI serves the version, then runs mcp-publisher v1.8.1 (pinned by sha256) and signs in with the run's own GitHub OIDC token, so no secret is stored. A release whose server.json version doesn't match the tag is refused.

Queries are checked, priced and refused exactly as in v0.2.3.

Verified

  • 1,176 unit tests + mypy strict.
  • server.json validates against the registry's 2025-12-11 schema. Tests fail CI if a version bump forgets it, if it lists an env var the server never reads, or if the ownership marker disappears.

v0.2.3 — uvx lagaam: install from PyPI, no clone

Choose a tag to compare

@7Mudit 7Mudit released this 27 Sep 16:42
0872dc1

Lagaam is on PyPI. Point it at the Trino you already have — no clone, no checkout:

TRINO_HOST=trino.internal LAGAAM_ALLOWED_TABLES=hive.sales.orders uvx lagaam

and wire it into any MCP client:

{
  "mcpServers": {
    "lagaam": {
      "command": "uvx",
      "args": ["lagaam"],
      "env": {
        "TRINO_HOST": "localhost",
        "LAGAAM_ALLOWED_TABLES": "hive.sales.orders,hive.sales.customers"
      }
    }
  }
}

LAGAAM_ENGINE=pinot with PINOT_CONTROLLER_URL / PINOT_BROKER_URL runs the native Pinot adapter the same way.

What changed

  • Installable as lagaam (#43). The distribution was lagaam-server and never published; it is now lagaam, with a lagaam console script. Running from a checkout (uv run python -m lagaam) still works.
  • Published from this release by GitHub Actions with PyPI Trusted Publishing — no API token exists. The workflow reruns the unit suite and mypy, refuses a tag that doesn't match the package version, smoke-tests the built wheel in a fresh venv, and waits for a maintainer's approval before uploading.
  • CI on every PR (#43), with CPU-time guards scaled to the runner's speed so a slow runner doesn't fail them (#45).

No change to how queries are checked, priced, or refused since v0.2.2.

Verified

  • 1,170 unit tests + mypy strict, locally and on CI.
  • The built wheel, installed in a fresh venv, driven by a real MCP stdio client against Trino 476: 3 tools listed, a grouped query answered, SELECT * refused with its hint.
  • Without LAGAAM_ALLOWED_TABLES it refuses to start (exit 2, says what to set).

v0.2.2 — a raw column's estimated cardinality no longer passes for a join key

Choose a tag to compare

@7Mudit 7Mudit released this 27 Sep 02:44
5cdd133

Upgrade if you run Lagaam against Apache Pinot. v0.2.1 could under-price a join on a raw (no-dictionary) column, and that let a query through that the row budget should have denied.

Who was affected

A Pinot table where:

  • a join column is stored without a dictionary, and
  • Pinot's optimised raw-column statistics are on, either through tableIndexConfig.optimizeNoDictStatsCollection: true or cluster-wide through pinot.stats.optimize.no.dict.collection.

With those statistics on, Pinot records a raw column's cardinality as an estimate once it holds more than about 2,000 distinct values. Lagaam read an estimate that reached the segment's row count as proof that the column was unique, and so priced the join as if each row matched at most once.

Measured on Pinot 1.5.1 with the v0.2.1 tag: 3,000 rows, 2,801 distinct values, one of them repeated 200 times.

column reported cardinality self-join pairs built v0.2.1 quoted v0.2.2 quotes
raw 3,000 (= row count) 42,800 9,000 9,006,000
dictionary 2,801 (exact) 42,800 9,006,000 9,006,000

Trino users, and Pinot joins on dictionary columns, were not affected.

What changed

  • Only a dictionary column's cardinality can prove a join key (#41, ADR 0009 amendment). A dictionary's size is an exact count of distinct stored values. A raw column's cardinality may be an estimate, and Lagaam cannot see the cluster setting that makes it one. Raw columns are now charged the full product, which is the safe direction.
  • A column that may hold nulls can now prove a key. This closes ADR 0009's open "null caveat". Measured under table-level null handling on and off, and under schema column-based null handling:
    • Pinot stores a null as the column's default and counts it once, so two nulls, or a null plus a literal default value, always lower the cardinality.
    • A cardinality equal to the row count therefore still means at most one match per row, whichever null mode applies at query time.
    • The old gate refused keys even on columns with no nulls at all. A self-join on such a table was quoted 120 where 30 was right.
  • The join-key rule now lives in one module, server/src/lagaam/adapters/pinot/keys.py (#40), so it can be audited in one place. This is a pure refactor with identical quotes, checked against 31 live query shapes.
  • The injection guard on the key-ordinal EXPLAIN now sees every column name it interpolates, including an empty one. That case was unreachable and failed safe before.

Verification

1,167 unit tests and 212 integration tests pass against live Trino 476 and Pinot 1.5.1 (batch and realtime), and mypy strict is clean. The integration suite creates its own Pinot test tables with nulls and skewed raw columns, and asserts on every case that the quote is at least the join pairs the engine actually builds. Every gate on the single-segment key rule is pinned by its own test, proven by mutation. Each change went through an independent review, and #40 and #41 through an external one (Astra).

Known gaps

  • Unique raw columns are now charged the product even when their count is exact. That is safe, but it may over-quote.
  • Hybrid tables remain unit-tested only.

v0.2.1 — your agent's Pinot SQL runs as written, and a real cluster gets a price

Choose a tag to compare

@7Mudit 7Mudit released this 26 Sep 19:27
1c898d5

Two Pinot fixes, both measured against a live cluster. Your agent's Pinot SQL now reaches the broker as written, and a Pinot cluster with more than one server is priced instead of refused.

The SQL your agent wrote is the SQL Pinot runs (#37, ADR 0010)

Every statement is re-rendered by sqlglot on its way to the broker, to inject the LIMIT and strip the catalog. Under sqlglot's generic dialect that re-render rewrote valid Pinot SQL. Measured on Pinot 1.5.1, both engines, over 55 shapes: 14 rewrites broke or changed the answer, and one valid shape was refused before it was sent.

your agent wrote the broker received effect
CAST(x AS STRING) CAST(x AS TEXT) error, the type the dialect card itself teaches
SUBSTR(c, 0, 1) SUBSTRING(c, 0, 1) ran, returned different rows
JSON_EXTRACT_SCALAR(c, '$.a', 'STRING', 'x') arguments 3–4 dropped error
LOG10, LOG2, TRUNCATE, STRPOS renamed error
VAR_POP, VAR_SAMP, BOOL_AND, BOOL_OR renamed error
ARRAY['a','b'] ARRAY('a','b') error on the multi-stage engine
ARRAY_AGG(c, 'STRING', true) nothing refused before it was sent

The SUBSTR row is the one that does not announce itself: Pinot's SUBSTR is 0-based start and end, SUBSTRING is 1-based start and length, so the query ran and the answer was wrong.

Pinot now has its own sqlglot dialect, which is configuration of sqlglot's parser and generator rather than a parser. The rule is preserve, never transpile. Re-measured over the same 55 shapes: 50 identical, 4 improved, 0 broken.

A multi-server Pinot cluster is priced, not refused (#38, ADR 0011)

GET /segments/{t}/metadata answers for one server. On a table spread over more than one server the completeness guard correctly refused to sum a subset, so every query on it was quoted low and denied. A cluster is a cluster because it has more than one server, so every real deployment was denied wholesale.

The guard stays and still decides. When it finds sealed segments missing, the adapter asks /segments/{t}/servers who holds them and fetches exactly those names with the controller's segments filter.

table v0.2.0 v0.2.1
2 servers, replication 1 denied, nothing quoted quoted high
4 servers, replication 2 denied, nothing quoted quoted high
1 server (control) quoted identical, and no new request made

Bounds, each of which leaves the quote exactly where v0.2.0 left it: request URLs under 6,144 bytes (the controller measured 8,059 accepted, 8,099 rejected), at most 64 requests including retries counted before the first call, and a 10 s deadline covering discovery too.

What the reviews caught before it shipped

  • An empty ARRAY_AGG() raised IndexError inside the parser, and the agent was told "internal error, retry" for SQL no retry can fix.
  • A segment whose metadata body was null counted as present, so a missing sealed segment was priced free at high confidence. This one predates the PR; the new fetch made it reachable.
  • Every missing name for one server went into a single GET URL; 2,000 names made httpx raise before any request was sent.
  • Server discovery ran outside the deadline.
  • The controller's answer to a filtered ask is one server's response, sometimes an empty one: the replicated table quoted low about 1 time in 10. A short answer is now asked again for exactly the missing names, twice at most. 40 live trials per table after: 0 low.
  • Batching 100,000 names blocked the event loop for 13.7 s before refusing. It now refuses in under 50 ms.
  • A pruned consuming segment was subtracted twice (ADR 0009 amended). After a time-based flush the server prunes the empty consuming segments, and the sealed charge then dropped two real sealed segments, covered only by the consuming projection's margin. Only the consuming segments pruning provably left now come off the count.

Verification

1,132 unit tests and 193 integration tests pass against live Trino 476 and Pinot 1.5.1 (batch and realtime), mypy strict clean on lagaam.core and lagaam.adapters.pinot, on main 1c898d5. Four external review rounds (Astra) across the two PRs. examples/pinot-realtime/bootstrap.sh now also creates a two-server and a replicated table, so the multi-server path is tested live.

Known gaps

  • The mechanism that makes the controller's filtered answer one server's, sometimes empty, is not established; only the behaviour is, on a four-servers-one-host cluster. A cluster with one host per server is unmeasured, and the retry rule holds either way.
  • Hybrid tables remain unit-tested only.

v0.2.0 — native Apache Pinot adapter: realtime rows priced, joins bounded by a proven key

Choose a tag to compare

@7Mudit 7Mudit released this 19 Sep 23:45
6b5fd43

Native Apache Pinot adapter. LAGAAM_ENGINE=pinot runs the same governed server against Pinot 1.5.1: queries on realtime tables are priced before they run, and joins are bounded when the catalog proves a key.

Lagaam demo: a consuming Pinot segment priced at its flush threshold, a keyless join blocked, the same join admitted on a proven upsert key

What Pinot turned out to be

Measured, not assumed:

  • No engine cost estimate exists: the multi-stage EXPLAIN reports rowcount = 100.0 for every scan, whatever the table holds, and priced a 954-million-row cross join at 10,000.
  • The multi-stage engine has no auto-LIMIT and ignores maxQueryResponseSizeBytes; the response ceiling is enforced client-side by streaming.
  • INSERT INTO ... FROM FILE reaches the query endpoint and is only refused by the AST check before it leaves the process.
  • Consuming segments report totalDocs: 0 and reportedSizeInBytes: -1; ten probes over 30 s while a segment demonstrably held 50 live docs never moved either number.
  • /segments/{t}/metadata truncated on a multi-server table: three consecutive calls on a 4-segment table returned one server's half, then the other, then the first again, a confident sum over a subset.

How a Pinot query is priced

  • The oracle reports how many segments survive a predicate, never which, so the adapter charges the k largest segments, independently by docs and by bytes.
  • The pruning counters (ByServer, ByValue, ByLimit) are maxed, not summed, and only those three are read; they are the three measured to nest on 1.5.1.
  • Every table is charged once per read. A UNION ALL of one table x60 quotes 584,760 docs / 291,681,300 bytes, exactly the independent truth, where the pre-fix logic would have quoted 1/60th.
  • Every join is the product of its inputs plus their sum, unless the catalog proves a key: airlineStats a JOIN airlineStats b ON a.Carrier = b.Carrier builds 10,719,442 pairs from 9,746 rows, an under-quote the "has an equality" shape-proxy would have missed by 1,100x.
  • Consuming segments are charged at their own stored flush threshold, unconditionally: 25 of 25 sealed segments held exactly 100 docs at a threshold of 100, so a segment seals at the threshold, not below it. The bytes charge is the one projected number in the whole quotation, flush_rows x the max bytes/doc ratio over the table's sealed segments.
  • A join key is proven only by scan ordinal, never by column name. The alias spoof SELECT Origin AS Carrier composes to ordinal 62 against the real Carrier's 18 and is refused: by name it would have quoted 205,524; by ordinal it is charged the product, 954,133,829.
  • Upsert keys count only without a TTL. With metadataTTL set, a re-ingested key stays visible twice (a self-join returned 10 pairs against 8 for a unique key), so a key is evidence only when mode is FULL or PARTIAL and neither metadataTTL nor deletedKeysTTL is above zero.

Shape, quoted, actual

shape quoted actual
freshness query (demo) 600 rows 600 scanned
twin self-join, no proven key (demo) 641,600 denied
upsert self-join, proven key (demo) 2,400 100 true pairs
live audit (U11, 35 shapes) n/a 0 under-quotes
live audit (U12, 74 shapes) n/a 0 under-quotes, 13 over-quotes above 100x, all denials

What the reviews caught before it shipped

  • Repeated reads of the same table were charged once instead of per read: a self-join under-quoted 9,746 vs 19,492 scanned, 60x on a UNION ALL case.
  • An equi-join was taken as proof of a key: ON a.Carrier = b.Carrier under-quoted 9,746 against a true 10,719,442-pair product, 1,100x.
  • A projected alias could spoof a key by name: SELECT Origin AS Carrier would have bought a false key; the ordinal-composition rule refused it (205,524 vs 954,133,829).
  • A LIMIT-less query trusted the single-stage EXPLAIN's own implicit LIMIT 10 prune while the multi-stage engine scanned everything: 200 quoted against 600 scanned realtime, 422 against 9,746 batch, a 23x breach.
  • LIMIT 10 OFFSET 9000 bypassed the scan budget: the planner priced it as though the OFFSET were absent, quoting 422 against 9,117 docs actually walked, a 21.6x breach.
  • The controller's ?columns= parameter is case-sensitive; a mismatched column name priced 36,739 bytes against a true 578,965, 15.8x.
  • An upsert key with metadataTTL set is not unique: a re-ingested key showed twice, and the adapter had cut the widest join step from 80 to 24 on the strength of a key that wasn't one.
  • The flush threshold was read from the table config instead of the segment's own stored value: a config change 100 to 10 left the live segment sealing at 100 while the adapter quoted 110 against 170 scanned, a lowered-threshold miss the fix closed by reading each consuming segment's own metadata.

Also in this release

  • The demo script and GIF: examples/demo_pinot.py and docs/demo-pinot.gif, three beats over the real server against live Pinot: a freshness query, a denied keyless join, an admitted proven-key join.
  • The pinot and pinot-realtime compose profiles, with examples/pinot-realtime/bootstrap.sh creating topics and tables idempotently, proven on Linux (ubuntu:24.04, GNU bash) from a fresh docker compose --profile pinot-realtime down -v, and idempotent on a second run.
  • Docs refreshed: ADR 0009 added (the consuming charge and the key evidence), ADR 0008's broker-counter justification corrected, docs/architecture.md gains the Pinot adapter.

Verification

1,031 unit tests and 146 integration tests pass against live Trino 476 and Pinot 1.5.1 (batch and realtime), mypy strict clean on lagaam.core and lagaam.adapters.pinot. git diff --stat main -- server/src/lagaam/core is empty across the adapter PRs. Three external review rounds (Astra) plus two live audits (35 shapes in U11, 74 shapes in U12).

Known gaps

  • Hybrid tables are unit-tested only: -type HYBRID cannot start in a container on Pinot 1.5.1, so the join/consuming arithmetic has no live test yet.
  • Multi-server tables are denied rather than under-quoted, until a per-segment metadata fetch lands in a later unit.
  • The null caveat on cardinality-based key proof is gated shut: whether cardinality counts a null or a default as distinct was never exercised on either quickstart dataset.
  • CAST(x AS STRING) renders as TEXT via sqlglot, which Pinot rejects. A usability gap, not a quoting fault; it fails safe.
  • Docker Desktop's default 7.65 GiB memory cap can OOM-kill Trino when both Pinot instances (batch and realtime) run alongside it.

v0.1.4 — a later CTE could vouch for a table the query never had

Choose a tag to compare

@7Mudit 7Mudit released this 16 Sep 22:21
999ba2a

Security patch — upgrade from v0.1.3. A CTE declared later in a WITH list vouched for a bare table name used earlier, so a query could read a table outside its grant by naming a CTE after it.

The bug

The allowlist resolves every bare name in a query: either a CTE declares it, or it must be a granted table. The check asked "is a CTE with this name declared anywhere in an enclosing WITH?" — but engines resolve a name against the CTEs declared before the reference. Inside the first CTE below, customer is not a CTE yet, so the engine reads the physical table:

WITH first AS (SELECT name FROM customer LIMIT 1),
     customer AS (SELECT k AS name FROM tpch.tiny.orders)
SELECT name FROM first

Measured, not argued: on Trino 476 with a session schema set, this returned the physical customer rows while only orders was granted; through the Pinot adapter, the same shape returned a row of the ungranted airlineStats while only baseballStats was granted. The pre-fix allowlist audited both as allowed.

Found by an external review of the Pinot adapter PR; reproduced live before fixing.

The fix

  • A CTE vouches only for references after its own declaration — a later sibling's body, or the query body. Its own body only when the WITH is RECURSIVE. Nested references several derived-table levels deep follow the same rule, and a duplicate name resolves to the first declaration, never a later shadow.
  • An oversized LIMIT is lowered to the row cap on the parsed tree, so the SQL that runs is the SQL the audit line records. An engine that downloads its whole result pays for every row a LIMIT allows: measured on Pinot, a 5-row budget survives LIMIT 6 and fails outright on LIMIT 5000 against the response ceiling. FETCH FIRST n ROWS ONLY is clamped the same way; an omitted count (FETCH FIRST ROWS ONLY, one row) is left as written; PERCENT and WITH TIES cannot be compared against a row count and are refused with the fix in the message.

Also closed since v0.1.3

Three quotation gaps in the row-generator pricing (ADR 0006), each measured against ground truth before and after:

shape quoted before actual after
GROUP BY x + 0 over two 1,000-row spines (#26) 1 1,000,000 1,000,000
EXISTS (SELECT 1 FROM part CROSS JOIN UNNEST(sequence(1, 10000000))) (#24) outer scan alone |part| x 10,000,000 built refused by the 1,000-row cap
x * CAST(0.4 AS bigint) as a group key (#27) full spine (over-quote) 1 group 1
  • A key that cannot merge two values is not a reduction (#26). x + 0, -x, abs(x), CAST(x AS varchar) merge nothing, so the scope emits every row the generator made; charging them as a collapse deleted the multiplier. 0 refusals across 18 legitimate spine and bucket queries after the fix.
  • A table anywhere under a predicate sizes the spine it crosses (#24). 13 of 16 attack shapes flipped from admitted to refused; 0 verdict changes across 42 legitimate EXISTS/IN spines. Memoising the walk took the unit suite from ~10s to ~3.5s.
  • A cast chain is evaluated, not stripped (#27). CAST(CAST(0.45 AS decimal(10,1)) AS bigint) is 0.45 → 0.5 → 1 on Trino, rounded half away from zero at each stage. 65 spellings checked against Trino 476: 12 over-quotes corrected, 0 under-quotes introduced. CHAR no longer reads as widening at any width (bare char is CHAR(1)).

Experimental: native Pinot adapter

LAGAAM_ENGINE=pinot starts the same MCP server against Apache Pinot 1.5.1. Agents ground themselves through list_catalogs/describe_table and query_data runs validated SQL on the multi-stage engine with the row, join, window, timeout and 64 MiB response ceilings enforced.

The quotation is not built yet, so query_data is denied under the default budget — pinned by tests, not hidden. Pinot reports no cost estimate at all: its multi-stage EXPLAIN says rowcount = 100.0 for every scan and priced a 954-million-row cross join at 10,000. The adapter will build its own upper bound from segment metadata next (U11). Until then treat Pinot as grounding-only.

Other things Pinot turned out to be, measured live and handled: the multi-stage engine has no auto-LIMIT and ignores maxQueryResponseSizeBytes (the ceiling is enforced client-side by streaming), INSERT INTO ... FROM FILE is accepted through the query endpoint (refused by the AST check before it leaves the process), and every error is an HTTP 200 whose message embeds server IPs (never forwarded to the agent).

Verification

706 unit + 118 integration green against dockerized Trino 476 and Pinot 1.5.1 together, mypy strict clean on lagaam.core, lagaam.adapters.pinot and the entrypoint. Every query option the Pinot adapter sends has an integration test that trips it, since Pinot ignores an unknown option name silently.

v0.1.3 — a generator could invent a billion rows the quote never saw

Choose a tag to compare

@7Mudit 7Mudit released this 23 Aug 16:41
091cdef

Security patch — upgrade from v0.1.2. A row generator could manufacture billions of rows while the quote reported only the table scan, so the row budget approved queries whose real cost was thousands of times what it was shown.

The bug

The row budget is a quotation: the gate prices a query from the engine's own plan before running it (ADR 0004). The planner cannot see rows a query manufactures from an argument — UNNEST(sequence(1, 1000)) over a 1.5M-row table plans as 1.5M rows and produces 1.5 billion — so generators are the one shape priced from SQL instead (ADR 0006).

That pricing had gaps. Each of these is a query the budget admitted on a number that was not close to true:

shape quoted actual ratio
orders CROSS JOIN UNNEST(sequence(1, 1000)) 1,500,000 1.5e9 1,000x
repeat(k, 1000000) AS arr → UNNEST scan alone 1e9 laundered
a spine a nested scope collapses scan alone up to 1e7 rows built 28,571x measured
UNNEST(a, b) … GROUP BY a (zipped) 1 5,000,000 5,000,000x
GROUP BY a.x + 1 over a spine scan alone 1.5e13 10,000,000x
GROUP BY x + 0, two such scopes scan alone 1e6 per scan row 1,000,000x

The last two are worth reading twice. Adding a second, redundant array to an UNNEST — or wrapping a group key in + 1 — was enough to make the multiplier disappear. Both cost one character of SQL to write.

The fix

  • Fanout is priced, not refused. generator_fanout() reports the multiplier and the Trino adapter scales the plan's own row estimate by it, so the budget decides with real cardinality in hand. A flat cap was written first and thrown away: it refused an 18-month daily spine and a month of hourly buckets.
  • Columns resolve through projections. A column feeding a generator is only as bounded as whatever bound it, so a projection that builds an array no longer reads as a scanned column — through expr AS name, column alias lists on every UNION arm, forwarding, and stars.
  • A zipped generator's column identifies the row it made. UNNEST(a, b) yields one row per index, so a sequence-backed column numbers those rows exactly as WITH ORDINALITY does. Only an array that provably runs distinct vouches for one — repeat(k, n) is the same value n times.
  • A key that cannot merge two values is not a reduction. Charging an unreadable GROUP BY as a collapse deletes a multiplier rather than keeping one, so x + 0 — which merges nothing — excused the generator. Such a key now partitions as the column it wraps; x % 7, length(x), round(x, -3) and a narrowing cast still read as reductions.
  • The cap asks per generator whether a table prices its rows, and "builds alone" now means a scope that provably emits one row — a bare aggregate, not a GROUP BY, whose group count belongs to the plan rather than to SQL.
  • Pre-auth DoS closed. Three unbudgeted resolver paths made ordinary width expensive inside the 200,000-char cap: 165 KB cost 33s and 12 GB of RSS before Trino was contacted; now 1.3s and 119 MB.

Verification

466 unit + 87 integration green against dockerized Trino 476, mypy strict clean on lagaam.core.

Measured end-to-end on a live cluster rather than argued from the AST: 1.5M orders x a 1000-row spine quotes 1.5e9 and the 50M budget denies it; the same spine over tpch.tiny quotes 5.49M and runs.

Over-blocking was measured at every step, never assumed — a denial the user cannot act on is a broken product too. An independent pass measured 0 refusals across 71 legitimate queries; one intermediate fix in this series refused 9 of 15 realistic bucket-spine queries and was replaced rather than shipped.

Twenty-one audit rounds, each re-auditing the previous round's fix instead of trusting it. That discipline paid: four of the bypasses above were introduced by earlier fixes in this same series and caught only because the fix itself was audited.

Known gap

A generator crossed with a table inside EXISTS/IN is charged no multiplier (#24) — WHERE EXISTS (SELECT 1 FROM part CROSS JOIN UNNEST(sequence(1, 10000000))) quotes the outer scan alone. Pre-existing since v0.1.2, and bounded: the counting cap still refuses anything past 10,000,000. A predicate subquery does not pair its rows with the outer row, which is why the multiplier is dropped — but it still builds them, and that size goes unpriced.

Two shapes over-quote rather than under-quote (#27): a truncating cast reads as a nonzero factor, and a bare CAST(x AS char) reads as widening where Trino defaults it to CHAR(1). Both deny on a number larger than the truth; neither admits anything.

Also unchanged: map_entries/split/|| feeding a generator are refused rather than priced — fail-closed by design.

v0.1.2 — a comment could hide nesting from the first guard

Choose a tag to compare

@7Mudit 7Mudit released this 09 Aug 18:26
0e4584e

Security patch — upgrade from v0.1.1. One apostrophe inside a SQL comment could hide arbitrary nesting from the guard that runs before anything else, letting a single small query burn coordinator CPU before it was ever priced.

The bug

validate_query scans raw text for bracket nesting before parsing, because sqlglot's recursive-descent parser blows its own stack on depth alone — a RecursionError no post-parse check can catch. That scanner tracked ' and " but had no notion of -- or /* */. An apostrophe inside a comment put it in "inside a literal" state for the rest of the query, so every bracket after it was skipped and the reported depth was 0.

Measured on this repo:

query scanner reads outcome
SELECT ARRAY[…18 deep…] 18 rejected, 0.00s
SELECT /* don't */ ARRAY[…18 deep…] 0 accepted, 5.16s
same at depth 22 0 accepted, 97.8s

Four spellings were reachable: -- and /* */, each carrying either quote character. This is the earliest code on the request path — before the table allowlist, before EXPLAIN, before any budget dimension — so an agent that can send one query could spend coordinator time without ever being quoted.

The fix

  • The scanner skips ---to-newline and /*…*/ spans. A comment marker inside a string literal is still data, and the brackets after it still count.
  • Text that ends inside a literal or an unterminated block comment is now refused outright. Any odd count of unmatched quotes leaves the scanner mid-literal, where the depth it holds is a floor rather than the truth — and the old code silently returned that floor. This matches how the rest of the gate treats an estimate it cannot trust: unmeasurable means denied.

Verification

381 unit + 87 integration green against dockerized Trino 476, mypy strict clean on lagaam.core.

The four comment spellings are pinned as parametrized tests that assert both rejection and sub-second rejection — before the fix the same payloads were killed by a 60s test timeout, so a regression fails loudly rather than hanging. They were mutation-checked against the pre-fix scanner: all four fail without it, so they pin behaviour rather than passing vacuously. A seeded fuzz over 400 combinations of comments, literals, escaped quotes and delimiters-as-data asserts the scanner never reads lower than the real depth — under-reporting is the direction that lets a payload through, and one point fix for comments would not have retired that class.

Found by a cold-read audit of the repository rather than by the work that shipped in v0.1.1.

v0.1.1 — the gates now hold: priced from the engine's plan, audited against a live cluster

Choose a tag to compare

@7Mudit 7Mudit released this 09 Aug 13:06
2acd852

Warning

Superseded by v0.1.2. A comment containing a quote character could hide arbitrary SQL nesting from the pre-parse guard, allowing a small query to burn coordinator CPU before any budget ran. Use v0.1.2.

Upgrade from v0.1.0 — this release closes gate bypasses. An adversarial audit against a live Trino cluster broke v0.1.0's gates in ways that matter, and this release ships the fixes. If you run v0.1.0, upgrade.

The big change: queries are priced from the engine's plan, not from SQL shape (#15)

Cardinality is a property of the data, not of syntax — so the gate now reads Trino's own per-operator row estimates (EXPLAIN (TYPE LOGICAL)) and gates on the widest intermediate any operator would build. A LIMIT 10 on top of a 900M-row cross join no longer hides the 900M rows. The SQL-shape heuristics this replaces are deleted (core/scans.py: 695 → 258 lines).

New budget dimension: LAGAAM_MAX_INTERMEDIATE_ROWS (default 50,000,000).

Gate bypasses found by audit, now closed

  • Read-only enforcement: SELECT ... INTO t parses as a Select, but Trino renders and runs it as CREATE TABLE AS — during EXPLAIN, before the budget gate. The gate now judges the SQL that will actually run, not only the SQL that parsed. The whole class is closed, not one construct.
  • Cardinality laundering: placing a LIMIT between the dirty and clean sides of a join made the dirty side read as clean. Only a grouping collapse (Aggregate/Distinct) can contain fan-out now, and the walk descends through Trino's partial stages (LimitPartial) instead of stopping above them.
  • Budget saturation: a scan count that ran out of measurement budget was quoted cheaper, not denied — a 61 GiB query admitted against a 3 GiB budget. Saturation now means unmeasurable, and unmeasurable means denied.
  • Gate as DoS target: a 722-character query could pin the coordinator for 32 seconds of planning before the gate answered. Planning the gate waits on is now capped.
  • Grant bypass via CTE names: a CTE defined in any subquery whitelisted its bare name in every scope. CTE names now resolve in their own scope only.
  • Credential leak: TrinoConnectionError/TrinoAuthError messages forwarded the internal hostname and auth token to the agent. Engine errors are now filtered to what the agent can act on.
  • Emitted-SQL bound: the length cap applied to input SQL, but rendering expands it (~1.33x) — the server now bounds the SQL it emits, not only what it received.

Also in this release

  • Date spines work now: UNNEST(sequence(...)) with literal bounds is priced at its actual row count instead of being refused outright — a 12-row month spine no longer gets blocked.
  • Parser safety bounds (bracket depth, AST depth) sit ahead of the parse — deeply nested payloads fail fast instead of hitting RecursionError or quadratic parse times.
  • Seven ADRs under docs/adr/ — all new since v0.1.0 — record the decisions above; README and docs/architecture.md describe the plan-based gate.

Verification

373 unit + 87 integration tests green (integration against dockerized Trino 476), mypy strict clean on lagaam.core. The integration suite commits the attack shapes — laundering variants, wrapped join keys, alias shadowing — alongside legitimate TPC-H analytics (Q21, star joins, correlated semi-joins), so the gate's behaviour on both is pinned by tests you can run: docker compose -f examples/docker-compose.yml --profile trino up -d && cd server && uv run pytest -m integration.

v0.1.0 — stop your agent from running $500 queries

Choose a tag to compare

@7Mudit 7Mudit released this 17 Jul 01:02
fc6fbb7

Warning

Superseded by v0.1.1. A post-release adversarial audit found gate bypasses in this version — including a read-only enforcement bypass and cardinality-gate laundering. Use v0.1.1.

First working release of Lagaam's governed MCP analytics server.

Point your agent at Trino through Lagaam and it gets schema-grounded, cost-guarded SQL access:

  • Schema grounding — list_catalogs / describe_table tools with row estimates, so the agent stops guessing table and column names
  • Pre-execution cost quotation — every query is priced via EXPLAIN (TYPE IO) before it runs; a query that would scan 48 GB against a 5 GB budget is blocked with a message telling the agent exactly how to fix it
  • Read-only enforcement — AST-level validation via sqlglot: single SELECT only, no DDL/DML, no SELECT *, no table-function passthrough, LIMIT always present
  • Query budgets — scan bytes, scanned-row estimate, returned-row cap, and wall-clock timeout, all enforced at the gate, all fail-safe when the engine can't produce an estimate
  • Per-agent identity + table allowlists — an agent sees and touches only the tables in its grant
  • Audit log — one JSONL line per tool call: who, what, allowed/denied, why
  • Result verification — zero-row, truncated, and all-NULL results come back with warnings the agent can act on instead of silently misleading it

199 tests (unit + integration against dockerized Trino 476), mypy strict on core.