Readyset Docs
Demos and TutorialsQueryPilot

Tutorial: Guard Policies

Enable Guard in shadow mode, write YAML policies that block and amend risky queries, watch decisions in the audit trail, and roll out to enforcement.

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

Guard is QueryPilot's policy-enforcement plugin. It runs inside the Router (binary querypilot), inspects every query before it reaches a backend, and can allow, amend, reroute, or block it, writing an audit record for every decision. It is built for traffic you do not fully trust: AI agents, third-party integrations, ad hoc tooling.

This tutorial enables the plugin in shadow mode, writes two policies, watches decisions accumulate, and then tightens to enforcement. The available actions are the shipped set: block, warn-level application via enforcement mode, and the amendments add_limit, add_timeout, and force_read_only, plus rerouting to another pool.

You need a Router deployment fronting PostgreSQL (see Getting Started).

Write the policies.

Policies are YAML files in a directory, loaded in alphabetical order (use filename prefixes like 10-, 20- for precedence within a severity). Create policies/:

policies/10-block-unbounded-delete.yaml blocks DELETE without a WHERE clause:

name: block-unbounded-delete
description: A DELETE with no WHERE clause wipes the table. Block it.
enabled: true
severity: critical
match:
  query_type: ["delete"]
  has_where: false
action:
  type: block
message: "DELETE without WHERE is blocked by policy. Add a WHERE clause."

policies/20-limit-unbounded-select.yaml caps unbounded scans instead of blocking them:

name: limit-unbounded-select
description: Cap SELECTs that carry neither WHERE nor LIMIT.
enabled: true
severity: high
match:
  query_type: ["select"]
  has_where: false
  has_limit: false
action:
  type: amend
  amendments:
    - type: add_limit
      value: 10000
notify: true

The pieces:

  • match conditions are AND-combined; omitted conditions match anything. Available conditions include query_type, tables (globs), has_where, has_limit, trust_level (comparison string like "< 2"), estimated_cost, risk_factors, time_of_day, and database.
  • action.type is one of allow, block, amend, or reroute (with target_pool). For block, the top-level message is returned to the client.
  • Amendments compose in order; add_limit appends LIMIT n to a SELECT that has none, add_timeout caps per-query execution time, force_read_only runs the statement read-only. An amendment that cannot apply to a statement is skipped.
  • severity interacts with session trust: critical policies always evaluate; lower tiers are skipped for sufficiently trusted sessions. With the default trust level (verified), critical and high policies fire and low policies do not.
  • notify: true sends the client a notice describing an applied amendment.

The full condition and trust-model reference is in the Guard guide.

Enable the plugin in shadow mode.

Add to querypilot.toml and mount the policy directory into the container:

[plugins.guard]
enabled = true
enforcement_mode = "shadow"
policy_dir = "/etc/querypilot/guard-policies"
default_trust_level = "verified"
api_key = "<your-api-key>"
audit_sink = { type = "stdout" }
docker run -d --name querypilot --network host \
  -v "$(pwd)/querypilot.toml:/etc/sqp/querypilot.toml:ro" \
  -v "$(pwd)/policies:/etc/querypilot/guard-policies:ro" \
  public.ecr.aws/readyset/querypilot:latest

In shadow mode Guard evaluates every query and audits the decision it would have made, but never applies it. Traffic is unaffected while you observe. Invalid policy files are logged and skipped, never fatal; confirm both loaded:

curl -s http://localhost:9091/api/v1/plugins/guard/policies

For sessions with different trust, add [[plugins.guard.trust_rules]] entries matching username, role (glob), or origin; for example, pin a known agent account to untrusted, which is blocked by default when no policy or registry entry explicitly allows the query.

Test policies without traffic.

POST /test dry-runs any SQL against the current configuration and returns the decision:

curl -s -X POST http://localhost:9091/api/v1/plugins/guard/test \
  -H "Content-Type: application/json" \
  -d '{"sql": "DELETE FROM orders"}'
# {"decision":"block","policy":"block-unbounded-delete","message":"DELETE without WHERE is blocked by policy. Add a WHERE clause.", ...}

curl -s -X POST http://localhost:9091/api/v1/plugins/guard/test \
  -H "Content-Type: application/json" \
  -d '{"sql": "SELECT * FROM orders"}'
# {"decision":"amend","policy":"limit-unbounded-select", ...}

The body also accepts optional username and trust_level fields to simulate a specific session. After editing a policy file, reload without a restart:

curl -s -X POST -H "X-Guard-Api-Key: change-me" \
  http://localhost:9091/api/v1/plugins/guard/policies/reload

Watch real decisions in shadow.

Run normal application traffic through the Router, then read the decision buffer and the audit sink:

curl -s "http://localhost:9091/api/v1/plugins/guard/decisions?limit=20"
docker logs querypilot | tail        # stdout sink: one JSON record per decision

Each audit record carries the timestamp, session, username, trust level, query fingerprint, statement type, tables, action, matching policy, amendments, and decision latency. For a persistent on-disk log, switch the sink to audit_sink = { type = "file", path = "/var/log/querypilot/guard.jsonl" }.

Let shadow mode run until the would-be blocks and amendments match your expectations: every block decision in the buffer is a query that will start failing when you enforce. Prometheus series to watch: sqp_guard_decisions_total (by action, policy, trust level), sqp_guard_decision_duration_seconds, and sqp_guard_audit_dropped_total (see metrics).

Roll out: shadow, then warn, then enforce.

enforcement_mode controls whether decisions bite:

ModeBehavior
shadowdecisions computed and audited, never applied
warndecisions applied, each application logged as a warning
enforcedecisions applied silently

Move one step at a time: set enforcement_mode = "warn" in querypilot.toml, restart the Router, and confirm applications behave with amendments actually applied (the warning log makes each application visible). Then set enforce. In enforce, the blocked DELETE now fails at the client:

psql -h localhost -p 5433 -U app -d mydb -c "DELETE FROM orders"
# ERROR:  DELETE without WHERE is blocked by policy. Add a WHERE clause.

Fail closed on unanalyzable queries.

Statements Guard cannot analyze (parse failures, statements over 64 KiB, pathological nesting) are governed by unanalyzable_action, which applies to non-read statements from sessions below privileged:

[plugins.guard]
unanalyzable_action = "block"

The default is log: pass the statement, log a warning, and increment sqp_guard_unanalyzable_total. Run on log first and watch that metric; once you have confirmed no legitimate traffic trips it, set block for the fail-closed posture. Plain reads and privileged sessions always flow regardless.

Where to go next

  • Guard guide for the trust model, severity gating, the trusted-query registry (the O(1) allowlist fast path), and the full REST surface.
  • Security for how Guard fits the wider deployment posture.
  • Operations for audit sink rotation and alerting.

On this page