Skip to content

AUDIT-02 — Critical — LIMIT ALL / huge limits bypass the row cap #59

Closed

Description

@getdownandcode

Summary

Severity: Critical
Area: Security / SQL
Affected files: agentkit/tools/builtin/sql_readonly.py:61-64 (_EXISTING_BOUND_REGEX), :289-305 (_apply_row_limit)
Suggested branch: fix/sql-row-limit-cap
Suggested PR title: fix(sql): enforce a real server-side row cap

This is finding AUDIT-02 of 18 from a consolidated audit of agentkit/. It is deliberately self-contained so it can be fixed on its own branch with its own PR.

Related findings: Depends on the same area as AUDIT-01; can be fixed independently.


AUDIT-02 — Critical — LIMIT ALL / huge limits bypass the row cap

Files

  • agentkit/tools/builtin/sql_readonly.py:61-64 (_EXISTING_BOUND_REGEX)
  • agentkit/tools/builtin/sql_readonly.py:289-305 (_apply_row_limit)

Current code — agentkit/tools/builtin/sql_readonly.py:61-64 and :289-305

#: A clause that already bounds the statement's own output. Such statements are executed
#: untouched so a caller-supplied bound is never widened.
_EXISTING_BOUND_REGEX = re.compile(
    r"(\blimit\b|\bfetch\s+first\b)",
    re.IGNORECASE,
)
def _apply_row_limit(sql: str, row_limit: int) -> str:
    """Bound the statement's output in SQL, preferring an appended LIMIT.
    ...
    """
    tail = _top_level_tail(_mask_literals(sql))
    if _EXISTING_BOUND_REGEX.search(tail):
        return sql
    if _LOCKING_CLAUSE_REGEX.search(tail):
        return f"SELECT * FROM ({sql}) AS agentkit_limited_query LIMIT {int(row_limit)}"
    return f"{sql} LIMIT {int(row_limit)}"

Why this is a bug

Any top-level LIMIT makes the statement pass through untouched, so LIMIT ALL (PostgreSQL's "no limit") completely defeats the row cap. The executed statement then has no server-side bound at all; the client-side fetchmany(row_limit) only limits what is displayed, not what the server computes.

Reproduced

validate_sql_query("SELECT * FROM runs LIMIT ALL")        -> ACCEPTED
_apply_row_limit("SELECT * FROM runs LIMIT ALL", 50)       -> "SELECT * FROM runs LIMIT ALL"   (no cap)
_apply_row_limit("SELECT * FROM runs LIMIT 1000000000",50) -> "SELECT * FROM runs LIMIT 1000000000"

Pass cases

  • LIMIT ALL (and LIMIT NULL, FETCH FIRST ROWS ONLY without a count) is replaced by the configured row limit.
  • A caller-supplied limit larger than the configured maximum is clamped down to the configured maximum.
  • A caller-supplied limit smaller than the configured maximum is left alone.
  • The effective limit is enforced server-side (assert the SQL handed to conn.execute() contains a bounded LIMIT), not only by the client-side fetchmany.
  • statement_timeout still bounds expensive uncapped scans (pg_sleep, cross join) — an existing timeout mechanism must not be weakened by the LIMIT rewrite.
  • Regression tests for LIMIT, FETCH FIRST n ROWS ONLY, LIMIT n OFFSET m, LIMIT inside a subquery (must not be treated as the outer bound), and FOR UPDATE (subquery-wrap path).


Definition of done (per AGENTS.md)

  • New/updated unit and integration tests covering the pass cases above, all passing (pytest).
  • ruff check ., ruff format --check . and mypy agentkit clean.
  • Docstrings updated where behaviour changed; docs/PROGRESS.md updated (status, PR link, commit count, notes).
  • A new ADR in docs/DECISIONS.md if an architectural decision was made.
  • Branch pushed and PR opened (never commit directly to main).

Baseline at the time of writing: pytest is green on main (all tests pass; tests/real/* skipped unless AGENTKIT_REAL_INFRA=1).

Activity

  1. added
    bugSomething isn't working
    securitySecurity vulnerability or hardening item
    on Oct 20, 2026
  2. getdownandcode commented on Oct 20, 2026

    @getdownandcode
    OwnerAuthor

    Audit index (18 separate issues, fix one by one)

    ID Issue Severity
    AUDIT-01 #58 Critical
    AUDIT-02 #59 Critical
    AUDIT-03 #60 High
    AUDIT-04 #61 High
    AUDIT-05 #62 High
    AUDIT-06 #63 High/Medium
    AUDIT-07 #64 Medium-High
    AUDIT-08 #65 Medium-High
    AUDIT-09 #66 Medium-High
    AUDIT-10 #67 Medium-High
    AUDIT-11 #68 Medium
    AUDIT-12 #69 Medium
    AUDIT-13 #70 Medium
    AUDIT-14 #71 Medium
    AUDIT-15 #72 Medium
    AUDIT-16 #73 Medium
    AUDIT-17 #74 Low
    AUDIT-18 #75 Low/Deployment

    Each issue is self-contained: current code, reproduction, pass cases, suggested branch and PR title. Reference the relevant one with Fixes #NN in your PR.

  3. HamzaKhanBUIC commented on Oct 20, 2026

    @HamzaKhanBUIC

    Opened PR with fix and regression tests: #77

    Summary of Resolution

    • Updated _mask_literals\ to preserve character length 1:1 so literal-masked positions align with original SQL.
    • Rewrote _apply_row_limit\ to parse top-level \LIMIT\ and \FETCH FIRST/NEXT\ clauses:
      • Replaces \LIMIT ALL\ and \LIMIT NULL\ with \LIMIT {row_limit}.
      • Clamps limits exceeding
        ow_limit\ down to the configured limit while preserving valid smaller limits.
      • Rewrites \FETCH FIRST/NEXT\ with omitted or oversized counts to \FETCH {dir} {row_limit} {rows} {only_ties}.
      • Leaves subqueries and locking clauses intact.
    • Verified: all unit tests passing,
      uff check .,
      uff format --check ., and \mypy agentkit\ clean.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't workingsecuritySecurity vulnerability or hardening item

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions