Rules
What is held
Every statement goes through the Postgres grammar compiled into the binary. There is no regex on the decision path. If it does not parse, it does not run. A WHERE with no column reference (WHERE true, WHERE 1=1) counts as unscoped. EXPLAIN ANALYZE is classified as the statement it would run.
Built-in actions
The same table on Free, Pro, and Team when rules.builtin is default.
| Kind | Examples | Action | Snapshot |
|---|---|---|---|
select | SELECT, SHOW, EXPLAIN without ANALYZE | allow | none |
insert | INSERT, INSERT ... ON CONFLICT | allow | none |
update | UPDATE with a column in WHERE | allow | none |
delete | DELETE with a column in WHERE | allow | none |
update_unscoped | UPDATE with no column in WHERE | hold | data and DDL of the target |
delete_unscoped | DELETE FROM t, or WHERE true | hold | data and DDL of the target |
merge | MERGE with matched-only actions | allow | none |
merge_delete_unscoped | WHEN NOT MATCHED BY SOURCE THEN DELETE | hold | target |
truncate | TRUNCATE, including CASCADE | hold | named tables, plus FK children on CASCADE |
drop_table | DROP TABLE | hold | each table |
drop_schema | DROP SCHEMA | hold | every ordinary table in the schema |
drop_database | DROP DATABASE | hold | none. See restore. |
drop_other | DROP VIEW, INDEX, FUNCTION, ROLE, ... | hold | none, or DDL only for a policy or trigger on a table |
alter_drop_column | ALTER TABLE t DROP COLUMN | hold | data and DDL of t |
alter_drop_constraint | ALTER TABLE t DROP CONSTRAINT | hold | DDL only |
alter_column_type | ALTER COLUMN ... TYPE | allow | none. A hold rule is commented in the init template. |
alter_other | other ALTER TABLE, RENAME | allow | none |
rls_disable | DISABLE ROW LEVEL SECURITY, NO FORCE | hold | DDL only |
create | CREATE TABLE, INDEX, VIEW, SQL functions | allow | none |
grant_revoke | GRANT, REVOKE, ALTER DEFAULT PRIVILEGES | allow | none |
copy_from_stdin, copy_to_stdout | COPY with STDIN or STDOUT | allow | none |
copy_program | COPY ... PROGRAM, or a server file | deny | none |
do_block | DO $$ ... $$ | hold | none |
create_function_opaque | CREATE FUNCTION in plpgsql or another non-SQL language | hold | none |
create_trigger, create_rule | CREATE TRIGGER, CREATE RULE | hold | none |
prepare_held | PREPARE of a statement that would be held | deny | none |
set | SET, RESET, SET ROLE | allow | none |
transaction_control | BEGIN, COMMIT, ROLLBACK, SAVEPOINT | allow | none |
utility | VACUUM, ANALYZE, LISTEN, CALL, others | allow | none. CALL is a documented limit. |
protected_object | anything in schema tablebelt | deny | not overridable |
unparsed | parse error, or statement over the size limit | deny | not overridable |
custom is a hold from a user rule on a kind that would otherwise be allowed. large_write (an EXPLAIN row estimate) is not in this version. A scoped DELETE ... WHERE id > 0 that matches every row is allowed.
Evaluation order
unparsedandprotected_objectare always deny. Nothing overrides them.- Hosted extra rules (Team), in order. They may only
holdordeny. - Local
rules.custom, in order. First match wins.allowcan relax a built-in hold, for exampleTRUNCATE public.sessions. It cannot relax step 1 or a hosted rule. - The built-in table above.
If a hosted rule and a local rule both match, the more severe action wins. A hosted hold beats a local allow.
rules:
builtin: default
protected_schemas: [tablebelt]
custom:
- id: deny-drop-database
description: Never drop a database through the agent path
match: { kinds: [drop_database] }
action: deny
- id: allow-truncate-sessions
match: { kinds: [truncate], tables: ["public.sessions", "public.cache_*"] }
action: allow
- id: hold-type-changes
match: { kinds: [alter_column_type] }
action: hold
- id: hold-migrations-tool-drops
match: { kinds: [drop_other], client_applications: ["prisma*"] }
action: hold
kinds matches if any statement kind is in the list. tables is a glob on schema.table after resolution. client_applications globs the startup application_name. Empty match fields match everything. Unknown keys fail config validation.
tablebelt init writes deny-drop-database enabled, so a fresh install denies DROP DATABASE. See Restore.
rules test
tablebelt rules test "DELETE FROM users; SELECT 1"
tablebelt rules test --file migration.sql
printf '%s' "TRUNCATE public.sessions" | tablebelt rules test --resolve
Without --resolve, unqualified names show as ?.name. Exit code 4 means a statement would be denied, which is useful in CI. tablebelt rules list prints built-in, hosted, and local rules in evaluation order.
Limits
- A function called from SELECT or CALL can do anything inside. The classifier cannot see existing function bodies. Creating or replacing a non-SQL function is held, so the agent cannot plant one without approval. Existing functions are your role privileges.
- Triggers that already exist fire on allowed statements. An
ON DELETEtrigger can delete more than the statement shows. - The agent can still bypass the proxy with a raw URL. See Security.