Readyset Docs

Guard

How Guard assigns trust to sessions, analyzes queries, and applies policies that block, amend, or reroute risky SQL before it reaches a backend.

What Guard does

Upcoming feature. Guard is not part of QueryPilot GA v1. It is documented here ahead of availability; details may change before release.

Guard (config block [plugins.guard]) is QueryPilot's policy-enforcement component. It runs inside the Router (binary querypilot) and inspects every query before it reaches a backend: it assigns the session a trust level, checks a trusted-query registry and a set of YAML policies, and then allows, amends, reroutes, or blocks the query, writing an audit record for every decision. It is built for environments where not all SQL is written by humans you trust: AI agents, third-party integrations, and generated queries from application frameworks.

Treat Guard's block policies as protection against accidents and misbehaving tools, an unbounded DELETE from an agent, a runaway full-table scan from a dashboard, DDL from a service account that should never issue it. It is not a hard security boundary against a determined adversary; enforce database-level privileges and network controls independently. See Security.

How a decision is made

For each query, Guard runs this pipeline under a per-decision time budget (max_decision_latency_ms, default 50 ms):

  1. Trust resolution. The session's trust level is computed once, from trust rules or else default_trust_level, and cached for the session.
  2. Registry fast path. The query's literal-agnostic fingerprint is looked up in the trusted-query registry. A hit applies the entry's action immediately; this is the O(1) path for pre-approved query shapes.
  3. Query analysis. The SQL is parsed and classified: statement type, tables touched, structural risk factors, and a heuristic cost estimate.
  4. Policy evaluation. Policies are evaluated in severity order (critical first), then alphabetically by name; the first matching policy decides. Higher session trust skips lower-severity policies entirely.
  5. Default action. If nothing matched, sessions at verified trust or above are allowed; untrusted sessions are blocked. An untrusted client can therefore only run queries something has explicitly approved.

Privileged sessions short-circuit to allow (with audit) before policy evaluation; critical-severity policies are the only ones that can still affect them.

Trust model

Four ordered trust levels:

LevelNameTypical usePolicy exposure
0untrustedunknown clients, AI agents, external toolsfull evaluation; blocked by default when nothing matches
1verifiedknown application identityfull evaluation
2establishedservices with a clean track recordskips low-severity policies
3privilegedDBAs, migration jobs, CI/CDaudit-only except critical policies

Severity gating: a policy of severity low is skipped for sessions at verified and above, medium is skipped at established and above, high is skipped at privileged, and critical is never skipped. Per-query cost stays proportional to how much you distrust the session.

Sessions get their level from [[plugins.guard.trust_rules]], evaluated in declaration order with last match winning; sessions no rule matches fall back to default_trust_level (default verified):

[[plugins.guard.trust_rules]]
match = { role = "service-*" }        # role is a glob
set_level = "established"
reason = "internal service accounts"

[[plugins.guard.trust_rules]]
match = { username = "ai_agent" }     # username is an exact match
set_level = "untrusted"
reason = "LLM-generated SQL"

Two matchers are supported: username (exact match against the authenticated client username) and role (glob pattern). All fields present in a match must hold for the rule to apply; a rule with no fields matches every session. Identify agent and tool traffic by giving each its own database credentials, then pin those usernames to untrusted.

Query analysis

The analyzer parses each statement and extracts what policies match on: the statement type (select, insert, update, delete, drop, and so on), the tables it touches, presence of WHERE and LIMIT, a heuristic cost estimate, and structural risk factors including missing WHERE on UPDATE/DELETE, unbounded SELECT, cartesian products, DDL, privilege changes, side-effecting functions, deep nesting, and multi-statement input.

Statements Guard cannot analyze, whether from parse failures, statements over 64 KiB, or pathological nesting, are handled by unanalyzable_action. It applies only to non-read statements from sessions below privileged; plain reads and privileged sessions always flow:

  • allow: pass silently.
  • log (default): pass, log a warning, increment sqp_guard_unanalyzable_total.
  • block: reject.

The default unanalyzable_action = "log" fails open: a write Guard cannot parse still reaches the backend. To fail closed, set unanalyzable_action = "block" in [plugins.guard]. Start on log, watch sqp_guard_unanalyzable_total, and tighten to block once you have confirmed no legitimate traffic trips it.

If a single decision exceeds max_decision_latency_ms, Guard fails open for reads and privileged sessions (allow, sqp_guard_timeouts_total incremented) and applies the unanalyzable_action handling for low-trust writes. Sustained timeout counts mean the policy set is too expensive; reduce policy count or matcher complexity.

Policies

Policies are YAML files in policy_dir, loaded at startup in alphabetical order (filename prefixes like 00-, 10- make a natural precedence convention) and reloadable at runtime via POST /api/v1/plugins/guard/policies/reload. Invalid files are logged and skipped, never fatal.

name: limit-untrusted-scans
description: Cap unbounded selects from low-trust sessions
severity: high            # critical | high | medium | low
enabled: true
match:
  query_type: [select]
  has_limit: false
  trust_level: "< 2"
