{"id":50016480,"url":"https://github.com/pgrls/pgrls","last_synced_at":"2026-06-30T00:00:39.332Z","repository":{"id":354590140,"uuid":"1220250277","full_name":"pgrls/pgrls","owner":"pgrls","description":"Static analyzer for Postgres Row-Level Security — 47 lint rules covering tenant and per-user row-scoping bugs, performance traps, and hygiene; 17 mechanically auto-fixable; semantic policy-diff command for CI gating; pytest plugin for RLS isolation tests.","archived":false,"fork":false,"pushed_at":"2026-06-23T20:48:18.000Z","size":5853,"stargazers_count":16,"open_issues_count":3,"forks_count":2,"subscribers_count":0,"default_branch":"main","last_synced_at":"2026-06-23T22:26:09.224Z","etag":null,"topics":["ci","linter","multi-tenant","postgres","postgresql","rls","row-level-security","security","static-analysis","supabase"],"latest_commit_sha":null,"homepage":null,"language":"Python","has_issues":true,"has_wiki":null,"has_pages":null,"mirror_url":null,"source_name":null,"license":"mit","status":null,"scm":"git","pull_requests_enabled":true,"icon_url":"https://github.com/pgrls.png","metadata":{"files":{"readme":"README.md","changelog":"CHANGELOG.md","contributing":"CONTRIBUTING.md","funding":null,"license":"LICENSE","code_of_conduct":null,"threat_model":null,"audit":null,"citation":null,"codeowners":null,"security":"SECURITY.md","support":null,"governance":null,"roadmap":null,"authors":null,"dei":null,"publiccode":null,"codemeta":null,"zenodo":null,"notice":null,"maintainers":null,"copyright":null,"agents":"AGENTS.md","dco":null,"cla":null}},"created_at":"2026-04-24T17:51:28.000Z","updated_at":"2026-06-23T20:48:23.000Z","dependencies_parsed_at":null,"dependency_job_id":null,"html_url":"https://github.com/pgrls/pgrls","commit_stats":null,"previous_names":["pgrls/pgrls"],"tags_count":151,"template":false,"template_full_name":null,"purl":"pkg:github/pgrls/pgrls","repository_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/pgrls%2Fpgrls","tags_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/pgrls%2Fpgrls/tags","releases_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/pgrls%2Fpgrls/releases","manifests_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/pgrls%2Fpgrls/manifests","owner_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/owners/pgrls","download_url":"https://codeload.github.com/pgrls/pgrls/tar.gz/refs/heads/main","sbom_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/pgrls%2Fpgrls/sbom","scorecard":null,"host":{"name":"GitHub","url":"https://github.com","kind":"github","repositories_count":286080680,"owners_count":34947088,"icon_url":"https://github.com/github.png","version":null,"created_at":"2022-05-30T11:31:42.601Z","updated_at":"2026-05-26T15:22:16.424Z","status":"online","status_checked_at":"2026-06-29T02:00:05.398Z","response_time":58,"last_error":null,"robots_txt_status":"success","robots_txt_updated_at":"2025-07-24T06:49:26.215Z","robots_txt_url":"https://github.com/robots.txt","online":true,"can_crawl_api":true,"host_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub","repositories_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories","repository_names_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repository_names","owners_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/owners"}},"keywords":["ci","linter","multi-tenant","postgres","postgresql","rls","row-level-security","security","static-analysis","supabase"],"created_at":"2026-05-20T04:02:26.436Z","updated_at":"2026-06-30T00:00:39.306Z","avatar_url":"https://github.com/pgrls.png","language":"Python","funding_links":[],"categories":[],"sub_categories":[],"readme":"# pgrls\n\n[![PyPI version](https://img.shields.io/pypi/v/pgrls.svg)](https://pypi.org/project/pgrls/)\n[![Python versions](https://img.shields.io/pypi/pyversions/pgrls.svg)](https://pypi.org/project/pgrls/)\n[![License: MIT](https://img.shields.io/pypi/l/pgrls.svg)](https://github.com/pgrls/pgrls/blob/main/LICENSE)\n[![CI](https://img.shields.io/github/actions/workflow/status/pgrls/pgrls/test.yml?branch=main\u0026label=tests)](https://github.com/pgrls/pgrls/actions/workflows/test.yml)\n[![Downloads](https://img.shields.io/pypi/dm/pgrls.svg)](https://pypistats.org/packages/pgrls)\n\n**[▶ 23-second demo](https://raw.githubusercontent.com/pgrls/pgrls/main/docs/screencast.svg)** · **[Quickstart](docs/QUICKSTART.md)** · **[Rule reference](AGENTS.md)** · **[Docs site](https://pgrls.github.io/pgrls-docs/)** · **[CHANGELOG](CHANGELOG.md)** · **[PyPI](https://pypi.org/project/pgrls/)**\n\n\u003e **Static analyzer for Postgres Row-Level Security.**\n\u003e Catches the policy bugs eyeball-review misses — broken row scoping (across tenants *and* between users in the same tenant), inverted auth checks, write-side holes; 20 of 64 rules mechanically auto-fixable.\n\u003e `pgrls diff` classifies every migration **SAFE / BREAKING / REQUIRES_REVIEW / DANGEROUS** so CI gates on real regressions, not safe schema changes.\n\u003e MIT, framework-agnostic (Supabase, PostgREST, Hasura, Django, raw SQL), CI-native (text / JSON / SARIF / Markdown / GitHub-PR-comment / GitHub annotations / JUnit XML).\n\n\u003c!--\n  Animated SVG cast generated by termtosvg from the demo recipe in\n  docs/screencast.md. Click-through opens the raw SVG which animates\n  in the browser (GitHub's \u003cimg\u003e sandbox renders the first frame as a\n  static preview). Both src and href use absolute raw.githubusercontent\n  URLs so PyPI's project page renders the image too — relative paths\n  break there. Re-record with `bash docs/screencast.md`'s recipe after\n  a feature ships.\n--\u003e\n\u003cp align=\"center\"\u003e\n  \u003ca href=\"https://raw.githubusercontent.com/pgrls/pgrls/main/docs/screencast.svg\"\u003e\n    \u003cimg src=\"https://raw.githubusercontent.com/pgrls/pgrls/main/docs/screencast.svg\" alt=\"pgrls 60-second tour\" width=\"780\"\u003e\n  \u003c/a\u003e\n\u003c/p\u003e\n\n\u003e **Beta — actively maintained.** 64 lint rules, 20 mechanically auto-fixable, [semantic policy-diff command](#diff--pgrls-snapshot--pgrls-diff), pytest plugin for RLS isolation tests. Tested on PostgreSQL 15, 16, 17. Stable JSON / SARIF schema for CI integrations. The [CHANGELOG](CHANGELOG.md) records every release; current build is shown by the PyPI badge above.\n\u003e\n\u003e - **Lint \u0026 fix** — `pgrls lint` checks a live database against all sixty-four rules and reports findings as text, JSON, SARIF, Markdown, GitHub-PR-comment (`--format pr-comment`), GitHub Actions annotations (`--format github`), or JUnit XML (`--format junit`) for CI. `pgrls fix` auto-remediates the mechanically-fixable rules (SEC001, SEC002, SEC004, SEC006, SEC010, SEC011, SEC015, SEC017, SEC019, SEC020, SEC030, SEC031, SEC032, SEC044, PERF001, PERF003, PERF004, HYG003, VIEW001, VIEW002) — to stdout or a migration-ready `.sql` file (`--output`). `pgrls lint --baseline` records existing findings so CI fails only on *new* ones, letting a team adopt pgrls on a legacy database without clearing the whole backlog first.\n\u003e - **Generate** — `pgrls generate` scaffolds gold-standard RLS for tables that lack it — per-tenant (`tenant_id`) or per-user (`--model owner`, incl. the Supabase `auth.uid()` form): ENABLE + FORCE, an isolation policy, a restrictive floor, and the index, output designed to lint clean. Don't trust your ORM's RLS; generate correct RLS, then lint it.\n\u003e - **Test** — the `pgrls.testing` pytest plugin for writing RLS tests: role switching, per-test transactions, and tenant-isolation assertions.\n\u003e - **Snapshot \u0026 diff** — `pgrls snapshot` / `pgrls diff` is a semantic RLS-policy diff that classifies every change SAFE / BREAKING / REQUIRES_REVIEW / DANGEROUS. Z3-based predicate analysis is built in, plus migration-as-input — apply a migration to an ephemeral Postgres and diff the result (`pip install pgrls[diff-apply]`), with `CREATE EXTENSION` auto-detection and a cached-baseline Docker image for fast re-runs.\n\u003e - **TypeScript port** — [`pgrls-test`](https://www.npmjs.com/package/pgrls-test) on npm implements the same RLS-testing contract for JS/TS — both `pg` and `postgres.js` driver adapters, vitest-friendly. See [`ts/`](ts/) in this repo.\n\u003e - **VS Code extension** — [`pgrls/pgrls-vscode`](https://github.com/pgrls/pgrls-vscode) wraps the CLI; `pgrls: Lint database` surfaces findings as diagnostics in the Problems panel, with hover documentation per rule.\n\n## Install\n\npgrls needs **Python 3.11+** (and Postgres 15+ to lint against) — check `python --version` first. On an older Python, `pip install pgrls` fails with the cryptic `Could not find a version that satisfies the requirement pgrls` rather than a clear version error.\n\n```bash\npip install pgrls   # requires Python 3.11+\n```\n\npgrls is tested in CI against PostgreSQL 15–17 (see [`.github/workflows/test.yml`](.github/workflows/test.yml) for the matrix).\n\n## Lint without a live database\n\nNo database to point at? Hand pgrls your migrations and it builds a throwaway Postgres for you — applies them in order, introspects, lints, and tears it down:\n\n```bash\npip install 'pgrls[ephemeral]'          # adds testcontainers; needs Docker\npgrls lint --migrations ./migrations    # auto-detects Supabase / Prisma / Flyway / sqitch / plain .sql\npgrls lint --supabase                   # shortcut for ./supabase/migrations + the auth.* stubs and roles\n```\n\n`--migrations` takes a directory or a single `.sql` file; the layout is auto-detected (override with `--migrations-layout` / `--migrations-glob`), and `--create-role NAME` pre-creates any role your policies reference. It's the zero-setup way to gate RLS in CI — no service container, no `DATABASE_URL`, just Docker.\n\n**No Docker either?** Hand pgrls the DDL directly — it parses it offline (no\nPostgres, no container) and checks what it can determine from the SQL alone:\n\n```bash\npgrls lint --sql-file schema.sql                 # repeat --sql-file per file (order matters)\ncat migrations/*.sql | pgrls lint --sql-file -   # or pipe a concatenated diff\npgrls snapshot \u003e schema.json \u0026\u0026 pgrls lint --snapshot schema.json\npgrls fix --sql-file schema.sql \u003e fixes.sql      # emit-only offline\npgrls generate --sql-file schema.sql             # scaffold RLS offline\n```\n\nOffline analysis is **sound but partial**: rules that need live catalog state\n(BYPASSRLS roles, SECURITY DEFINER functions, triggers, …) can't fire, so\npgrls *skips and lists them* (`skipped_rules` in `--format json`). **An offline\nexit 0 is not a clean bill of health** — add `--require-full-coverage` to make\na partial offline run fail your CI gate. It's the zero-dependency way to lint\nthe RLS in a PR's `.sql` diff.\n\n## Real-world bugs pgrls catches\n\nThe kind of mistake that ships to prod despite policy review:\n\n```sql\nCREATE POLICY tenant_read ON public.documents\n    FOR SELECT TO authenticated\n    USING (auth.uid() IS NULL OR owner = auth.uid());\n```\n\nLooks fine — and is structured the way many RLS tutorials show it. But `auth.uid()` returns `NULL` for any connection without a session JWT. For those connections the `IS NULL` branch is `true`, the `OR` short-circuits, and the policy admits *every* row of `public.documents`. It's a recurring pattern in multi-tenant Supabase / PostgREST projects — the kind of thing a public CVE write-up names by hindsight.\n\n`pgrls` flags it as **SEC004** (severity `error`) in milliseconds. With `--explain`, the rule's reference paragraph is appended underneath the finding (lines hard-wrapped here for the README; the real output is one long line per paragraph):\n\n```\n$ pgrls lint --rule SEC004 --explain\n  ERROR  SEC004  public.documents.tenant_read\n         Policy 'tenant_read' on public.documents contains a top-level\n         `auth_func() IS NULL` disjunct in its USING clause. For anonymous\n         connections that disjunct evaluates to true, satisfying the policy\n         and exposing every row. Remove the IS NULL disjunct or replace with\n         an explicit deny.\n\n         The pattern: a policy with USING (auth_func() IS NULL OR \u003creal check\u003e).\n         auth_func() returns NULL for anonymous connections, so the IS NULL\n         disjunct is true and the OR is satisfied without ever evaluating the\n         real check. Anonymous clients see all rows.\n\npgrls: 1 error.\n```\n\nRLS isn't only about keeping tenants apart. The same bug class bites *within* a single tenant, when rows are meant to be per-user:\n\n```sql\nCREATE TABLE documents (id uuid, tenant_id int, owner_id uuid, body text);\nALTER TABLE documents ENABLE ROW LEVEL SECURITY;\nCREATE POLICY tenant_scope ON documents\n    USING (tenant_id = current_setting('app.tenant')::int);\n```\n\nCross-tenant reads are blocked, so this passes a tenant-isolation review. But there's an `owner_id` column and nothing keys on it, so every user *in* a tenant reads every other user's documents. If that table holds drafts, DMs, or private uploads, it's a leak. **SEC027** (info) flags the table so you decide: add a per-user predicate, or confirm it's intentionally tenant-shared and allowlist it.\n\n[Browse the full rule catalogue in AGENTS.md](docs/RULES.md#rule-sec004) for the other 41 — missing `WITH CHECK`, `BYPASSRLS` roles, per-row auth-function evaluation, search-path attacks, view-mediated RLS bypasses, and more.\n\n## Usage\n\nScaffold a config (optional — `pgrls` runs with zero config):\n\n```bash\npgrls init                     # writes a commented pgrls.toml; --force to overwrite\npgrls init --preset supabase   # tailor the conventions to a stack\n```\n\n`--preset` (`generic` · `supabase` · `postgrest` · `neon`) tailors the documented tenancy convention and the exact `pgrls generate` command for that stack — rules stay at their defaults.\n\nPoint `pgrls` at any Postgres database:\n\n```bash\nexport DATABASE_URL=\"postgres://user:pass@host:5432/db\"\npgrls lint\n```\n\nOr pass the URL directly:\n\n```bash\npgrls lint --database-url \"postgres://user:pass@host:5432/db\"\n```\n\nLimit the scan to specific schemas:\n\n```bash\npgrls lint --schemas public,tenant\n```\n\nPoint at a non-default config file, or pick an output format:\n\n```bash\npgrls lint --config ./config/pgrls.toml --format text     # human-readable (default)\npgrls lint --config ./config/pgrls.toml --format json     # machine-readable for CI\npgrls lint --config ./config/pgrls.toml --format sarif    # GitHub Code Scanning\npgrls lint --config ./config/pgrls.toml --format markdown   # rendered CI reports / runbooks\npgrls lint --config ./config/pgrls.toml --format pr-comment # collapsible per-rule GitHub PR comment\npgrls lint --config ./config/pgrls.toml --format github     # GitHub Actions run annotations\npgrls lint --config ./config/pgrls.toml --format junit    # JUnit XML for CI test reports\n```\n\nOr run only specific rules — handy when scoping a SARIF report in CI, or\ninvestigating one rule in isolation. `--rule` is case-insensitive,\nrepeatable, and overrides `[lint] disable` in the config so you can pull a\ndisabled rule back in for one run without editing the config:\n\n```bash\npgrls lint --rule SEC001 --rule SEC003\n```\n\nOr run everything *except* certain rules with `--exclude-rule` (the\ncomplement of `--rule`; case-insensitive, repeatable):\n\n```bash\npgrls lint --exclude-rule SEC022 --exclude-rule PERF002\n```\n\nTrim the printed report to the findings you care about with\n`--min-severity` (display-only — the exit code still reflects every finding\nper `--fail-on`, so hiding info noise can't flip CI green), and write the\nreport to a file instead of stdout with `--output`/`-o`:\n\n```bash\npgrls lint --min-severity warning            # hide info-level nudges from output\npgrls lint --format sarif --output pgrls.sarif\n```\n\nPass `--explain` to append each rule's reference paragraph beneath its\nfinding in the text output, so a CI log carries the *why* next to the\n*where* without a separate `pgrls explain \u003cRULE\u003e` lookup. Text format\nonly — JSON / SARIF / Markdown / GitHub / JUnit keep their schemas stable.\n\n### Example output\n\nText (default):\n\n```\n  ERROR  SEC001  public.users\n         Table public.users does not have row-level security enabled.\n         Add ENABLE ROW LEVEL SECURITY or include the table in\n         [lint.rules.SEC001].allowlist if it is a public reference table.\n\npgrls: 1 error.\n```\n\nJSON (`--format json`):\n\n```json\n{\n  \"violations\": [\n    {\n      \"rule_id\": \"SEC001\",\n      \"severity\": \"error\",\n      \"title\": \"RLS not enabled on table\",\n      \"message\": \"Table public.users does not have row-level security enabled. Add ENABLE ROW LEVEL SECURITY or include the table in [lint.rules.SEC001].allowlist if it is a public reference table.\",\n      \"location\": \"public.users\"\n    }\n  ],\n  \"summary\": { \"errors\": 1, \"warnings\": 0, \"infos\": 0, \"others\": 0, \"total\": 1 }\n}\n```\n\nThe JSON shape is the public CI contract — top-level keys, per-violation keys, and summary keys are stable across releases. Pipe through `jq` to filter, count, or transform; ship to a dashboard; upload as a build artifact.\n\nSARIF (`--format sarif`) emits a SARIF v2.1.0 document. GitHub Code Scanning, Azure DevOps, and other static-analysis aggregators consume it directly — see the GitHub Actions recipe below for the upload step that puts findings inline on PRs.\n\nExit codes follow the standard linter convention:\n\n- `0` — clean (or findings below `fail_on`)\n- `1` — findings met or exceeded `fail_on` (default `warning`); your schema has an RLS issue\n- `2` — `pgrls` itself failed to run (bad config, DB unreachable, fixer SQL rolled back, etc.). Distinct from `1` so CI alerts can route \"schema bug\" differently from \"tool error.\"\n\n### Baseline — adopt pgrls on a legacy database\n\nRunning pgrls against an existing database for the first time often surfaces a backlog of pre-existing findings. `--baseline` lets you ratchet: record today's findings and have CI fail only on *new* ones.\n\n```bash\n# First run (file absent): records every current finding, exits 0.\npgrls lint --database-url \"$DATABASE_URL\" --baseline pgrls-baseline.json\n\n# Later runs: report and fail only on findings NOT in the baseline.\npgrls lint --database-url \"$DATABASE_URL\" --baseline pgrls-baseline.json\n```\n\nThe first run writes the baseline file and exits `0`. Every later run suppresses findings already recorded and exits nonzero only when a *new* finding appears — so a team can adopt pgrls without fixing the whole backlog up front, then chip away at the baseline over time. Commit the baseline file to the repo.\n\nTo re-baseline after deliberately accepting new findings, pass `--update-baseline` alongside `--baseline FILE`; the baseline is refreshed in place with the current findings (replace, not merge — stale entries for findings that no longer fire are dropped). No need to delete the file first.\n\n### Auto-remediation: `pgrls fix`\n\n`pgrls fix` generates SQL for the rules whose remediation is mechanical. Default mode is dry-run — it prints the SQL but does not modify the database. Pass `--apply` to execute, or `--output \u003cfile\u003e` to write a migration-ready `.sql` script (a header plus one `-- [rule] description` comment per statement) instead of printing to stdout.\n\n```bash\n# Dry-run: print what would change.\npgrls fix --database-url \"$DATABASE_URL\"\n\n# Apply for real.\npgrls fix --database-url \"$DATABASE_URL\" --apply\n\n# Only fix one rule.\npgrls fix --database-url \"$DATABASE_URL\" --rule SEC002 --apply\n\n# Write the SQL to a migration-ready file instead of stdout.\npgrls fix --database-url \"$DATABASE_URL\" --output migration.sql\n\n# CI gate: exit 1 if any auto-fixable violations exist (no SQL emitted).\npgrls fix --database-url \"$DATABASE_URL\" --check\n```\n\nCurrently fixable: **SEC001** (emits `ALTER TABLE … ENABLE ROW LEVEL SECURITY;`), **SEC002** (emits `ALTER TABLE … FORCE ROW LEVEL SECURITY;`), **SEC004** (emits `ALTER POLICY … USING (…)` stripping the inverted-auth `auth_func() IS NULL` disjunct that leaks rows to anonymous clients; only removes a top-level `OR` disjunct so it can never broaden the policy, and abstains when no real check survives), **SEC006** (emits `ALTER POLICY … WITH CHECK (…)` mirroring the policy's `USING`), **SEC011** (emits `ALTER POLICY … USING (…) / WITH CHECK (…)` stripping an `OR true` debug bypass), **SEC015** (emits `ALTER FUNCTION …(…) SET search_path = …` per SECDEF overload, pinning `pg_temp` last; abstains on pre-v12 empty signatures and quoted-comma paths), **SEC017** (emits `ALTER FUNCTION …(…) NOT LEAKPROOF;` per overload; abstains on pre-v12 empty signatures), **SEC019** (emits `ALTER POLICY … USING (…) / WITH CHECK (…)` adding the `missing_ok = true` second argument to one-argument `current_setting()` calls), **SEC020** (emits `ALTER POLICY … WITH CHECK (…)` replacing a constant-`true` `WITH CHECK` with the policy's `USING`), **SEC031** (emits `DROP POLICY … ON …;` for a no-op restrictive `USING (true)` floor — it AND-combines to nothing, so dropping it leaves access unchanged), **SEC030** (emits `ALTER TABLE … ALTER COLUMN … SET NOT NULL;` for a nullable tenant discriminator — backfill existing `NULL`s first, or the apply fails and rolls back the batch), **SEC032** (emits `ALTER TABLE … ENABLE ROW LEVEL SECURITY;` for a table whose policies are dormant because RLS is off), **PERF001** (rewrites unwrapped auth calls as `(SELECT auth.uid())` in `USING` and/or `WITH CHECK`, emitting only the clause(s) it changes), **PERF003** (emits `CREATE INDEX ON … (…);` for a policy-predicate column with no leading-column index), **PERF004** (emits `CREATE INDEX ON … (\u003cexpression\u003e);` matching a function-wrapped predicate like `lower(email)` that defeats the plain column index), **HYG003** (emits `DROP POLICY … ON …;` for a policy that exactly duplicates another on the same table), **VIEW001** (emits `ALTER VIEW … SET (security_invoker = true);`), and **VIEW002** (emits `ALTER VIEW … SET (security_barrier = true);`). Other rules need human intent (which role? which column? which policy?) and are not auto-fixed.\n\n## Scaffold RLS — `pgrls generate`\n\n`pgrls fix` repairs RLS you already have; `pgrls generate` writes it from\nscratch for tenant tables that have none. The shoot-out showed ORMs emit\nbroken or absent row security — the counter is to generate correct RLS and\nlint it.\n\nFor every table that carries a tenant-discriminator column (default\n`tenant_id`) and has **no** policies, `generate` emits the complete\ngold-standard setup — `ENABLE` + `FORCE` row security, a permissive\ntenant-isolation policy, a `RESTRICTIVE` floor, and the supporting index —\n**designed to lint clean**:\n\n```bash\n# Dry-run: print the SQL for every unprotected tenant_id table.\npgrls generate --database-url \"$DATABASE_URL\"\n\n# Write a migration, or apply in one all-or-nothing transaction.\npgrls generate --database-url \"$DATABASE_URL\" --output rls.sql\npgrls generate --database-url \"$DATABASE_URL\" --apply\n\n# The round-trip the feature guarantees:\npgrls generate --apply \u0026\u0026 pgrls lint   # → no findings\n```\n\nThe predicate compares the column to a session value, wrapped in\n`(SELECT …)` for per-statement caching and cast to the column's type:\n\n```sql\nCREATE POLICY posts_tenant_isolation ON public.posts TO authenticated\n    USING (tenant_id = (SELECT current_setting('app.tenant_id', true)::uuid))\n    WITH CHECK (tenant_id = (SELECT current_setting('app.tenant_id', true)::uuid));\n```\n\n`--convention postgrest` switches the source to\n`current_setting('request.jwt.claim.tenant_id', true)`; `--setting-name`,\n`--role` (default `authenticated`), and `--no-restrictive` tune the rest.\n(The restrictive floor is what silences the SEC007 \"all policies\npermissive\" advisory — `--no-restrictive` trades it back for that info\nfinding.)\nFor a non-conventional column, name it explicitly:\n`--table public.orgs:org_id`. Tables that already have policies are\n**skipped** — `generate` never overwrites hand-written policy intent, so\nre-running it is a no-op.\n\n**Per-user ownership** — `--model owner` scaffolds the other canonical\npattern (rows owned by a user, default column `user_id`) instead of\nper-tenant isolation. With `--convention supabase` it emits the idiomatic\n`user_id = (SELECT auth.uid())`:\n\n```bash\npgrls generate --model owner --convention supabase --apply\n```\n\n`--convention app-guc` / `postgrest` use\n`current_setting('app.user_id', …)` /\n`current_setting('request.jwt.claim.sub', …)` instead. Scope is the common\nsingle-column case (tenant or owner); per-CRUD and membership-join shapes\nstay hand-written.\n\n## RLS posture — `pgrls report`\n\n`pgrls lint` answers *\"what's wrong?\"*; `pgrls report` answers *\"what's the posture overall?\"* — a factual, rule-free snapshot of every table's row-level-security state, for audits and onboarding.\n\n```bash\npgrls report --database-url \"$DATABASE_URL\"                                  # text table + summary\npgrls report --database-url \"$DATABASE_URL\" --format json                    # machine-readable\npgrls report --database-url \"$DATABASE_URL\" --format markdown -o posture.md  # write an audit doc\npgrls report --database-url \"$DATABASE_URL\" --format html -o posture.html    # standalone HTML page, print/PDF-ready\n```\n\nEach table gets a coarse status — `protected` (RLS on, FORCE'd, ≥1 permissive policy), `not-forced` (RLS on with a permissive policy, but owner bypasses), `no-policies` (RLS on but no permissive policy → default-deny; covers zero policies *and* restrictive-only tables), `covered-by-parent` (a partition child whose RLS-enabled parent covers queries routed through it — credited when that parent is among the scanned schemas), or `rls-off` — plus an aggregate summary. It runs **no rules** and emits no findings; use `pgrls lint` for that.\n\n## Who can access what — `pgrls matrix`\n\nWhere `pgrls report` summarizes each table's posture, `pgrls matrix` answers the audit question directly: **for every role × table × command, can it reach the rows?** It collapses table `GRANT`s, the RLS enabled/forced flags, and the permissive(OR) / restrictive(AND) policy set into one verdict per cell.\n\n```bash\npgrls matrix --database-url \"$DATABASE_URL\"                                 # role × table × command grid\npgrls matrix --database-url \"$DATABASE_URL\" --roles anon,authenticated     # only these role columns\npgrls matrix --database-url \"$DATABASE_URL\" --format json                   # full predicates, machine-readable\npgrls matrix --database-url \"$DATABASE_URL\" --format html -o access.html     # standalone audit page\n```\n\nEach cell is one of:\n\n- **`OPEN`** — every row is reachable: the role is granted the command's privilege **and** either RLS is off, the role bypasses RLS (`BYPASSRLS`), or an applicable permissive policy is unconditionally `true` with no restrictive policy narrowing it.\n- **`DENIED`** — no table privilege for that command, *or* RLS is on with no applicable permissive policy (Postgres default-denies).\n- **`COND`** — granted, but gated by a row predicate (shown in `--format json` / `html`): the OR of applicable permissive clauses, AND-ed with any restrictive ones.\n\nPer command it evaluates the clause Postgres actually applies — `WITH CHECK` for INSERT, `USING` for SELECT/UPDATE/DELETE. (For UPDATE, v1 shows the read-side `USING` — *which rows are reachable*; the write-side `WITH CHECK` gate that validates the new row image is not modeled per-cell.) Columns default to `PUBLIC`, `anon`, `authenticated` plus every non-system role named by a grant or policy, or carrying `BYPASSRLS` (`--roles` overrides; `--include-system-roles` adds `pg_*`). Like `report`, it runs **no rules**. Two further caveats it does not model per-cell: a table *owner* bypasses RLS unless the table is `FORCE`d, and a superuser bypasses everything.\n\n## Prove tenant isolation — `pgrls verify`\n\n`pgrls lint` *flags* a suspicious policy; `pgrls verify` **proves** — with the Z3 SMT solver — an isolation property, and hands back the offending row (not just a warning) when it fails. Four threat models via `--mode`:\n\n- **`anon`** (default) — an **anonymous** session (every auth function — `auth.uid()`/`role()`/`jwt()`, `current_setting(...)` — returning `NULL`, the unauthenticated state) cannot read any row.\n- **`cross-tenant`** — a session authenticated as *one* tenant cannot read a *different* tenant's row, checked against the policy's own `\u003ccolumn\u003e = \u003csession identity\u003e` scoping equality (the predicate [`pgrls generate`](#scaffold-rls--pgrls-generate) emits).\n- **`write`** — such a session cannot **write** (INSERT/UPDATE) a row stamped for *another* tenant, checked against each write policy's effective `WITH CHECK` (or the `USING` that `FOR UPDATE`/`FOR ALL` reuses as the new-row check). The write side is the most CVE-adjacent footgun ([CVE-2025-48757](https://nvd.nist.gov/vuln/detail/CVE-2025-48757)): a policy that scopes reads but not writes lets a tenant stamp data for another. `SEC006`/`SEC020`/`SEC028`/`SEC040` are the heuristic fallback.\n- **`escalation`** — proves the static [`SEC048`](docs/RULES.md#rule-sec048) reachability finding: a low-trust role that is a member of a table's **owner** (and the owner is not superuser/`BYPASSRLS`) can `SET ROLE` to it and, on the owner's RLS-enabled-but-not-`FORCE`'d tables, bypass RLS entirely (reading *every row*). It composes the role-reachability closure with the `cross-tenant` prover — **`LEAK`** when the table's RLS provably isolates tenants (the bypass defeats it) *or* only partially leaks (the bypass also exposes the other tenants' rows the partial leak hides); **`ISOLATED`** only when the table already leaks every row cross-tenant anyway (the bypass adds nothing); **`UNVERIFIED`** when the predicate is unprovable. It also proves the [`SEC042`](docs/RULES.md#rule-sec042) case — an anon/`PUBLIC`-EXECUTE-able SECURITY DEFINER function owned by an RLS-exempt role whose body reads an RLS table — against that table's `anon` verdict (abstains `UNVERIFIED` on anything it cannot see through: an opaque PL/pgSQL or dynamic-SQL body, or one reading via a view / function; honors `[lint.rules.SEC042].anon_roles`). Turns a noisy SEC048/SEC042 warning into an evidenced leak — or clears it.\n\n```bash\npgrls verify --database-url \"$DATABASE_URL\"                          # anon proof, exits 1 on any leak\npgrls verify --database-url \"$DATABASE_URL\" --mode cross-tenant       # prove no tenant reads another tenant's rows\npgrls verify --database-url \"$DATABASE_URL\" --mode write              # prove no tenant writes a row for another tenant\npgrls verify --database-url \"$DATABASE_URL\" --mode escalation         # prove the SEC048 reachable owner-bypass actually leaks\npgrls verify --database-url \"$DATABASE_URL\" --format json            # per-table / per-policy verdicts + counterexamples\npgrls verify --database-url \"$DATABASE_URL\" --format sarif            # SARIF v2.1.0 for GitHub Code Scanning\npgrls verify --database-url \"$DATABASE_URL\" --strict                 # also fail on UNVERIFIED\npgrls verify --database-url \"$DATABASE_URL\" --auth-function auth.user_id   # add a custom auth helper\npgrls verify --database-url \"$DATABASE_URL\" --emit-repro ./repro      # runnable repro per leak (anon / cross-tenant)\npgrls verify --database-url \"$DATABASE_URL\" --probe                   # confirm the static proof against the live DB\n```\n\nThe two modes are complementary. The signature inverted-auth policy `auth.uid() IS NULL OR tenant_id = auth.uid()` is an anon **`LEAK`** (an unauthenticated client sees every row) yet cross-tenant **`PROVEN`** (an authenticated tenant only ever sees its own rows — the `IS NULL` branch is false once authenticated). Conversely a `tenant_id = auth.uid() OR is_public` policy is anon-`LEAK` *and* cross-tenant-`LEAK` (another tenant's public rows leak), while `USING (true)` is an anon-`LEAK` but cross-tenant-`UNVERIFIED` (no scoping equality to verify against — already caught by anon mode). Run both for full coverage.\n\nEach RLS-enabled table gets one of three **honest** verdicts (the phrasing below is `anon`-mode; `cross-tenant` frames the same verdicts as \"readable by a session of a different tenant\"):\n\n- **`PROVEN`** — the read is *unsatisfiable* under the threat model: Z3 proves no row is ever visible to an anonymous session (`anon`) / to a session of a different tenant (`cross-tenant`).\n- **`LEAK`** — a row *is* readable, with a concrete counterexample: a characterizing row (`a row with is_public=True is anonymously readable`; cross-tenant: `a row of another tenant with is_public=True is readable`), the unconditional case (`anon`: *every row* — `USING (true)`, the `auth.uid() IS NULL OR …` inversion, now proven rather than guessed; cross-tenant: *a row of any other tenant*), or — for a *conditional* leak the prover can't pin to a single row (an opaque/session-dependent bypass) — the leak reported without a characterizing row.\n- **`UNVERIFIED`** — Z3 is unavailable, the predicate is outside the decidable fragment, the solver timed out, or (`cross-tenant`) the policy has no single tenant-scoping equality to verify against. This is the point where the **verifier degrades to the linter** — no claim is made; run `pgrls lint` for the heuristic rules.\n\n`--format sarif` emits a SARIF v2.1.0 document for GitHub Code Scanning that shares the schema and `tool.driver` block with `pgrls lint --format sarif`: each `LEAK` is an `error`-level result located at `schema.table.policy` with the witness phrase as its message; `PROVEN` tables emit nothing; `UNVERIFIED` tables are omitted unless `--strict`, where each becomes a `note`-level result — so the result-set is non-empty exactly when the run would fail the gate. The prover is one rule per `--mode` (`pgrls-anon-isolation` / `pgrls-cross-tenant-isolation` / `pgrls-write-isolation`).\n\n`--emit-repro DIR` turns a `LEAK` into something you can run: for each leak it writes a `.sql` script and a pytest that recreate a throwaway copy of the table from the introspected column types, install the leaking policy, insert the counterexample row, and `SELECT` it back — as an anonymous session (`anon`) or, for `--mode cross-tenant`, as a session **authenticated as tenant A** with the inserted row belonging to a **different tenant B** (the session identity is set via the GUC the policy reads — the JWT-claim GUC for an `auth.*` helper, or a direct `current_setting('\u003cguc\u003e')`). Either way the SELECT runs as a NOSUPERUSER/NOBYPASSRLS runner, so the reproduction is sound (a *fixed* policy returns zero rows) — the proof, reproduced and rolled back. The pytest **passes while the leak exists and turns red once you fix the policy** — a runnable proof of the bug (invert the assertion to keep it as a green regression guard). For a characterized or unconditional leak the inserted row reliably triggers the policy; for a *conditional* leak (no pinned row) the placeholder row is best-effort and the `.sql` header flags it for a hand-edit. Re-running won't clobber a hand-edited reproduction unless `--force`.\n\n### Confirm the proof against the live database — `--probe`\n\nThe static proof reasons over the introspected policy AST. `--probe` keeps it honest by **confirming it against the live database**: for each verified table it connects as the threat-model session (anonymous, or authenticated as one tenant — for `--mode escalation` it `SET ROLE`s through the reaching low-trust member to the table owner), seeds a throwaway row, runs the real query the proof reasons about, and diffs the **observed** behavior against the Z3 verdict — all inside a transaction that is **rolled back**, so nothing is committed and the unprivileged probe role it creates — granted the policies' `TO` roles so role-scoped policies (e.g. `TO authenticated`) actually apply, but kept `NOSUPERUSER`/`NOBYPASSRLS` so RLS still decides visibility — does not survive the rollback. Per table × `--mode` it reports one of:\n\n| static verdict | observed live | result |\n|---|---|---|\n| `PROVEN` | no rows / write rejected | **AGREE** — the proof is backed by reality |\n| `PROVEN` | rows visible / write admitted | **MISMATCH** — proof↔reality broken (a soundness break, or schema drift since the proof) |\n| `LEAK` | rows visible / write admitted | **LEAK CONFIRMED** — the leak reproduced live |\n| `LEAK` | no rows / write rejected | **MISMATCH** — the witness is conditional (see `--emit-repro`) or the policy changed |\n| `UNVERIFIED` | rows visible / write admitted | **LEAK CONFIRMED** — a reproduced leak the static proof could not claim |\n| `UNVERIFIED` | no rows / write rejected | *skipped* — the verifier made no claim and the probe saw no leak (not a proof) |\n\nThe headline is the `UNVERIFIED → LEAK CONFIRMED` row: a policy whose reads are scoped but whose `INSERT … WITH CHECK (true)` is wide open is statically `UNVERIFIED`, but `--probe --mode write` authenticates as one tenant, attempts to stamp a row for another, sees it admitted, and **upgrades the honest \"no claim\" into a reproduced leak**. With `--probe`, `pgrls verify` **exits 1 on any MISMATCH or LEAK CONFIRMED** (and, under `--strict`, on any table the probe had to abstain on); exit 2 is reserved for tool failure.\n\nThe probe is deliberately conservative — anything it cannot reproduce live it **abstains** on cleanly (per table, with a one-line reason, never a crash): the connection cannot create its probe role (it needs CREATEROLE or superuser), it has no INSERT path to seed, there is no `\u003ccolumn\u003e = \u003csession identity\u003e` axis to pivot a cross-tenant/write probe on, the leak witness is conditional (no single characterizing row), the policy references other tables, or a column has an exotic type the synthesis can't fill. It reuses `--emit-repro`'s GUC/identity/tenant-value synthesis verbatim, so the session it establishes is identical to the one the emitted reproduction would. `--probe` supports `--format text` (the static proof stacked above the AGREE/MISMATCH/LEAK CONFIRMED table), `--format json`, and `--format sarif` — a SARIF v2.1.0 document where each MISMATCH and LEAK CONFIRMED is an `error`-level result for GitHub Code Scanning (one rule per `--mode`: `pgrls-probe-anon` / `pgrls-probe-cross-tenant` / `pgrls-probe-write` / `pgrls-probe-escalation`, kept distinct from the static `verify` SARIF rules; under `--strict` an abstain becomes a `note`). `--probe --emit-repro` is rejected — run those separately. For `--mode escalation`, `--probe` confirms the SEC048 owner-bypass (the anon-callable-SECDEF SEC042 case is not yet probed — it surfaces as the static verdict only).\n\nIt is a *soundness* proof, not a heuristic: it never reports a leak it cannot exhibit, and never reports `PROVEN` unless Z3 proves it. `pgrls verify` exits non-zero on any leak — drop it in CI as a hard tenant-isolation gate, alongside `pgrls lint`. **Scope:** both modes reason over each table's permissive `SELECT`/`ALL` policies; a *leaking* permissive policy on a table that also carries a `RESTRICTIVE` read floor is reported `UNVERIFIED` (v1 does not combine floors — an already-`PROVEN` policy stays proven, since a restrictive floor only narrows access), and RLS-disabled tables are out of scope (that is SEC001's job). `cross-tenant` mode verifies the single `\u003ccolumn\u003e = \u003csession identity\u003e` scoping equality `pgrls generate` emits — including when that identity is cast to the tenant column's type (`current_setting(...)::uuid`, `::bigint`/`::int`, …); a policy with no such equality (or two competing ones) is `UNVERIFIED` there. `--emit-repro` works in both modes. Needs the `z3-solver` dependency (bundled).\n\n## MCP server — `pgrls mcp`\n\n`pgrls mcp` runs a [Model Context Protocol](https://modelcontextprotocol.io/) server (over stdio) that gives AI coding agents pgrls's analysis as tools. The headline is **offline** analysis of the DDL the agent just wrote: it passes the `CREATE TABLE` / `CREATE POLICY` SQL as `sql=` and pgrls lints it **and** runs the full Z3 isolation prover with no database.\n\n```bash\npip install 'pgrls[mcp]'   # FastMCP is an optional extra; the plain install stays slim\n```\n\nPoint an MCP client at it:\n\n```json\n{\"mcpServers\": {\"pgrls\": {\"command\": \"pgrls\", \"args\": [\"mcp\"]}}}\n```\n\nThe server **never mutates a database** — it only ever issues read-only introspection SELECTs, and never auto-applies SQL. The remediation tools (`fix` / `generate`) are **emit-only**: they return SQL text for the agent to review and run through its own channel. It exposes six tools:\n\n- **`lint`** / **`verify`** — accept exactly one schema source: `sql=` (raw DDL, analyzed offline), `database_url=` (a live connection, read-only introspection), or `snapshot=` (a `pgrls snapshot` JSON). `lint` returns the same JSON violation shape as `pgrls lint --format json`; `verify` returns the per-table verdicts and leak witnesses (modes `anon` / `cross-tenant` / `write`).\n- **`fix`** / **`generate`** — *emit-only* remediation. `fix` returns the auto-fix SQL for the mechanically-fixable findings (the remediation counterpart of `lint`); `generate` scaffolds gold-standard RLS for unprotected multi-tenant / row-owner tables. Both take the same schema sources as `lint` and return both structured statements and a copy-pasteable `migration` — but never execute it.\n- **`explain_rule`** / **`list_rules`** — the rule catalog and a single rule's reference.\n\nOn the `sql=` path the response's `warnings` list flags that catalog-only rules (those needing live catalog state — BYPASSRLS roles, `pg_default_acl`, SECURITY DEFINER owners, FKs, indexes, triggers) can't fire, so an empty findings list is **not** a clean bill of health. A `database_url` is treated as a credential: it is never logged, and connection errors are sanitized so the DSN can't leak.\n\n## Tracking trends — `pgrls history`\n\nPair a daily cron with `pgrls lint --format json -o snapshots/$(date -u +%FT%H%M%SZ).json` and ask `pgrls history snapshots/` weekly — \"are we gaining ground over time, or is the findings count creeping up?\"\n\n```bash\npgrls history snapshots/                       # terminal table\npgrls history snapshots/ --format markdown     # paste-ready GFM (for a weekly update / PR comment)\npgrls history snapshots/ --format html -o trend.html   # standalone trend page, print/PDF-ready\npgrls history snapshots/ --format json -o trend.json   # machine-readable for plotting\n```\n\nEach row is one snapshot plus the **NEW** / **FIXED** delta vs. the prior snapshot in chronological order (findings keyed by `(rule_id, location)` so a schema-wide finding stays PERSISTENT rather than NEW+FIXED on every comparison). A trailing summary line names the net change over the full series.\n\n## Runtime seq-scans — `pgrls perf`\n\n`PERF003` predicts a missing index *statically* (the RLS predicate column has no usable index, so the planner *will* seq-scan). `pgrls perf` reads what the database actually did — Postgres's cumulative table statistics (`pg_stat_user_tables`) — and ranks RLS-enabled tables by rows read sequentially, cross-referencing each against PERF003:\n\n```bash\npgrls perf --database-url \"$DATABASE_URL\"                 # text table + summary\npgrls perf --database-url \"$DATABASE_URL\" --format json   # also markdown / html\npgrls perf --database-url \"$DATABASE_URL\" --fail-on-findings   # CI gate\npgrls perf --database-url \"$DATABASE_URL\" --statements     # blame specific queries (pg_stat_statements)\n```\n\nA table PERF003 flagged that is *also* observed seq-scanning is a **confirmed** missing-index candidate; a table PERF003 thought was indexed that still seq-scans means the index **isn't being used** — poor selectivity or stale statistics, which no amount of schema reading would catch. Tune `--min-rows` / `--min-seq-scans` / `--min-seq-pct` to set what counts as pressure (defaults are conservative — small tables seq-scan by the planner's choice).\n\nTo gate CI inside your normal lint run, persist a snapshot and point lint at it — `pgrls perf --snapshot .pgrls-perf.json` writes the raw counters, then `pgrls lint --perf .pgrls-perf.json` fires the opt-in [**PERF005**](docs/RULES.md#rule-perf005) rule for each RLS table under pressure (inert without the artifact, exactly like HYG004 with a coverage artifact).\n\nWhen `pg_stat_statements` is installed, **`--statements`** turns \"this table seq-scans\" into \"*this query* seq-scans it\": it parses each recorded statement, keeps those touching a pressured table, and lists the costliest by total execution time — the precise query to fix. It degrades cleanly (a note, base report unchanged) when the extension isn't available.\n\nHonest scope: `pg_stat_user_tables` counts *every* sequential scan on a table, not only those an RLS predicate drove, so the table-level view prioritises where to look rather than proving RLS is the cause (that's what `--statements` resolves). Partitioned tables are under-covered in this release — a partitioned parent records no direct scans (queries hit the children) and partition children don't carry the parent's RLS flag — so their scans may not surface (a false negative, never a false positive). Warm the planner's statistics first (exercise the workload, then `ANALYZE`).\n\n## Configuration\n\nDrop a `pgrls.toml` next to your project. See `pgrls.example.toml` in the repo for a fully commented version.\n\n```toml\n[database]\nurl = \"$DATABASE_URL\"\nschemas = [\"public\"]\n\n[lint]\ndisable = []\nfail_on = \"warning\"\n\n[lint.rules.SEC001]\nallowlist = [\"countries\", \"currencies\"]\n\n# `severity` re-tiers a rule's findings without disabling it —\n# \"error\" | \"warning\" | \"info\". SEC019 is an info rule; promoting\n# it to error makes a one-arg current_setting() call fail CI.\n[lint.rules.SEC019]\nseverity = \"error\"\n```\n\n### Sharing config across projects — `extends`\n\nA config can layer on top of a shared base with a top-level `extends`\n(a path, or a list of paths resolved relative to the file that declares\nit) — handy for a monorepo or an org-wide ruleset:\n\n```toml\nextends = \"../pgrls.base.toml\"   # or [\"../base.toml\", \"./team.toml\"]\n\n[lint]\nfail_on = \"error\"                # override one key; inherit the rest\n```\n\nTables deep-merge key-by-key (a child can set `[lint.rules.SEC001].severity`\nwhile inheriting the base's `allowlist`); scalars and arrays are *replaced*,\nnot appended (a child `disable` list wins wholesale). For a list, later\nentries override earlier ones, and the declaring file overrides every base.\nA cycle in the `extends` chain is an error.\n\n### Editor support — JSON Schema\n\npgrls ships a JSON Schema for `pgrls.toml` ([`pgrls.schema.json`](pgrls.schema.json)) so editors autocomplete keys and flag typos and invalid values (a misspelled `[lint.rles]`, a bad `fail_on`). `pgrls init` writes a `#:schema` directive on the first line, which the [Even Better TOML](https://marketplace.visualstudio.com/items?itemName=tamasfe.even-better-toml) VS Code extension applies automatically:\n\n```toml\n#:schema https://raw.githubusercontent.com/pgrls/pgrls/main/pgrls.schema.json\n```\n\nPoint any JSON-Schema-aware TOML tooling at that URL for the same validation.\n\n## Testing your RLS — `pgrls.testing`\n\nInstall with `pip install pgrls[testing]` to pull in pytest alongside pgrls.\n\n`pgrls.testing` is a pytest plugin that lets you write RLS tests with idiomatic pytest ergonomics. The `pgrls_db` fixture opens a connection, starts a per-test transaction, lets you switch roles + claims for each scenario, and rolls back at end so nothing persists between tests.\n\n```python\ndef test_user_a_cannot_see_user_bs_invoices(pgrls_db):\n    pgrls_db.seed(\"public.invoices\", [\n        {\"id\": \"1\", \"tenant_id\": \"tenant-a\", \"amount\": 100},\n        {\"id\": \"2\", \"tenant_id\": \"tenant-b\", \"amount\": 200},\n    ])\n    with pgrls_db.as_role(\n        \"authenticated\",\n        claims={\"sub\": \"user-a\", \"tenant_id\": \"tenant-a\"},\n    ):\n        pgrls_db.assert_rows(\"SELECT id FROM invoices\", count=1)\n        pgrls_db.assert_invisible(\n            \"SELECT id FROM invoices WHERE tenant_id = 'tenant-b'\"\n        )\n        pgrls_db.assert_rejected(\n            \"INSERT INTO invoices (tenant_id, amount) VALUES ('tenant-b', 999)\"\n        )\n```\n\nThe plugin assumes the standard PostgREST conventions (`SET LOCAL ROLE` + `request.jwt.claims` GUC). Configure the connection string via one of the following — the first one defined wins:\n\n- A `pgrls_test_database_url` fixture in your `conftest.py`. This *replaces* the plugin's default fixture (pytest fixture shadowing); when you supply one, the env-var fallback below is not consulted. Useful for per-session testcontainers.\n- The `PGRLS_TEST_DATABASE_URL` environment variable.\n- The `DATABASE_URL` environment variable (fallback).\n\nSetting none of the three causes `pgrls_db` to raise `PgrlsTestConfigError`.\n\nThe cross-language contract is documented at [`docs/pgrls-test-protocol.md`](docs/pgrls-test-protocol.md). The **TypeScript port** ships as [`pgrls-test`](https://www.npmjs.com/package/pgrls-test) on npm — same Layer 1 protocol, same wire-level behaviour, idiomatic JS/TS surface (camelCase API, `pg` and `postgres.js` adapters). Source under [`ts/`](ts/) in this repo. The **Go port** is shipping in stages at [`go/`](go/) (module `github.com/pgrls/pgrls/go`, versioned independently as `go/v0.7.x`); step 1 (scaffold + `ProtocolVersion` constant + error types) shipped in `go/v0.7.0`, with steps 2–7 (Driver interface, pgx + lib/pq adapters, Client API, assertion helpers, conformance suite, release tag) tracked in [`go/CHANGELOG.md`](go/CHANGELOG.md).\n\n### Coverage — which policies are actually tested\n\nWhen your `pgrls.testing` suite runs, the plugin records which\n`(table, role, command)` tuples each test exercised and writes them to\n`.pgrls-coverage.json` on session finish (gitignored; disable with\n`pgrls_coverage = false` in your pytest config or `PGRLS_COVERAGE=off`).\n\n`pgrls coverage` cross-references that artifact against the live schema\nand reports which policies a test exercised and which were never\ntouched — the cross-tenant `DELETE` nobody wrote a test for. A policy\nis *covered* when a test queried its table, under a role it targets\n(or `PUBLIC`), with a matching command.\n\n```bash\npgrls coverage                          # text report (text/json/markdown/html)\npgrls coverage --fail-under 80          # exit 1 if coverage \u003c 80% (CI gate)\npgrls lint --coverage .pgrls-coverage.json   # enables HYG004 for uncovered policies\n```\n\n## Diff — `pgrls snapshot` + `pgrls diff`\n\n`pgrls diff` is the semantic policy diff command. Point it at any two\nPostgres sources — two snapshot files, a snapshot and a live DB, or two\nlive DBs — and it classifies every RLS change as SAFE, BREAKING,\nREQUIRES_REVIEW, or DANGEROUS. Use it in CI to gate deployments on\nactual security regressions without blocking safe migrations.\n\n```bash\n# Capture a baseline from the current branch (filter to a schema list\n# to keep snapshots small and stable).\npgrls snapshot --database-url \"$DATABASE_URL\" --schemas app -o base.json\n\n# After applying a migration, compare live DB to the baseline. The\n# --schemas filter applies to the URL side only (the snapshot file\n# already carries the filter from capture time).\npgrls diff base.json --database-url \"$DATABASE_URL\" --schemas app\n```\n\nThe default `--fail-on dangerous` threshold means CI only fails when a\ngenuinely dangerous change is detected (RLS toggled off, a permissive\npolicy added, a predicate widened, etc.). Pass `--fail-on requires-review`\nfor a stricter gate, or set `[diff].fail_on` in `pgrls.toml` to make\nthe choice persistent (CLI flag → `[diff].fail_on` → built-in\n`dangerous`). Output is git-diff-style by default (`--format text`);\nuse `--format json` or `--format sarif` for CI integrations that\nalready parse `pgrls lint` output (same `Violation` shape),\n`--format markdown` for a paste-ready PR-comment table with\nclassification badges, or `--format html` for a standalone audit\npage (embedded CSS, opens offline, prints to PDF) — same shape\n`pgrls report --format html` and `pgrls history --format html` use.\n\nPass `--explain` to append a one-paragraph rationale beneath each\nclassified Change in the text output — why a dropped PERMISSIVE\npolicy is BREAKING rather than DANGEROUS, why a column drop is\nREQUIRES_REVIEW, etc. Text format only; JSON / SARIF already carry\nthe classification tag. (The leaking-row counterexample described\nbelow is separate: it is emitted unconditionally on the Z3 path, not\ngated behind `--explain`.)\n\n| Change category                        | Default classification |\n|----------------------------------------|------------------------|\n| RLS toggled off                        | DANGEROUS              |\n| Table dropped                          | BREAKING               |\n| Permissive policy added                | DANGEROUS              |\n| Restrictive policy dropped             | DANGEROUS              |\n| USING predicate widened (OR added)     | DANGEROUS              |\n| USING predicate tightened (AND added)  | SAFE                   |\n| Roles widened (PUBLIC or new role)     | DANGEROUS              |\n| Column dropped (still referenced)      | REQUIRES_REVIEW        |\n| GRANT added on non-RLS table to PUBLIC | DANGEROUS              |\n\nA DANGEROUS *semantic-loosening* verdict — the new predicate admits a\nstrict superset of the old one's rows — also prints a concrete\n**leaking row**: a row the new policy admits but the old one rejected\n(e.g. `example leaking row: {tenant_id=2}`), in both text and JSON\noutput. The row is only emitted when its column values are a sound,\nself-sufficient witness; when the leak depends on a NULL test or an\nopaque value (a function call, a `current_setting(...)` GUC, `COALESCE`,\nor `CASE`), pgrls prints the label-only DANGEROUS verdict rather than a\nrow that would not actually leak. Without the extra, the verdict is\nunchanged and no row is printed.\n\nSee [AGENTS.md](AGENTS.md) for the full classification table and AST\npattern documentation.\n\n## Rules\n\n`pgrls lint` ships these rules:\n\n| ID | Severity | Catches |\n|---|---|---|\n| [SEC001](docs/RULES.md#rule-sec001) | error | Tables in scanned schemas with RLS disabled and no policies (a table with policies but RLS off is SEC032) |\n| [SEC002](docs/RULES.md#rule-sec002) | error | Tables with RLS enabled but FORCE ROW LEVEL SECURITY off |\n| [SEC003](docs/RULES.md#rule-sec003) | error | Permissive policies granted to PUBLIC |\n| [SEC004](docs/RULES.md#rule-sec004) | error | Inverted auth check (Lovable CVE pattern) in USING |\n| [SEC005](docs/RULES.md#rule-sec005) | warning | Policy expression has no own-column reference |\n| [SEC006](docs/RULES.md#rule-sec006) | error | INSERT/UPDATE/ALL policies with no WITH CHECK |\n| [SEC007](docs/RULES.md#rule-sec007) | info | All policies on a table are permissive (no RESTRICTIVE floor) |\n| [SEC008](docs/RULES.md#rule-sec008) | warning | Permissive policy USING clause is constant `true` (admits every row) |\n| [SEC009](docs/RULES.md#rule-sec009) | warning | RLS enabled but no policies defined (silent deny-all) |\n| [SEC010](docs/RULES.md#rule-sec010) | warning | Policy `USING`/`WITH CHECK` clause is constant `false` (deny-all anti-pattern) |\n| [SEC011](docs/RULES.md#rule-sec011) | warning | Policy expression has an `OR true` branch (debug bypass left in) |\n| [SEC012](docs/RULES.md#rule-sec012) | warning | Table has only RESTRICTIVE policies (silent deny-all — needs at least one PERMISSIVE) |\n| [SEC013](docs/RULES.md#rule-sec013) | warning | Trigger on RLS-protected table can bypass policies (triggers fire as table owner) |\n| [SEC014](docs/RULES.md#rule-sec014) | warning | SECURITY DEFINER function bypasses caller's RLS (audit every SECDEF function) |\n| [SEC015](docs/RULES.md#rule-sec015) | warning | SECURITY DEFINER function exposed to `pg_temp` search-path shadowing |\n| [SEC016](docs/RULES.md#rule-sec016) | warning | Role with the `BYPASSRLS` attribute bypasses every RLS policy |\n| [SEC017](docs/RULES.md#rule-sec017) | warning | Function with the `LEAKPROOF` attribute is evaluated below the RLS barrier |\n| [SEC018](docs/RULES.md#rule-sec018) | warning | Policy compares a column against `current_user` / `session_user` (no isolation under a shared pool role) |\n| [SEC019](docs/RULES.md#rule-sec019) | info | Policy calls `current_setting()` without the `missing_ok` argument (raises on an unset GUC) |\n| [SEC020](docs/RULES.md#rule-sec020) | warning | Policy `WITH CHECK` is constant `true` while `USING` restricts (writes accept rows reads never would) |\n| [SEC021](docs/RULES.md#rule-sec021) | info | Policy compares an identity column against a hardcoded literal (e.g. `tenant_id = 1`) |\n| [SEC022](docs/RULES.md#rule-sec022) | info | RLS-enabled table whose policies are all `FOR SELECT` — no write-side policy, so INSERT/UPDATE/DELETE are denied |\n| [SEC023](docs/RULES.md#rule-sec023) | warning | Policy granted to a role carrying `BYPASSRLS` — the role skips the policy entirely, so its `TO` clause is inert |\n| [SEC024](docs/RULES.md#rule-sec024) | info | Policy calls `current_setting()` with an unqualified parameter name (a dropped prefix the application cannot `SET`) |\n| [SEC025](docs/RULES.md#rule-sec025) | warning | Policy predicate references another table whose RLS is disabled — the cross-table read is only as strong as the referenced table's isolation |\n| [SEC026](docs/RULES.md#rule-sec026) | warning | Policy predicate uses `LIKE` / `ILIKE` / `SIMILAR TO` / POSIX regex against an auth-context value (a wildcard-shape GUC matches every row) |\n| [SEC027](docs/RULES.md#rule-sec027) | info | RLS table has an owner / user-identity column that no policy scopes by — rows may be visible across users within the same tenant |\n| [SEC028](docs/RULES.md#rule-sec028) | warning | Permissive write policy (INSERT/UPDATE/ALL) whose `WITH CHECK` is constant `true` — accepts every write; the `TO` clause gates who, not what |\n| [SEC029](docs/RULES.md#rule-sec029) | warning | Role can `SET ROLE` to a `BYPASSRLS` role through membership — escalation path that silently disables every policy (BYPASSRLS is not inherited, but reachable) |\n| [SEC030](docs/RULES.md#rule-sec030) | info | Policy scopes by a nullable discriminator column (`tenant_id = current_setting(…)` where the column allows NULL) — NULL rows escape scoping today and leak the moment a NULL-tolerant predicate appears |\n| [SEC031](docs/RULES.md#rule-sec031) | warning | RESTRICTIVE policy whose `USING` is constant `true` — AND-combines to a no-op, so it looks like a security floor but enforces none |\n| [SEC032](docs/RULES.md#rule-sec032) | error | Table has policies but RLS is not enabled — the policies are dormant (Postgres ignores them) and the table is wide open despite looking RLS-managed |\n| [SEC033](docs/RULES.md#rule-sec033) | error | Policy scopes by a user-modifiable JWT claim (`user_metadata` / `raw_user_meta_data`) — the authenticated user can rewrite the value via the auth API, bypassing the check; use `app_metadata` (service-role-only) instead |\n| [SEC034](docs/RULES.md#rule-sec034) | warning | Policy gates rows on `auth.email()` — silent denial-of-service-to-self when the user changes email, when SQL `=` is case-sensitive but emails aren't, or when plus-addressing means `x+y@host` ≠ `x@host`; scope by `auth.uid()` instead |\n| [SEC035](docs/RULES.md#rule-sec035) | warning | UNIQUE constraint not scoped to the tenant discriminator — a global `UNIQUE(email)` instead of `UNIQUE(tenant_id, email)` leaks cross-tenant existence via duplicate-key errors (the PRIMARY KEY and all-uuid uniques are excluded) |\n| [SEC036](docs/RULES.md#rule-sec036) | error | Policy `EXISTS (SELECT FROM auth.users WHERE …)` sub-select with no caller binding — checks \"is there any admin at all\" instead of \"is THIS user an admin\", so every authenticated user passes once any matching row exists |\n| [SEC037](docs/RULES.md#rule-sec037) | warning | Policy compares `auth.role()` to a value outside the known role set (`anon` / `authenticated` / `service_role`) — comparison never matches and silently denies every row, masking the broken policy |\n| [SEC038](docs/RULES.md#rule-sec038) | error | Semantic anonymous-read leak — Z3 proves the USING predicate is unconditionally TRUE for an unauthenticated session (all auth functions NULL), catching inverted-auth variants SEC004's syntactic match misses. |\n| [SEC039](docs/RULES.md#rule-sec039) | error | Permissive **write** policy (INSERT/UPDATE/DELETE/ALL) grants the unauthenticated `anon` role — anonymous PostgREST/Supabase clients can modify rows; the write-side analog of SEC003 for the named `anon` role (SELECT-only `anon` policies, the public-read pattern, are not flagged) |\n| [SEC040](docs/RULES.md#rule-sec040) | warning | Permissive `FOR ALL` policy whose `USING` scopes by a tenant/owner key but whose explicit `WITH CHECK` binds no identity column at all — a `FOR ALL` insert is governed by WITH CHECK alone, so a caller can INSERT a row stamped with another tenant's id (cross-tenant write); the asymmetry SEC006 (absent WITH CHECK) and SEC028/SEC020 (constant-true) miss. The legitimate \"read team, write own\" shape (WITH CHECK binds a *different* identity) is not flagged |\n| [SEC041](docs/RULES.md#rule-sec041) | warning | Declarative partition child has RLS disabled while its partitioned parent enforces it, and is granted directly to a non-owner role — Postgres doesn't inherit RLS (or grants) to partitions, so a query naming the granted child directly (e.g. PostgREST `GET /child`) bypasses the parent's policies; the complement of SEC001, which deliberately skips this case |\n| [SEC042](docs/RULES.md#rule-sec042) | error | `SECURITY DEFINER` function whose owner bypasses RLS (superuser/`BYPASSRLS`) is `EXECUTE`-able by `anon`/`PUBLIC` — an unauthenticated PostgREST `POST /rpc/fn` caller runs owner-privileged, RLS-exempt code; function `EXECUTE` defaults to `PUBLIC`, so this fires even with no explicit `GRANT`. Sharpens SEC014 along the anon-exposure axis |\n| [SEC043](docs/RULES.md#rule-sec043) | warning | Classic-`INHERITS` child has RLS disabled while an inheritance ancestor enforces it, and is granted directly to a non-owner role — Postgres doesn't inherit RLS (or grants) to children, so a query naming the granted child directly (e.g. PostgREST `GET /child`) bypasses the parent's policies; the classic-inheritance analogue of SEC041. SEC001 also fires on the child (it doesn't walk classic inheritance) — same fix |\n| [SEC044](docs/RULES.md#rule-sec044) | warning | `ALTER DEFAULT PRIVILEGES … GRANT \u003crow-access\u003e ON TABLES TO PUBLIC` auto-grants every *future* table to `PUBLIC`, so a new table whose author forgets `ENABLE ROW LEVEL SECURITY` is silently exposed (incl. to `anon`). A standing least-privilege / defense-in-depth posture, flagged on the `pg_default_acl` entry itself; grantee set is configurable (`anon`/`authenticated` excluded by default — the RLS-gated Supabase pattern). Complements SEC003/SEC001 |\n| [SEC045](docs/RULES.md#rule-sec045) | warning | A **column-level** `GRANT` of a content privilege (SELECT/INSERT/UPDATE) on a PII / secret-named column (`email`, `ssn`, `credit_card`, `password`, `api_key`, …) to a low-trust role (`PUBLIC`/`anon`) over-shares the most sensitive field to the least-trusted role. Column grants are rare and deliberate, so it's high-signal; a least-privilege posture finding distinct from SEC003 (a PUBLIC *policy*). PII patterns + grantees configurable; allowlist a deliberate public column |\n| [SEC046](docs/RULES.md#rule-sec046) | error | A policy calls a **user-defined `IMMUTABLE`** function whose body reads session/identity state (`current_setting`, `auth.*`, `current_user`) or a table. `IMMUTABLE` lets Postgres constant-fold the call into a reused/cached plan (pooling, PostgREST, prepared statements, PL/pgSQL), so the value frozen for one user is served to the next — a cross-user wrong-row leak. The fix is to declare the function `STABLE`. Inline `current_setting` (a STABLE built-in) and pure IMMUTABLE functions are not flagged |\n| [SEC047](docs/RULES.md#rule-sec047) | warning | A **foreign key whose parent (referenced) table has RLS enabled** is a cross-tenant *existence* covert channel when a low-trust role can write the child: FK validation bypasses RLS, so referencing a guessed parent key succeeds (row exists) or errors (it doesn't), even though RLS hides that row from the role's `SELECT` — the FK-validation analog of SEC035's UNIQUE-index oracle. Fires only when the parent has RLS on **and** the child is low-trust-writable (INSERT/UPDATE grant + RLS-off or an anon write policy). A posture over-approximation (does not prove parent row-visibility); allowlist by child table or constraint name |\n| [SEC048](docs/RULES.md#rule-sec048) | warning | A **low-trust role that is a member of a table owner** which is **not** superuser/`BYPASSRLS` bypasses RLS on that owner's **enabled-but-not-`FORCE`'d** tables: owner privileges (unlike the `BYPASSRLS` attribute SEC029 covers) ARE inherited through membership, automatically with `INHERIT` or after a `SET ROLE` with `NOINHERIT`. The table-owner analog of SEC029; reports per reachable member role, and co-fires with SEC002 (which reports the table) on the same missing-`FORCE` misconfig. No auto-fix — `FORCE` the owner's tables, revoke the membership, or allowlist the member / owner |\n| [SEC049](docs/RULES.md#rule-sec049) | warning | A **PostgREST-exposed table readable by a low-trust role**: a table in an API-exposed schema (default `public`), granted SELECT (table- or column-level) to `anon` / `authenticated` / `PUBLIC`, whose SELECT row-access is unrestricted (RLS off, or RLS on with a permissive `USING (true)` and nothing restrictive to filter), is directly readable at `GET /rest/v1/\u003ctable\u003e`. The *conjunction* that SEC001 / SEC003 / SEC008 each report a precondition of — fires once, naming the HTTP-reachable consequence. Conservative: a real policy or any unproven restrictive policy means \"protected\" → silent (so the normal RLS-gated Supabase grant is not flagged). No auto-fix — enable RLS, restrict the grant, or allowlist intentionally-public tables |\n| [SEC050](docs/RULES.md#rule-sec050) | warning | A **Supabase Storage policy not scoped to a bucket**: in `storage.objects` (where all object access is RLS-enforced), a permissive policy that authorizes by owner or path but whose row-reach clause references no `bucket_id` condition applies to **every** bucket — a caller authorized for one bucket can reach objects in another (Supabase's own guidance: \"your RLS policy must explicitly specify the `bucket_id` condition\"). Literal `USING (true)` is ceded to SEC008; a restrictive `bucket_id` floor keeps it silent; only `storage.objects` is examined (no `storage` schema → no findings). No auto-fix — add a `bucket_id = '\u003cbucket\u003e'` predicate or allowlist the policy |\n| [SEC051](docs/RULES.md#rule-sec051) | warning | A **Realtime-published table with RLS disabled**: a table in the Supabase `supabase_realtime` publication (membership resolved via `pg_publication_tables`, so `FOR ALL TABLES` / schema publications are expanded) with row level security off has every row change broadcast to all subscribed clients without policy filtering — one tenant's changes reach every subscriber. The Realtime-channel analog of SEC001, sharpened to the broadcast consequence. RLS-enabled members are filtered by Realtime per-policy and not flagged; a non-Realtime publication is not flagged (configurable). No auto-fix — enable RLS, remove from the publication, or allowlist the table |\n| [PERF001](docs/RULES.md#rule-perf001) | warning | Auth function called per-row in policy USING/WITH CHECK (unwrapped) |\n| [PERF002](docs/RULES.md#rule-perf002) | warning | Policy expression uses a VOLATILE function (`random()`, `clock_timestamp()`, …) |\n| [PERF003](docs/RULES.md#rule-perf003) | warning | Policy predicate column without a leading-column index (sequential scan on every query) |\n| [PERF004](docs/RULES.md#rule-perf004) | warning | Policy predicate wraps an indexed column in a function (e.g. `lower(email)`) so the plain index can't serve it — Postgres seq-scans; needs an expression index |\n| [PERF005](docs/RULES.md#rule-perf005) | info | RLS table *observed* to sequentially scan in production (opt-in; fed by `pgrls perf --snapshot` via `pgrls lint --perf`) |\n| [HYG001](docs/RULES.md#rule-hyg001) | error | Policies referencing columns that don't exist on the table |\n| [HYG002](docs/RULES.md#rule-hyg002) | warning | Policy named like a placeholder (`todo`, `fixme`, `tmp`, …) |\n| [HYG003](docs/RULES.md#rule-hyg003) | info | Policy is an exact duplicate of another policy on the same table |\n| [HYG004](docs/RULES.md#rule-hyg004) | info | Policy has no behavioral test exercising it (needs `pgrls lint --coverage`) |\n| [VIEW001](docs/RULES.md#rule-view001) | error | View over RLS-protected table without `WITH (security_invoker = true)` |\n| [VIEW002](docs/RULES.md#rule-view002) | warning | View over RLS-protected table without `WITH (security_barrier = true)` |\n| [VIEW003](docs/RULES.md#rule-view003) | warning | Materialized view over RLS-protected table (RLS not honored at query time) |\n| [VIEW004](docs/RULES.md#rule-view004) | warning | View calls SECURITY DEFINER function that reads an RLS-protected table |\n\nRun `pgrls explain \u003cRULE\u003e` (for example `pgrls explain SEC023`) to print any\nrule's full rationale — what it flags, why it matters, how detection works,\nand how to allowlist a false positive — on the command line. Bare\n`pgrls explain` (no argument) lists the catalog: one line per rule with its\nseverity and title. Pass `--format markdown` to either form (`pgrls explain\nSEC023 --format markdown`, `pgrls explain --format markdown`) for a\npaste-ready Markdown document — an `## SEC023 — …` heading + the body, or a\nMarkdown table of the catalog. `--format json` emits machine-readable rule\nmetadata (id, severity, title, a `fixable` flag, and — for a single rule —\nthe full reference body) for IDE / tooling integrations. All forms read only\npgrls's built-in rule catalog, so they need no database connection.\n\nFor canonical SQL fixes per rule, see [AGENTS.md](AGENTS.md). For per-rule\nconfiguration options (allowlists, etc.), see `pgrls.example.toml`.\n\n### Precision \u0026 false positives\n\nA linter is only useful if you can trust it not to cry wolf. pgrls ships an\n**adjudicated precision corpus** — small, self-contained schemas, each\nlabeled with exactly which rules should fire, including deliberately\nadversarial *near-misses* (a `coalesce()`-wrapped auth check, an `IS NULL`\nburied in a subquery, a predicate hidden in a `SubLink`) that look like\nviolations but are safe. The full rule set runs over every case against a\nreal Postgres; the result is published in\n[`docs/PRECISION.md`](docs/PRECISION.md), and a CI job re-measures on every\npush and fails if any rule fires where it shouldn't. Regenerate with\n`python -m corpus.measure`; see [`corpus/README.md`](corpus/README.md) to\nadd cases.\n\nFor per-release changes, see [CHANGELOG.md](CHANGELOG.md).\n\n## CI integration\n\npgrls is designed to live in your CI alongside any other linter. By\ndefault it connects to a Postgres database with your schema applied,\nintrospects, and exits non-zero if any rule at or above\n`fail_on` (default `warning`) fires. No database to point at? `pgrls lint\n--migrations` builds an ephemeral one from your migration files (see\n[Lint without a live database](#lint-without-a-live-database)).\n\n### pre-commit\n\n```yaml\n# .pre-commit-config.yaml\nrepos:\n  - repo: https://github.com/pgrls/pgrls\n    rev: v0.5.7\n    hooks:\n      - id: pgrls-lint\n        # pgrls hits a real database, so most teams scope this to\n        # `pre-push` rather than every commit.\n        stages: [pre-push]\n        args:\n          - --database-url=$DATABASE_URL\n          - --config=pgrls.toml\n```\n\n### GitHub Actions\n\nThe quickest path is the published Action ([`pgrls/pgrls-action`](https://github.com/marketplace/actions/pgrls-postgres-rls-linter) on the GitHub Marketplace) — it installs `pgrls` from PyPI and runs `pgrls lint` against a reachable database:\n\n```yaml\n- uses: pgrls/pgrls-action@v1\n  with:\n    database-url: ${{ secrets.PGRLS_DATABASE_URL }}\n    schemas: public\n    fail-on: error\n```\n\nIt exposes every flag `pgrls lint` does (`--format`, `--rule`, `--exclude-rule`, `--baseline`, `--output`, `--min-severity`, …); see the [Marketplace listing](https://github.com/marketplace/actions/pgrls-postgres-rls-linter) for the full input table.\n\nOr run `pgrls` directly — useful when you want to spin up an ephemeral Postgres as a job service:\n\n```yaml\n# .github/workflows/pgrls.yml\nname: pgrls\non: [push, pull_request]\njobs:\n  lint:\n    runs-on: ubuntu-latest\n    services:\n      postgres:\n        image: postgres:16-alpine\n        env:\n          POSTGRES_USER: ci\n          POSTGRES_PASSWORD: ci\n          POSTGRES_DB: ci\n        ports: [\"5432:5432\"]\n        options: \u003e-\n          --health-cmd pg_isready\n          --health-interval 10s\n          --health-retries 5\n    env:\n      DATABASE_URL: postgres://ci:ci@localhost:5432/ci\n    steps:\n      - uses: actions/checkout@v4\n      - uses: actions/setup-python@v5\n        with:\n          python-version: \"3.11\"\n      - run: pip install pgrls\n      - name: Apply schema\n        run: psql \"$DATABASE_URL\" -v ON_ERROR_STOP=1 -f migrations/all.sql\n      - name: Lint RLS\n        run: pgrls lint --format sarif \u003e pgrls.sarif\n      - name: Upload SARIF for code scanning\n        uses: github/codeql-action/upload-sarif@v3\n        if: always()\n        with:\n          sarif_file: pgrls.sarif\n```\n\nThe SARIF upload puts findings inline on the PR as code-scanning\nalerts — no extra dashboard plumbing. Use `--format json` instead\nof `--format sarif` if you want to pipe to `jq`, build your own\ndashboard, or keep the report as a build artifact.\n\n## Roadmap\n\n- **More lint rules.** Continued expansion of the SEC / PERF / HYG / VIEW catalog. Polished error messages.\n- ~~**TypeScript port of `pgrls.testing`**~~ — shipped as the [`pgrls-test`](https://www.npmjs.com/package/pgrls-test) npm package, versioned independently of the Python package (tagged `ts-v0.6.0`). Source: [`ts/`](ts/).\n- **Go port** of `pgrls.testing` following the same Layer 1 protocol — versioned independently of the Python package as the `go/v0.7.x` sequence (Go module tag prefix `go/`, distinct from the Python package's tags). Step 1 (scaffold + protocol-version constant + error types) landed in `go/v0.7.0`; subsequent steps (Driver interface, pgx + lib/pq adapters, Client API, assertion helpers, conformance suite) tracked in [`go/CHANGELOG.md`](go/CHANGELOG.md).\n- ~~**SAT-based predicate implication checking.**~~ Z3-driven semantic predicate analysis landed in v0.4.x.\n- ~~**Migration-as-input.**~~ `pgrls diff --apply migration.sql` shipped in v0.5.0; baseline cache + extension auto-detect in v0.5.1–v0.5.2.\n\n## License\n\nMIT — see `LICENSE`.\n","project_url":"https://awesome.ecosyste.ms/api/v1/projects/github.com%2Fpgrls%2Fpgrls","html_url":"https://awesome.ecosyste.ms/projects/github.com%2Fpgrls%2Fpgrls","lists_url":"https://awesome.ecosyste.ms/api/v1/projects/github.com%2Fpgrls%2Fpgrls/lists"}