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: trueThe pieces:
matchconditions are AND-combined; omitted conditions match anything. Available conditions includequery_type,tables(globs),has_where,has_limit,trust_level(comparison string like"< 2"),estimated_cost,risk_factors,time_of_day, anddatabase.action.typeis one ofallow,block,amend, orreroute(withtarget_pool). Forblock, the top-levelmessageis returned to the client.- Amendments compose in order;
add_limitappendsLIMIT nto a SELECT that has none,add_timeoutcaps per-query execution time,force_read_onlyruns the statement read-only. An amendment that cannot apply to a statement is skipped. severityinteracts with session trust:criticalpolicies always evaluate; lower tiers are skipped for sufficiently trusted sessions. With the default trust level (verified),criticalandhighpolicies fire andlowpolicies do not.notify: truesends 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:latestIn 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/policiesFor 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/reloadWatch 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 decisionEach 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:
| Mode | Behavior |
|---|---|
shadow | decisions computed and audited, never applied |
warn | decisions applied, each application logged as a warning |
enforce | decisions 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.