action: amend
amendments:
  - type: add_limit
    value: 10000
notify: true              # send the client a notice when amended

All match conditions are AND-combined; omitted conditions match anything:

ConditionMatches on
query_typestatement type list (select, insert, update, delete, drop, ...)
tablestable name globs (orders, user_*), case-insensitive
has_where, has_limitpresence of WHERE / LIMIT
trust_levelcomparison against session trust ("< 2", ">= 1")
estimated_costcomparison against the heuristic cost score ("> 100000")
risk_factorsany of the analyzer's risk factors (NoWhereClause, CartesianProduct, FullTableScan, ...)
time_of_dayUTC window, wraps midnight ("22:00-06:00")
databasesession database list

Four actions ship:

  • allow: pass through; useful as a high-priority exemption above a broader policy.
  • block: reject with a structured error; message is returned to the client.
  • amend: rewrite the query before execution. Three amendment types ship: add_limit (append LIMIT n to a SELECT that has none), add_timeout (per-query statement timeout in seconds), and force_read_only (read-only transaction mode). An amendment that cannot apply to the statement is skipped; with notify_on_amend = true (the default) the client receives a notice describing the change.
  • reroute: send the query to a different pool (target_pool), for example steering expensive analytics to a replica or an Analytics pool.

Worked policy sets are in the Guard policies tutorial.

Trusted-query registry

The registry is the allowlist fast path: a YAML file (registry_file) of approved query fingerprints, loaded at startup and manageable at runtime over REST. Matching is by normalized fingerprint, so one entry covers a query shape for all literal values; entries record the normalized pattern SQL as well, defeating hash collisions.

entries:
  - fingerprint: "0xabcd1234deadbeef"
    name: "inventory lookup"
    action: allow
    approved_by: "jane@example.com"
    approved_at: 1709289600
    pattern_sql: "SELECT item.* FROM item WHERE store_uuid = $1"
    conditions:
      - type: trust_level_at_least
        value: verified

Entries can carry trust-level conditions (all must hold) and an optional expires_at; expired entries are evicted automatically. The registry is capped at max_registry_size (default 100000) and swapped atomically on reload. Use POST /api/v1/plugins/guard/test on a candidate query to obtain its fingerprint and see what the current configuration would decide.

Enforcement modes and rollout

enforcement_mode in [plugins.guard] controls whether decisions bite:

ModeBehavior
offplugin inactive
shadowdecisions computed and audited, never applied
warndecisions applied, each application logged as a warning
enforce (default)decisions applied silently

Guard itself always evaluates and always audits; the mode governs whether the Router applies the outcome. shadow therefore gives you a complete preview of production behavior. The recommended rollout:

  1. Deploy in shadow; watch GET /api/v1/plugins/guard/decisions and the audit log until the would-be blocks and amendments look right.
  2. Move to warn and confirm applications behave under real amendments.
  3. Move to enforce.

Switching modes is a config change plus reload; there is no runtime API for it.

Audit trail

Every decision produces a JSON record: timestamp, session id, username, trust level, query fingerprint, statement type, tables, action, matching policy (if any), reason, amendments applied, and decision latency in microseconds. Records flow through a bounded channel (audit_channel_size) to the configured sink, and the most recent ones (audit_buffer_size) stay readable in memory at GET /api/v1/plugins/guard/decisions?limit=N.

Two sinks ship: audit_sink = { type = "stdout" } (one JSON line per record, the default, suited to container deployments shipping logs via a sidecar) and audit_sink = { type = "file", path = "/var/log/querypilot/guard.jsonl" } (JSONL appended; new files are created mode 0640 on Unix).

Operational notes for the file sink:

  • Rotation by rename (logrotate's default) silently detaches the sink from the visible file. Use copytruncate, or rotate by pointing new processes at time-stamped paths; the file descriptor is held for the sink's lifetime and there is no SIGHUP reopen.
  • If audit throughput exceeds the channel, records are dropped rather than blocking queries. Alert on sqp_guard_audit_dropped_total and on sqp_guard_audit_file_write_errors_total (disk full, permissions).

REST API

Mounted at /api/v1/plugins/guard on the Router's admin API. Mutating endpoints require the configured api_key in the X-Guard-Api-Key header (or a bearer token); with no key configured, everything is open, acceptable only for loopback development. The surface covers listing and reloading policies, reading and mutating the registry, dry-running a query (POST /test with { sql, username?, trust_level? }), reading recent decisions, and stats. The full endpoint list is in the admin API reference.

Monitoring

Key Prometheus series: sqp_guard_decisions_total (by action, policy, trust level), sqp_guard_decision_duration_seconds, sqp_guard_registry_hits_total / sqp_guard_registry_misses_total, sqp_guard_amendments_total (by type), sqp_guard_timeouts_total, sqp_guard_unanalyzable_total, and the audit-sink counters above. The most common "policy never fires" causes are severity gating (a low policy is invisible to verified-and-above sessions) and a match condition that does not hold on real traffic; check GET /decisions for what was actually evaluated, and POST /test to reproduce. See the metrics reference.