From e3161a0fd22f4086fe92f07b0be491590fc57cf7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9s=20Pe=C3=B1a?= Date: Sat, 9 May 2026 21:58:43 -0600 Subject: [PATCH 1/2] feat(rules): add 4 migration-discipline PreToolUse rules MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a new rule family — "Database / migration discipline" — that prevents the class of drift seen when a teammate's PR shipped supabase/migrations files that did not auto-run on staging, and the manual SQL Editor workaround introduced silent skew between the migrations directory and other devs' local databases. Layered defense (4 new rules, 21 new tests): 1. schema-sql-outside-migrations (BLOCK on Edit/Write/MultiEdit) Blocks ALTER/CREATE/DROP/GRANT/REVOKE/CREATE POLICY in any .sql file outside supabase/migrations/, supabase/tests/, supabase/seed. 2. warn-psql-against-supabase-remote (WARN on Bash) Nudges away from psql/SQL Editor execution against any *.supabase.co host — that workflow is the primary source of drift vs versioned migrations. 3. pr-create-with-migrations-needs-deploy-note (WARN on Bash) Reminds the author to describe migration deployment in the PR body when calling gh pr create. Pure heuristic — does not inspect the diff; bypass marker available for migration-free PRs. 4. block-supabase-db-push-prod (BLOCK on Bash) Hard stop on supabase db push aimed at the PRODUCTION project ref (ukwovawzehnebuoowcec). Only the create-release SOP may apply migrations to prod. Test count: 59 → 80 (21 new tests, all 80 pass) Version: 1.6.0 → 1.7.0 (minor bump per semver — feature add) Files: - hooks/rules/rules.yaml — 4 new rule entries with full test arrays - hooks/rules/rules.json — regenerated via npm run build-rules - hooks/rules/README.md — new "Database / migration discipline" family - package.json, .claude-plugin/{plugin,marketplace}.json — version bump Co-Authored-By: Claude Opus 4.7 (1M context) --- .claude-plugin/marketplace.json | 4 +- .claude-plugin/plugin.json | 2 +- hooks/rules/README.md | 11 ++ hooks/rules/rules.json | 290 ++++++++++++++++++++++++++++++++ hooks/rules/rules.yaml | 242 ++++++++++++++++++++++++++ package.json | 2 +- 6 files changed, 547 insertions(+), 4 deletions(-) diff --git a/.claude-plugin/marketplace.json b/.claude-plugin/marketplace.json index a36d1bf..e770c27 100644 --- a/.claude-plugin/marketplace.json +++ b/.claude-plugin/marketplace.json @@ -1,7 +1,7 @@ { "$schema": "https://anthropic.com/claude-code/marketplace.schema.json", "name": "make-no-mistakes", - "version": "1.6.0", + "version": "1.7.0", "description": "The disciplined dev lifecycle — implement issues, review PRs, sync releases, test E2E, manage sessions, and stash secrets via OS-native prompts. One plugin to make no mistakes.", "owner": { "name": "Luis Andres Pena Castillo", @@ -11,7 +11,7 @@ { "name": "make-no-mistakes", "description": "Dev lifecycle orchestrator: disciplined Linear issue execution with worktree isolation, PR review with Greptile gating, team release sync, E2E test generation and execution, test suite previewer, security pentesting, MoSCoW + RICE prioritization, cross-platform secret stash via OS-native GUI prompts (zenity / kdialog / osascript / Get-Credential), and session management. 18 commands, 6 auto-activating skills, 2 specialized agents.", - "version": "1.6.0", + "version": "1.7.0", "author": { "name": "Luis Andres Pena Castillo", "email": "lapc506@users.noreply.github.com" diff --git a/.claude-plugin/plugin.json b/.claude-plugin/plugin.json index 731facd..95c8a76 100644 --- a/.claude-plugin/plugin.json +++ b/.claude-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "make-no-mistakes", - "version": "1.6.0", + "version": "1.7.0", "description": "The disciplined dev lifecycle — implement issues, review PRs, sync releases, test E2E, manage sessions, stash secrets, and enforce manifest-driven tool-call hooks. One plugin to make no mistakes.", "author": { "name": "Luis Andres Pena Castillo", diff --git a/hooks/rules/README.md b/hooks/rules/README.md index e2f6881..d210760 100644 --- a/hooks/rules/README.md +++ b/hooks/rules/README.md @@ -105,6 +105,17 @@ Adding a new family is fine — just keep ids unique and follow the schema. alongside the runtime hook, pre-commit linter, and Storage upload validator. See [DOJ-3924](https://linear.app/dojocoding/issue/DOJ-3924). +- **Database / migration discipline** + (`schema-sql-outside-migrations`, `warn-psql-against-supabase-remote`, + `pr-create-with-migrations-needs-deploy-note`, + `block-supabase-db-push-prod`) — keep schema mutations inside + versioned `supabase/migrations/` files, nudge developers away from + SQL-Editor-style direct execution against `*.supabase.co` hosts, + remind PR authors to document migration deployment, and hard-block + `supabase db push` aimed at the production project ref. Added after + a Slack discussion surfaced drift between manually-applied SQL and + the migrations directory when migrations failed to auto-run on + staging after a teammate's PR merged. ## Tier 2 — decomposing non-deterministic memories diff --git a/hooks/rules/rules.json b/hooks/rules/rules.json index dc6c92f..668202b 100644 --- a/hooks/rules/rules.json +++ b/hooks/rules/rules.json @@ -882,5 +882,295 @@ "expected_exit": 0 } ] + }, + { + "id": "schema-sql-outside-migrations", + "description": "Block schema-changing SQL (ALTER/CREATE/DROP/GRANT/REVOKE/CREATE POLICY) in .sql files outside supabase/migrations/", + "applies_to": [ + "Edit", + "Write", + "MultiEdit" + ], + "match": [ + { + "field": "file_path", + "pattern": "\\.sql$", + "not_pattern": "(supabase/migrations/|supabase/tests/|supabase/seed)" + }, + { + "field": "content", + "pattern": "\\b(ALTER TABLE|CREATE TABLE|DROP TABLE|CREATE POLICY|DROP POLICY|GRANT |REVOKE |CREATE FUNCTION|DROP FUNCTION|CREATE TYPE|ALTER TYPE)\\b", + "flags": "i" + } + ], + "action": "block", + "bypass_marker": "schema-sql-outside-migrations", + "memory_ref": "feedback_scripts_not_db.md", + "message": "BLOCKED: schema-changing SQL in a file outside supabase/migrations/.\n\nEvery schema mutation (ALTER/CREATE/DROP/GRANT/POLICY) MUST live in a\ntimestamped migration file, never in ad-hoc scripts or runtime queries.\n\nCreate a new file:\n supabase/migrations/$(date -u +%Y%m%d%H%M%S)_.sql\n\nLegitimate exceptions (use the bypass marker):\n- Schema docs / examples in READMEs — should not be executable SQL\n- Test fixtures under supabase/tests/ — already exempt via not_pattern\n", + "tests": [ + { + "name": "blocks-create-table-in-script", + "input": { + "tool_input": { + "file_path": "scripts/setup.sql", + "content": "CREATE TABLE foo (id int primary key);" + } + }, + "expected_exit": 2 + }, + { + "name": "blocks-grant-in-edge-function", + "input": { + "tool_input": { + "file_path": "supabase/functions/foo/migrate.sql", + "content": "GRANT SELECT ON foo TO authenticated;" + } + }, + "expected_exit": 2 + }, + { + "name": "blocks-alter-table-in-readme-example", + "input": { + "tool_input": { + "file_path": "docs/example.sql", + "content": "ALTER TABLE foo ADD COLUMN bar text;" + } + }, + "expected_exit": 2 + }, + { + "name": "allows-schema-in-migrations-folder", + "input": { + "tool_input": { + "file_path": "supabase/migrations/20260509000000_foo.sql", + "content": "CREATE TABLE foo (id int primary key);" + } + }, + "expected_exit": 0 + }, + { + "name": "allows-schema-in-tests-folder", + "input": { + "tool_input": { + "file_path": "supabase/tests/foo_test.sql", + "content": "CREATE TABLE temp_test_t (id int);" + } + }, + "expected_exit": 0 + }, + { + "name": "allows-non-schema-sql-anywhere", + "input": { + "tool_input": { + "file_path": "scripts/query.sql", + "content": "SELECT * FROM foo;" + } + }, + "expected_exit": 0 + }, + { + "name": "allows-bypass-marker", + "input": { + "tool_input": { + "file_path": "scripts/oneshot.sql", + "content": "-- # hook-bypass: schema-sql-outside-migrations\nCREATE TABLE foo (id int primary key);\n" + } + }, + "expected_exit": 0 + } + ] + }, + { + "id": "warn-psql-against-supabase-remote", + "description": "Warn when psql/SQL is executed directly against a *.supabase.co host (drift risk vs versioned migrations)", + "applies_to": [ + "Bash" + ], + "match": [ + { + "field": "command", + "pattern": "psql.*\\.supabase\\.co", + "flags": "i" + } + ], + "action": "warn", + "bypass_marker": "psql-supabase-remote", + "memory_ref": "feedback_scripts_not_db.md", + "message": "WARNING: direct psql execution against a Supabase remote host.\n\nSQL Editor / psql-against-remote workflows create drift between the\nversioned migrations directory and other devs' local databases.\n\nCorrect workflow:\n 1. Add a migration in supabase/migrations/_.sql\n 2. Test locally: supabase db reset --local\n 3. Push to develop — CI applies the migration to staging automatically\n 4. For production: William runs the create-release SOP\n\nUse the bypass marker only for legitimate hotfixes where the PR cycle\nis too slow.\n", + "tests": [ + { + "name": "warns-on-psql-supabase-remote", + "input": { + "tool_input": { + "command": "psql postgresql://user:pass@db.xepaexmpawtpqtilhcpw.supabase.co:5432/postgres -c 'SELECT 1'" + } + }, + "expected_exit": 0, + "expected_stderr_contains": "warn-psql-against-supabase-remote" + }, + { + "name": "warns-on-psql-different-supabase-host", + "input": { + "tool_input": { + "command": "psql -h db.ukwovawzehnebuoowcec.supabase.co -d postgres -c 'CREATE INDEX foo_idx ON foo (id)'" + } + }, + "expected_exit": 0, + "expected_stderr_contains": "warn-psql-against-supabase-remote" + }, + { + "name": "allows-psql-localhost", + "input": { + "tool_input": { + "command": "psql -h localhost -p 54322 -d postgres -c 'SELECT 1'" + } + }, + "expected_exit": 0 + }, + { + "name": "allows-non-psql-supabase-references", + "input": { + "tool_input": { + "command": "curl https://xepaexmpawtpqtilhcpw.supabase.co/rest/v1/foo" + } + }, + "expected_exit": 0 + }, + { + "name": "allows-bypass-marker", + "input": { + "tool_input": { + "command": "psql -h db.xepaexmpawtpqtilhcpw.supabase.co -c 'SELECT 1' # hook-bypass: psql-supabase-remote" + } + }, + "expected_exit": 0 + } + ] + }, + { + "id": "pr-create-with-migrations-needs-deploy-note", + "description": "Warn when gh pr create body does not mention migrations (caller should verify the PR has no migration files)", + "applies_to": [ + "Bash" + ], + "match": [ + { + "field": "command", + "pattern": "gh[[:space:]]+pr[[:space:]]+create.*--body", + "flags": "i" + }, + { + "field": "command", + "not_pattern": "(migration|db push|supabase db|migration-deployment|requires.*staging|requires.*prod)", + "flags": "i" + } + ], + "action": "warn", + "bypass_marker": "pr-create-no-migration-note", + "memory_ref": "feedback_scripts_not_db.md", + "message": "WARNING: gh pr create body does not mention migrations.\n\nIf your PR modifies files under supabase/migrations/, the body MUST\ninclude a \"Migration deployment\" section that lists:\n 1. Which envs need supabase db push (staging auto via CI; prod via release)\n 2. Backfill order if the migrations depend on existing data\n 3. Post-merge verification steps\n\nThis rule does NOT inspect the diff — it only reads the command. Use\nthe bypass marker if your PR has no migrations.\n", + "tests": [ + { + "name": "warns-on-pr-create-body-without-migration-mention", + "input": { + "tool_input": { + "command": "gh pr create --base develop --title \"feat: foo\" --body \"Adds feature X\"" + } + }, + "expected_exit": 0, + "expected_stderr_contains": "pr-create-with-migrations-needs-deploy-note" + }, + { + "name": "allows-pr-create-body-mentioning-migration", + "input": { + "tool_input": { + "command": "gh pr create --base develop --title \"feat: foo\" --body \"Adds X. Migration deployment: requires supabase db push on staging\"" + } + }, + "expected_exit": 0 + }, + { + "name": "allows-pr-create-body-mentioning-db-push", + "input": { + "tool_input": { + "command": "gh pr create --body \"Use db push to apply\"" + } + }, + "expected_exit": 0 + }, + { + "name": "allows-bypass-marker", + "input": { + "tool_input": { + "command": "gh pr create --body \"release notes only # hook-bypass: pr-create-no-migration-note\"" + } + }, + "expected_exit": 0 + } + ] + }, + { + "id": "block-supabase-db-push-prod", + "description": "Block supabase db push targeting the PRODUCTION project ref without an explicit bypass marker", + "applies_to": [ + "Bash" + ], + "match": [ + { + "field": "command", + "pattern": "supabase[[:space:]]+db[[:space:]]+push.*ukwovawzehnebuoowcec" + } + ], + "action": "block", + "bypass_marker": "prod-db-push-approved", + "memory_ref": "feedback_never_touch_prod_without_approval.md", + "message": "BLOCKED: supabase db push targeting the PRODUCTION project ref\n(ukwovawzehnebuoowcec).\n\nNEVER run against production without explicit user approval — see\nfeedback_never_touch_prod_without_approval.md.\n\nCorrect workflow:\n 1. Merge to main\n 2. William runs the release per the SOP at\n https://github.com/DojoCodingLabs/dojo-os/blob/develop/.claude/commands/create-release.md\n\nUse the bypass marker ONLY when the user has verbally approved the\noperation.\n", + "tests": [ + { + "name": "blocks-supabase-db-push-prod", + "input": { + "tool_input": { + "command": "supabase db push --db-url postgresql://postgres:pwd@db.ukwovawzehnebuoowcec.supabase.co:5432/postgres" + } + }, + "expected_exit": 2 + }, + { + "name": "blocks-supabase-db-push-prod-with-project-ref-flag", + "input": { + "tool_input": { + "command": "supabase db push --project-ref ukwovawzehnebuoowcec" + } + }, + "expected_exit": 2 + }, + { + "name": "allows-supabase-db-push-staging", + "input": { + "tool_input": { + "command": "supabase db push --project-ref xepaexmpawtpqtilhcpw" + } + }, + "expected_exit": 0 + }, + { + "name": "allows-supabase-db-push-local", + "input": { + "tool_input": { + "command": "supabase db push" + } + }, + "expected_exit": 0 + }, + { + "name": "allows-bypass-marker", + "input": { + "tool_input": { + "command": "supabase db push --project-ref ukwovawzehnebuoowcec # hook-bypass: prod-db-push-approved" + } + }, + "expected_exit": 0 + } + ] } ] diff --git a/hooks/rules/rules.yaml b/hooks/rules/rules.yaml index c84303b..4d38519 100644 --- a/hooks/rules/rules.yaml +++ b/hooks/rules/rules.yaml @@ -675,3 +675,245 @@ file_path: 'src/components/Logo.tsx' content: ' // hook-bypass: ds-raw-hex-color' expected_exit: 0 + +# ----------------------------------------------------------------------------- +# Database / migration discipline rules +# +# These four rules prevent the class of drift exposed in Slack thread +# #C0ARS7RE5EK 2026-05-07: a teammate's PR shipped 3 supabase/migrations files +# that did not auto-run on staging; the manual SQL Editor workaround introduced +# drift between the migrations directory and other devs' local databases. +# Anti-pattern doubled because future devs had no record that the migrations +# had ever been applied outside of timestamped files. +# +# Layered defense: +# 1. schema-sql-outside-migrations — block ad-hoc DDL in non-migration files +# 2. warn-psql-against-supabase-remote — nudge away from SQL-Editor-style +# direct execution against any *.supabase.co host +# 3. pr-create-with-migrations-needs-deploy-note — remind the author to +# describe migration deployment when opening a PR +# 4. block-supabase-db-push-prod — hard stop on `supabase db push` aimed at +# the PRODUCTION project ref; only the create-release SOP may apply +# migrations to prod. +# ----------------------------------------------------------------------------- + +- id: schema-sql-outside-migrations + description: Block schema-changing SQL (ALTER/CREATE/DROP/GRANT/REVOKE/CREATE POLICY) in .sql files outside supabase/migrations/ + applies_to: [Edit, Write, MultiEdit] + match: + - field: file_path + pattern: '\.sql$' + not_pattern: '(supabase/migrations/|supabase/tests/|supabase/seed)' + - field: content + pattern: '\b(ALTER TABLE|CREATE TABLE|DROP TABLE|CREATE POLICY|DROP POLICY|GRANT |REVOKE |CREATE FUNCTION|DROP FUNCTION|CREATE TYPE|ALTER TYPE)\b' + flags: i + action: block + bypass_marker: schema-sql-outside-migrations + memory_ref: feedback_scripts_not_db.md + message: | + BLOCKED: schema-changing SQL in a file outside supabase/migrations/. + + Every schema mutation (ALTER/CREATE/DROP/GRANT/POLICY) MUST live in a + timestamped migration file, never in ad-hoc scripts or runtime queries. + + Create a new file: + supabase/migrations/$(date -u +%Y%m%d%H%M%S)_.sql + + Legitimate exceptions (use the bypass marker): + - Schema docs / examples in READMEs — should not be executable SQL + - Test fixtures under supabase/tests/ — already exempt via not_pattern + tests: + - name: blocks-create-table-in-script + input: + tool_input: + file_path: 'scripts/setup.sql' + content: 'CREATE TABLE foo (id int primary key);' + expected_exit: 2 + - name: blocks-grant-in-edge-function + input: + tool_input: + file_path: 'supabase/functions/foo/migrate.sql' + content: 'GRANT SELECT ON foo TO authenticated;' + expected_exit: 2 + - name: blocks-alter-table-in-readme-example + input: + tool_input: + file_path: 'docs/example.sql' + content: 'ALTER TABLE foo ADD COLUMN bar text;' + expected_exit: 2 + - name: allows-schema-in-migrations-folder + input: + tool_input: + file_path: 'supabase/migrations/20260509000000_foo.sql' + content: 'CREATE TABLE foo (id int primary key);' + expected_exit: 0 + - name: allows-schema-in-tests-folder + input: + tool_input: + file_path: 'supabase/tests/foo_test.sql' + content: 'CREATE TABLE temp_test_t (id int);' + expected_exit: 0 + - name: allows-non-schema-sql-anywhere + input: + tool_input: + file_path: 'scripts/query.sql' + content: 'SELECT * FROM foo;' + expected_exit: 0 + - name: allows-bypass-marker + input: + tool_input: + file_path: 'scripts/oneshot.sql' + content: | + -- # hook-bypass: schema-sql-outside-migrations + CREATE TABLE foo (id int primary key); + expected_exit: 0 + +- id: warn-psql-against-supabase-remote + description: Warn when psql/SQL is executed directly against a *.supabase.co host (drift risk vs versioned migrations) + applies_to: [Bash] + match: + - field: command + pattern: 'psql.*\.supabase\.co' + flags: i + action: warn + bypass_marker: psql-supabase-remote + memory_ref: feedback_scripts_not_db.md + message: | + WARNING: direct psql execution against a Supabase remote host. + + SQL Editor / psql-against-remote workflows create drift between the + versioned migrations directory and other devs' local databases. + + Correct workflow: + 1. Add a migration in supabase/migrations/_.sql + 2. Test locally: supabase db reset --local + 3. Push to develop — CI applies the migration to staging automatically + 4. For production: William runs the create-release SOP + + Use the bypass marker only for legitimate hotfixes where the PR cycle + is too slow. + tests: + - name: warns-on-psql-supabase-remote + input: + tool_input: + command: "psql postgresql://user:pass@db.xepaexmpawtpqtilhcpw.supabase.co:5432/postgres -c 'SELECT 1'" + expected_exit: 0 + expected_stderr_contains: 'warn-psql-against-supabase-remote' + - name: warns-on-psql-different-supabase-host + input: + tool_input: + command: "psql -h db.ukwovawzehnebuoowcec.supabase.co -d postgres -c 'CREATE INDEX foo_idx ON foo (id)'" + expected_exit: 0 + expected_stderr_contains: 'warn-psql-against-supabase-remote' + - name: allows-psql-localhost + input: + tool_input: + command: "psql -h localhost -p 54322 -d postgres -c 'SELECT 1'" + expected_exit: 0 + - name: allows-non-psql-supabase-references + input: + tool_input: + command: 'curl https://xepaexmpawtpqtilhcpw.supabase.co/rest/v1/foo' + expected_exit: 0 + - name: allows-bypass-marker + input: + tool_input: + command: "psql -h db.xepaexmpawtpqtilhcpw.supabase.co -c 'SELECT 1' # hook-bypass: psql-supabase-remote" + expected_exit: 0 + +- id: pr-create-with-migrations-needs-deploy-note + description: Warn when gh pr create body does not mention migrations (caller should verify the PR has no migration files) + applies_to: [Bash] + match: + - field: command + pattern: 'gh[[:space:]]+pr[[:space:]]+create.*--body' + flags: i + - field: command + not_pattern: '(migration|db push|supabase db|migration-deployment|requires.*staging|requires.*prod)' + flags: i + action: warn + bypass_marker: pr-create-no-migration-note + memory_ref: feedback_scripts_not_db.md + message: | + WARNING: gh pr create body does not mention migrations. + + If your PR modifies files under supabase/migrations/, the body MUST + include a "Migration deployment" section that lists: + 1. Which envs need supabase db push (staging auto via CI; prod via release) + 2. Backfill order if the migrations depend on existing data + 3. Post-merge verification steps + + This rule does NOT inspect the diff — it only reads the command. Use + the bypass marker if your PR has no migrations. + tests: + - name: warns-on-pr-create-body-without-migration-mention + input: + tool_input: + command: 'gh pr create --base develop --title "feat: foo" --body "Adds feature X"' + expected_exit: 0 + expected_stderr_contains: 'pr-create-with-migrations-needs-deploy-note' + - name: allows-pr-create-body-mentioning-migration + input: + tool_input: + command: 'gh pr create --base develop --title "feat: foo" --body "Adds X. Migration deployment: requires supabase db push on staging"' + expected_exit: 0 + - name: allows-pr-create-body-mentioning-db-push + input: + tool_input: + command: 'gh pr create --body "Use db push to apply"' + expected_exit: 0 + - name: allows-bypass-marker + input: + tool_input: + command: 'gh pr create --body "release notes only # hook-bypass: pr-create-no-migration-note"' + expected_exit: 0 + +- id: block-supabase-db-push-prod + description: Block supabase db push targeting the PRODUCTION project ref without an explicit bypass marker + applies_to: [Bash] + match: + - field: command + pattern: 'supabase[[:space:]]+db[[:space:]]+push.*ukwovawzehnebuoowcec' + action: block + bypass_marker: prod-db-push-approved + memory_ref: feedback_never_touch_prod_without_approval.md + message: | + BLOCKED: supabase db push targeting the PRODUCTION project ref + (ukwovawzehnebuoowcec). + + NEVER run against production without explicit user approval — see + feedback_never_touch_prod_without_approval.md. + + Correct workflow: + 1. Merge to main + 2. William runs the release per the SOP at + https://github.com/DojoCodingLabs/dojo-os/blob/develop/.claude/commands/create-release.md + + Use the bypass marker ONLY when the user has verbally approved the + operation. + tests: + - name: blocks-supabase-db-push-prod + input: + tool_input: + command: 'supabase db push --db-url postgresql://postgres:pwd@db.ukwovawzehnebuoowcec.supabase.co:5432/postgres' + expected_exit: 2 + - name: blocks-supabase-db-push-prod-with-project-ref-flag + input: + tool_input: + command: 'supabase db push --project-ref ukwovawzehnebuoowcec' + expected_exit: 2 + - name: allows-supabase-db-push-staging + input: + tool_input: + command: 'supabase db push --project-ref xepaexmpawtpqtilhcpw' + expected_exit: 0 + - name: allows-supabase-db-push-local + input: + tool_input: + command: 'supabase db push' + expected_exit: 0 + - name: allows-bypass-marker + input: + tool_input: + command: 'supabase db push --project-ref ukwovawzehnebuoowcec # hook-bypass: prod-db-push-approved' + expected_exit: 0 diff --git a/package.json b/package.json index a8a7ffe..b2d6c18 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@lapc506/make-no-mistakes", - "version": "1.6.0", + "version": "1.7.0", "description": "The disciplined dev lifecycle — implement issues, review PRs, sync releases, test E2E, manage sessions, stash secrets, and enforce manifest-driven tool-call hooks (no SSH+DB, no manual prod, no minified build, no secret leaks, Slack format). OpenCode + Claude Code plugin.", "type": "module", "main": "./dist/index.js", From e930eba7e360a7be2e26affee354f6728744da8a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9s=20Pe=C3=B1a?= Date: Sat, 9 May 2026 22:11:11 -0600 Subject: [PATCH 2/2] fix(rules): address Greptile review on migration-discipline rules MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Greptile flagged 4 issues on the initial PR (3/5 confidence). All four are now resolved without weakening the rules' intent: 1. Marketplace-safe production project ref (was: hardcoded literal) - Replace ukwovawzehnebuoowcec / xepaexmpawtpqtilhcpw in rules.yaml with __PROD_SUPABASE_REF__ / __STAGING_SUPABASE_REF__ placeholders. - Extend scripts/build-rules.mjs with an opt-in substitution layer that reads .private/substitutions.json (UPPER_SNAKE token map) and swaps placeholders before YAML parsing. Mirrors the existing gitignored .private/forbidden-names.txt IP-leak guard. - When the file is absent, placeholders remain in rules.json and the rule is documented inert (only fires for commands containing the literal placeholder text). Preferred over a silent broken protection for marketplace consumers. - hooks/rules/README.md documents the substitution workflow and lists currently-consumed tokens. 2. Cover supabase db push --linked, which silently bypassed the block when the linked project was prod. Pattern now matches __PROD_SUPABASE_REF__ OR --linked\b. Two new tests: blocks-supabase-db-push-linked and blocks-supabase-db-push-linked-with-extra-flags. Bypass marker prod-db-push-approved continues to apply. 3. Stop false-positive on gh pr create --body-file. Positive pattern tightened to require --body followed by space, "=", or end-of-string; added not_pattern --body-file as defense in depth. New test allows-pr-create-with-body-file. 4. Extend warn-psql-against-supabase-remote to cover pg_dump, pg_restore, pg_dumpall — equivalent vectors for ad-hoc Postgres CLI work against the live Supabase host. Two new tests: warns-on-pg-dump-supabase-remote and warns-on-pg-restore-supabase-remote. Test fixtures use generic example.supabase.co hosts (no team-specific refs). Test count: 80 -> 86 (6 new tests, all 86 pass). No version bump (still 1.7.0 — pre-merge fixes). Co-Authored-By: Claude Opus 4.7 (1M context) --- hooks/rules/README.md | 52 +++++++++++++-- hooks/rules/rules.json | 93 +++++++++++++++++++++------ hooks/rules/rules.yaml | 136 ++++++++++++++++++++++++++++++---------- scripts/build-rules.mjs | 53 +++++++++++++++- 4 files changed, 275 insertions(+), 59 deletions(-) diff --git a/hooks/rules/README.md b/hooks/rules/README.md index d210760..ea3920a 100644 --- a/hooks/rules/README.md +++ b/hooks/rules/README.md @@ -84,6 +84,43 @@ client-acme client-beta ``` +## Per-install substitutions (opt-in, gitignored) + +Some rules need values that are specific to your team or environment — +for example, the `block-supabase-db-push-prod` rule guards a Supabase +production project ref that varies per organization. Hard-coding such +values in the public rules.yaml would either leak the value upstream or +leave consumers of this toolkit with a rule that silently never fires. + +The build script supports literal-string substitution from a gitignored +`.private/substitutions.json` file. The file is a flat JSON object where +keys are UPPER_SNAKE token names and values are the literal replacement +strings. For each pair, every occurrence of `__TOKEN__` in `rules.yaml` is +replaced before the YAML is parsed, so both the rule patterns and the +test fixtures see the substituted value. + +Example `.private/substitutions.json`: + +```json +{ + "PROD_SUPABASE_REF": "abcdefghij1234567890", + "STAGING_SUPABASE_REF": "klmnopqrst0987654321" +} +``` + +When the file is absent, `__TOKEN__` placeholders remain in `rules.json` +verbatim. The rule still parses and runs; it simply does not fire for any +real-world command (only for commands that happen to contain the literal +placeholder text). This is a deliberately documented inert state — it is +preferable to a silently-broken protection. + +Tokens currently consumed by the published rule set: + +- `PROD_SUPABASE_REF` — production Supabase project ref, used by the + `block-supabase-db-push-prod` rule and its test fixtures. +- `STAGING_SUPABASE_REF` — staging Supabase project ref, used as the + negative-match example in the same rule's test array. + ## Rule families The manifest groups rules into informal families by prefix / domain. @@ -110,12 +147,15 @@ Adding a new family is fine — just keep ids unique and follow the schema. `pr-create-with-migrations-needs-deploy-note`, `block-supabase-db-push-prod`) — keep schema mutations inside versioned `supabase/migrations/` files, nudge developers away from - SQL-Editor-style direct execution against `*.supabase.co` hosts, - remind PR authors to document migration deployment, and hard-block - `supabase db push` aimed at the production project ref. Added after - a Slack discussion surfaced drift between manually-applied SQL and - the migrations directory when migrations failed to auto-run on - staging after a teammate's PR merged. + direct `psql` / `pg_dump` / `pg_restore` execution against + `*.supabase.co` hosts, remind PR authors to document migration + deployment, and hard-block `supabase db push` aimed at the production + project ref or `--linked` (which transparently resolves to whichever + project was last linked, possibly production). The production project + ref is configured per install via the substitutions mechanism described + above (`PROD_SUPABASE_REF` token). Added after a discussion surfaced + drift between manually-applied SQL and the migrations directory when + migrations failed to auto-run after a teammate's PR merged. ## Tier 2 — decomposing non-deterministic memories diff --git a/hooks/rules/rules.json b/hooks/rules/rules.json index 668202b..77df8d4 100644 --- a/hooks/rules/rules.json +++ b/hooks/rules/rules.json @@ -982,27 +982,27 @@ }, { "id": "warn-psql-against-supabase-remote", - "description": "Warn when psql/SQL is executed directly against a *.supabase.co host (drift risk vs versioned migrations)", + "description": "Warn when psql / pg_dump / pg_restore is executed directly against a *.supabase.co host (drift risk vs versioned migrations)", "applies_to": [ "Bash" ], "match": [ { "field": "command", - "pattern": "psql.*\\.supabase\\.co", + "pattern": "\\b(psql|pg_dump|pg_restore|pg_dumpall)\\b.*\\.supabase\\.co", "flags": "i" } ], "action": "warn", "bypass_marker": "psql-supabase-remote", "memory_ref": "feedback_scripts_not_db.md", - "message": "WARNING: direct psql execution against a Supabase remote host.\n\nSQL Editor / psql-against-remote workflows create drift between the\nversioned migrations directory and other devs' local databases.\n\nCorrect workflow:\n 1. Add a migration in supabase/migrations/_.sql\n 2. Test locally: supabase db reset --local\n 3. Push to develop — CI applies the migration to staging automatically\n 4. For production: William runs the create-release SOP\n\nUse the bypass marker only for legitimate hotfixes where the PR cycle\nis too slow.\n", + "message": "WARNING: direct psql / pg_dump / pg_restore execution against a Supabase\nremote host.\n\nSQL Editor / direct-Postgres-CLI workflows create drift between the\nversioned migrations directory and other devs' local databases.\n\nCorrect workflow:\n 1. Add a migration in supabase/migrations/_.sql\n 2. Test locally: supabase db reset --local\n 3. Push to develop — CI applies the migration to staging automatically\n 4. For production: follow the create-release SOP\n\nUse the bypass marker only for legitimate hotfixes where the PR cycle\nis too slow.\n", "tests": [ { "name": "warns-on-psql-supabase-remote", "input": { "tool_input": { - "command": "psql postgresql://user:pass@db.xepaexmpawtpqtilhcpw.supabase.co:5432/postgres -c 'SELECT 1'" + "command": "psql postgresql://user:pass@db.example1.supabase.co:5432/postgres -c 'SELECT 1'" } }, "expected_exit": 0, @@ -1012,7 +1012,27 @@ "name": "warns-on-psql-different-supabase-host", "input": { "tool_input": { - "command": "psql -h db.ukwovawzehnebuoowcec.supabase.co -d postgres -c 'CREATE INDEX foo_idx ON foo (id)'" + "command": "psql -h db.example2.supabase.co -d postgres -c 'CREATE INDEX foo_idx ON foo (id)'" + } + }, + "expected_exit": 0, + "expected_stderr_contains": "warn-psql-against-supabase-remote" + }, + { + "name": "warns-on-pg-dump-supabase-remote", + "input": { + "tool_input": { + "command": "pg_dump -h db.example1.supabase.co -d postgres -t public.foo > foo.sql" + } + }, + "expected_exit": 0, + "expected_stderr_contains": "warn-psql-against-supabase-remote" + }, + { + "name": "warns-on-pg-restore-supabase-remote", + "input": { + "tool_input": { + "command": "pg_restore -h db.example1.supabase.co -d postgres backup.dump" } }, "expected_exit": 0, @@ -1031,7 +1051,7 @@ "name": "allows-non-psql-supabase-references", "input": { "tool_input": { - "command": "curl https://xepaexmpawtpqtilhcpw.supabase.co/rest/v1/foo" + "command": "curl https://example1.supabase.co/rest/v1/foo" } }, "expected_exit": 0 @@ -1040,7 +1060,7 @@ "name": "allows-bypass-marker", "input": { "tool_input": { - "command": "psql -h db.xepaexmpawtpqtilhcpw.supabase.co -c 'SELECT 1' # hook-bypass: psql-supabase-remote" + "command": "psql -h db.example1.supabase.co -c 'SELECT 1' # hook-bypass: psql-supabase-remote" } }, "expected_exit": 0 @@ -1049,14 +1069,15 @@ }, { "id": "pr-create-with-migrations-needs-deploy-note", - "description": "Warn when gh pr create body does not mention migrations (caller should verify the PR has no migration files)", + "description": "Warn when gh pr create body (inline --body) does not mention migrations (caller should verify the PR has no migration files)", "applies_to": [ "Bash" ], "match": [ { "field": "command", - "pattern": "gh[[:space:]]+pr[[:space:]]+create.*--body", + "pattern": "gh[[:space:]]+pr[[:space:]]+create.*--body([[:space:]]|=|$)", + "not_pattern": "--body-file", "flags": "i" }, { @@ -1068,7 +1089,7 @@ "action": "warn", "bypass_marker": "pr-create-no-migration-note", "memory_ref": "feedback_scripts_not_db.md", - "message": "WARNING: gh pr create body does not mention migrations.\n\nIf your PR modifies files under supabase/migrations/, the body MUST\ninclude a \"Migration deployment\" section that lists:\n 1. Which envs need supabase db push (staging auto via CI; prod via release)\n 2. Backfill order if the migrations depend on existing data\n 3. Post-merge verification steps\n\nThis rule does NOT inspect the diff — it only reads the command. Use\nthe bypass marker if your PR has no migrations.\n", + "message": "WARNING: gh pr create body does not mention migrations.\n\nIf your PR modifies files under supabase/migrations/, the body MUST\ninclude a \"Migration deployment\" section that lists:\n 1. Which envs need supabase db push (staging auto via CI; prod via release)\n 2. Backfill order if the migrations depend on existing data\n 3. Post-merge verification steps\n\nThis rule does NOT inspect the diff — it only reads the command, and it\nintentionally skips --body-file invocations because it cannot read the\nreferenced file. Use the bypass marker if your PR has no migrations.\n", "tests": [ { "name": "warns-on-pr-create-body-without-migration-mention", @@ -1098,6 +1119,15 @@ }, "expected_exit": 0 }, + { + "name": "allows-pr-create-with-body-file", + "input": { + "tool_input": { + "command": "gh pr create --base develop --title \"feat: foo\" --body-file ./pr-body.md" + } + }, + "expected_exit": 0 + }, { "name": "allows-bypass-marker", "input": { @@ -1111,26 +1141,26 @@ }, { "id": "block-supabase-db-push-prod", - "description": "Block supabase db push targeting the PRODUCTION project ref without an explicit bypass marker", + "description": "Block supabase db push that targets the PRODUCTION project ref or uses --linked (the linked project may transparently be production)", "applies_to": [ "Bash" ], "match": [ { "field": "command", - "pattern": "supabase[[:space:]]+db[[:space:]]+push.*ukwovawzehnebuoowcec" + "pattern": "supabase[[:space:]]+db[[:space:]]+push.*(__PROD_SUPABASE_REF__|--linked\\b)" } ], "action": "block", "bypass_marker": "prod-db-push-approved", "memory_ref": "feedback_never_touch_prod_without_approval.md", - "message": "BLOCKED: supabase db push targeting the PRODUCTION project ref\n(ukwovawzehnebuoowcec).\n\nNEVER run against production without explicit user approval — see\nfeedback_never_touch_prod_without_approval.md.\n\nCorrect workflow:\n 1. Merge to main\n 2. William runs the release per the SOP at\n https://github.com/DojoCodingLabs/dojo-os/blob/develop/.claude/commands/create-release.md\n\nUse the bypass marker ONLY when the user has verbally approved the\noperation.\n", + "message": "BLOCKED: supabase db push targeting either the PRODUCTION project ref\nor the currently-linked project (which may be production).\n\nNEVER run against production without explicit user approval — see\nfeedback_never_touch_prod_without_approval.md.\n\nCorrect workflow:\n 1. Merge to main\n 2. The release owner runs the create-release SOP that ships\n migrations to production through CI, not from a developer machine.\n\nUse the bypass marker ONLY when the user has verbally approved the\noperation. For --linked, double-check the active link first:\n supabase status --output json | jq -r '.project_ref'\n\nMarketplace consumers: the production project ref is configured per\ninstall via .private/substitutions.json (PROD_SUPABASE_REF token). See\nhooks/rules/README.md for the substitution workflow.\n", "tests": [ { "name": "blocks-supabase-db-push-prod", "input": { "tool_input": { - "command": "supabase db push --db-url postgresql://postgres:pwd@db.ukwovawzehnebuoowcec.supabase.co:5432/postgres" + "command": "supabase db push --db-url postgresql://postgres:pwd@db.__PROD_SUPABASE_REF__.supabase.co:5432/postgres" } }, "expected_exit": 2 @@ -1139,7 +1169,25 @@ "name": "blocks-supabase-db-push-prod-with-project-ref-flag", "input": { "tool_input": { - "command": "supabase db push --project-ref ukwovawzehnebuoowcec" + "command": "supabase db push --project-ref __PROD_SUPABASE_REF__" + } + }, + "expected_exit": 2 + }, + { + "name": "blocks-supabase-db-push-linked", + "input": { + "tool_input": { + "command": "supabase db push --linked" + } + }, + "expected_exit": 2 + }, + { + "name": "blocks-supabase-db-push-linked-with-extra-flags", + "input": { + "tool_input": { + "command": "supabase db push --linked --include-all" } }, "expected_exit": 2 @@ -1148,7 +1196,7 @@ "name": "allows-supabase-db-push-staging", "input": { "tool_input": { - "command": "supabase db push --project-ref xepaexmpawtpqtilhcpw" + "command": "supabase db push --project-ref __STAGING_SUPABASE_REF__" } }, "expected_exit": 0 @@ -1163,10 +1211,19 @@ "expected_exit": 0 }, { - "name": "allows-bypass-marker", + "name": "allows-bypass-marker-prod-ref", + "input": { + "tool_input": { + "command": "supabase db push --project-ref __PROD_SUPABASE_REF__ # hook-bypass: prod-db-push-approved" + } + }, + "expected_exit": 0 + }, + { + "name": "allows-bypass-marker-linked", "input": { "tool_input": { - "command": "supabase db push --project-ref ukwovawzehnebuoowcec # hook-bypass: prod-db-push-approved" + "command": "supabase db push --linked # hook-bypass: prod-db-push-approved" } }, "expected_exit": 0 diff --git a/hooks/rules/rules.yaml b/hooks/rules/rules.yaml index 4d38519..2ccb787 100644 --- a/hooks/rules/rules.yaml +++ b/hooks/rules/rules.yaml @@ -679,22 +679,30 @@ # ----------------------------------------------------------------------------- # Database / migration discipline rules # -# These four rules prevent the class of drift exposed in Slack thread -# #C0ARS7RE5EK 2026-05-07: a teammate's PR shipped 3 supabase/migrations files -# that did not auto-run on staging; the manual SQL Editor workaround introduced -# drift between the migrations directory and other devs' local databases. -# Anti-pattern doubled because future devs had no record that the migrations -# had ever been applied outside of timestamped files. +# These four rules prevent the class of drift that occurs when migration files +# fail to auto-run in a downstream environment and the workaround is to apply +# the SQL manually via psql / the Supabase SQL Editor — leaving the migrations +# directory and downstream databases silently out of sync. # # Layered defense: # 1. schema-sql-outside-migrations — block ad-hoc DDL in non-migration files # 2. warn-psql-against-supabase-remote — nudge away from SQL-Editor-style -# direct execution against any *.supabase.co host +# direct execution (psql / pg_dump / pg_restore) against any *.supabase.co +# host # 3. pr-create-with-migrations-needs-deploy-note — remind the author to # describe migration deployment when opening a PR # 4. block-supabase-db-push-prod — hard stop on `supabase db push` aimed at -# the PRODUCTION project ref; only the create-release SOP may apply -# migrations to prod. +# the PRODUCTION project ref OR --linked (which may transparently target +# a previously-linked production project); only the create-release SOP +# may apply migrations to prod. +# +# Rule 4 references project-ref placeholders __PROD_SUPABASE_REF__ and +# __STAGING_SUPABASE_REF__. The `npm run build-rules` step substitutes these +# from the per-install gitignored config at .private/substitutions.json +# (UPPER_SNAKE token map). Without the config the placeholders remain +# in rules.json — the rule still parses and runs, but it only fires for +# commands that literally contain the placeholder text (i.e., it is +# documented inert). See hooks/rules/README.md for the substitution workflow. # ----------------------------------------------------------------------------- - id: schema-sql-outside-migrations @@ -769,26 +777,31 @@ expected_exit: 0 - id: warn-psql-against-supabase-remote - description: Warn when psql/SQL is executed directly against a *.supabase.co host (drift risk vs versioned migrations) + description: Warn when psql / pg_dump / pg_restore is executed directly against a *.supabase.co host (drift risk vs versioned migrations) applies_to: [Bash] match: - field: command - pattern: 'psql.*\.supabase\.co' + # Cover the full Postgres CLI surface a developer might reach for — + # not just psql. pg_dump / pg_restore / pg_dumpall against the live + # Supabase host are equivalent vectors for ad-hoc reads and writes + # that bypass the versioned migrations workflow. + pattern: '\b(psql|pg_dump|pg_restore|pg_dumpall)\b.*\.supabase\.co' flags: i action: warn bypass_marker: psql-supabase-remote memory_ref: feedback_scripts_not_db.md message: | - WARNING: direct psql execution against a Supabase remote host. + WARNING: direct psql / pg_dump / pg_restore execution against a Supabase + remote host. - SQL Editor / psql-against-remote workflows create drift between the + SQL Editor / direct-Postgres-CLI workflows create drift between the versioned migrations directory and other devs' local databases. Correct workflow: 1. Add a migration in supabase/migrations/_.sql 2. Test locally: supabase db reset --local 3. Push to develop — CI applies the migration to staging automatically - 4. For production: William runs the create-release SOP + 4. For production: follow the create-release SOP Use the bypass marker only for legitimate hotfixes where the PR cycle is too slow. @@ -796,13 +809,25 @@ - name: warns-on-psql-supabase-remote input: tool_input: - command: "psql postgresql://user:pass@db.xepaexmpawtpqtilhcpw.supabase.co:5432/postgres -c 'SELECT 1'" + command: "psql postgresql://user:pass@db.example1.supabase.co:5432/postgres -c 'SELECT 1'" expected_exit: 0 expected_stderr_contains: 'warn-psql-against-supabase-remote' - name: warns-on-psql-different-supabase-host input: tool_input: - command: "psql -h db.ukwovawzehnebuoowcec.supabase.co -d postgres -c 'CREATE INDEX foo_idx ON foo (id)'" + command: "psql -h db.example2.supabase.co -d postgres -c 'CREATE INDEX foo_idx ON foo (id)'" + expected_exit: 0 + expected_stderr_contains: 'warn-psql-against-supabase-remote' + - name: warns-on-pg-dump-supabase-remote + input: + tool_input: + command: 'pg_dump -h db.example1.supabase.co -d postgres -t public.foo > foo.sql' + expected_exit: 0 + expected_stderr_contains: 'warn-psql-against-supabase-remote' + - name: warns-on-pg-restore-supabase-remote + input: + tool_input: + command: 'pg_restore -h db.example1.supabase.co -d postgres backup.dump' expected_exit: 0 expected_stderr_contains: 'warn-psql-against-supabase-remote' - name: allows-psql-localhost @@ -813,20 +838,26 @@ - name: allows-non-psql-supabase-references input: tool_input: - command: 'curl https://xepaexmpawtpqtilhcpw.supabase.co/rest/v1/foo' + command: 'curl https://example1.supabase.co/rest/v1/foo' expected_exit: 0 - name: allows-bypass-marker input: tool_input: - command: "psql -h db.xepaexmpawtpqtilhcpw.supabase.co -c 'SELECT 1' # hook-bypass: psql-supabase-remote" + command: "psql -h db.example1.supabase.co -c 'SELECT 1' # hook-bypass: psql-supabase-remote" expected_exit: 0 - id: pr-create-with-migrations-needs-deploy-note - description: Warn when gh pr create body does not mention migrations (caller should verify the PR has no migration files) + description: Warn when gh pr create body (inline --body) does not mention migrations (caller should verify the PR has no migration files) applies_to: [Bash] match: - field: command - pattern: 'gh[[:space:]]+pr[[:space:]]+create.*--body' + # Require an inline --body argument and explicitly exclude --body-file + # (which sources the body from a separate file the rule cannot read, + # so any keyword check would produce a false positive). Matching --body + # immediately followed by space, "=" or end-of-string anchors the + # positive match away from --body-file's "--body-" substring. + pattern: 'gh[[:space:]]+pr[[:space:]]+create.*--body([[:space:]]|=|$)' + not_pattern: '--body-file' flags: i - field: command not_pattern: '(migration|db push|supabase db|migration-deployment|requires.*staging|requires.*prod)' @@ -843,8 +874,9 @@ 2. Backfill order if the migrations depend on existing data 3. Post-merge verification steps - This rule does NOT inspect the diff — it only reads the command. Use - the bypass marker if your PR has no migrations. + This rule does NOT inspect the diff — it only reads the command, and it + intentionally skips --body-file invocations because it cannot read the + referenced file. Use the bypass marker if your PR has no migrations. tests: - name: warns-on-pr-create-body-without-migration-mention input: @@ -862,6 +894,11 @@ tool_input: command: 'gh pr create --body "Use db push to apply"' expected_exit: 0 + - name: allows-pr-create-with-body-file + input: + tool_input: + command: 'gh pr create --base develop --title "feat: foo" --body-file ./pr-body.md' + expected_exit: 0 - name: allows-bypass-marker input: tool_input: @@ -869,51 +906,82 @@ expected_exit: 0 - id: block-supabase-db-push-prod - description: Block supabase db push targeting the PRODUCTION project ref without an explicit bypass marker + description: Block supabase db push that targets the PRODUCTION project ref or uses --linked (the linked project may transparently be production) applies_to: [Bash] match: - field: command - pattern: 'supabase[[:space:]]+db[[:space:]]+push.*ukwovawzehnebuoowcec' + # Two paths into the production database, both blocked: + # 1. An explicit --project-ref / --db-url containing __PROD_SUPABASE_REF__ + # (substituted at build time from .private/substitutions.json; see + # hooks/rules/README.md). Without the substitution, the literal + # placeholder string remains and the rule is documented inert. + # 2. `supabase db push --linked`, which resolves to whichever project + # the developer most recently ran `supabase link --project-ref ...` + # against. The CLI gives no static way to know whether that link + # points at staging or prod, so we conservatively block it and + # require the bypass marker as the author's explicit ack that they + # verified the linked project is intentional. + pattern: 'supabase[[:space:]]+db[[:space:]]+push.*(__PROD_SUPABASE_REF__|--linked\b)' action: block bypass_marker: prod-db-push-approved memory_ref: feedback_never_touch_prod_without_approval.md message: | - BLOCKED: supabase db push targeting the PRODUCTION project ref - (ukwovawzehnebuoowcec). + BLOCKED: supabase db push targeting either the PRODUCTION project ref + or the currently-linked project (which may be production). NEVER run against production without explicit user approval — see feedback_never_touch_prod_without_approval.md. Correct workflow: 1. Merge to main - 2. William runs the release per the SOP at - https://github.com/DojoCodingLabs/dojo-os/blob/develop/.claude/commands/create-release.md + 2. The release owner runs the create-release SOP that ships + migrations to production through CI, not from a developer machine. Use the bypass marker ONLY when the user has verbally approved the - operation. + operation. For --linked, double-check the active link first: + supabase status --output json | jq -r '.project_ref' + + Marketplace consumers: the production project ref is configured per + install via .private/substitutions.json (PROD_SUPABASE_REF token). See + hooks/rules/README.md for the substitution workflow. tests: - name: blocks-supabase-db-push-prod input: tool_input: - command: 'supabase db push --db-url postgresql://postgres:pwd@db.ukwovawzehnebuoowcec.supabase.co:5432/postgres' + command: 'supabase db push --db-url postgresql://postgres:pwd@db.__PROD_SUPABASE_REF__.supabase.co:5432/postgres' expected_exit: 2 - name: blocks-supabase-db-push-prod-with-project-ref-flag input: tool_input: - command: 'supabase db push --project-ref ukwovawzehnebuoowcec' + command: 'supabase db push --project-ref __PROD_SUPABASE_REF__' + expected_exit: 2 + - name: blocks-supabase-db-push-linked + input: + tool_input: + command: 'supabase db push --linked' + expected_exit: 2 + - name: blocks-supabase-db-push-linked-with-extra-flags + input: + tool_input: + command: 'supabase db push --linked --include-all' expected_exit: 2 - name: allows-supabase-db-push-staging input: tool_input: - command: 'supabase db push --project-ref xepaexmpawtpqtilhcpw' + command: 'supabase db push --project-ref __STAGING_SUPABASE_REF__' expected_exit: 0 - name: allows-supabase-db-push-local input: tool_input: command: 'supabase db push' expected_exit: 0 - - name: allows-bypass-marker + - name: allows-bypass-marker-prod-ref + input: + tool_input: + command: 'supabase db push --project-ref __PROD_SUPABASE_REF__ # hook-bypass: prod-db-push-approved' + expected_exit: 0 + - name: allows-bypass-marker-linked input: tool_input: - command: 'supabase db push --project-ref ukwovawzehnebuoowcec # hook-bypass: prod-db-push-approved' + command: 'supabase db push --linked # hook-bypass: prod-db-push-approved' expected_exit: 0 diff --git a/scripts/build-rules.mjs b/scripts/build-rules.mjs index e10cf2d..ec21866 100755 --- a/scripts/build-rules.mjs +++ b/scripts/build-rules.mjs @@ -39,7 +39,58 @@ if (existsSync(forbiddenFile)) { ); } -const source = readFileSync(yamlPath, 'utf8'); +// Optional per-install substitution layer. Lets each consumer of this toolkit +// specialize project-specific values (e.g., Supabase project refs) without +// committing them to the public source tree. The file maps UPPER_SNAKE token +// names to literal replacement strings; for each pair, every "__TOKEN__" +// occurrence in rules.yaml is replaced before YAML parsing. +// +// When the file is absent, "__TOKEN__" remains in the output verbatim. The +// rule still fires for any command that literally contains "__TOKEN__" +// (i.e., effectively inert — a marketplace consumer who hasn't configured a +// substitution gets a documented no-op rule, not a silent broken protection). +// +// Token names must be UPPER_SNAKE_CASE so they can't be confused with regex +// metacharacters or with the existing kebab-case rule / bypass identifiers. +// See hooks/rules/README.md for the full opt-in workflow. +const substFile = join(repoRoot, '.private', 'substitutions.json'); +let SUBSTITUTIONS = {}; +if (existsSync(substFile)) { + try { + SUBSTITUTIONS = JSON.parse(readFileSync(substFile, 'utf8')); + } catch (err) { + console.error(`Failed to parse ${substFile}: ${err.message}`); + process.exit(1); + } + if (typeof SUBSTITUTIONS !== 'object' || Array.isArray(SUBSTITUTIONS)) { + console.error(`${substFile} must be a JSON object of {TOKEN: value} pairs`); + process.exit(1); + } + for (const token of Object.keys(SUBSTITUTIONS)) { + if (!/^[A-Z][A-Z0-9_]*$/.test(token)) { + console.error( + `Invalid substitution token "${token}" in ${substFile}: must be UPPER_SNAKE_CASE`, + ); + process.exit(1); + } + } + console.log( + `Substitutions active (${Object.keys(SUBSTITUTIONS).length} token(s) loaded from .private/substitutions.json)`, + ); +} + +let source = readFileSync(yamlPath, 'utf8'); +for (const [token, value] of Object.entries(SUBSTITUTIONS)) { + if (typeof value !== 'string') { + console.error( + `Substitution value for "${token}" must be a string, got ${typeof value}`, + ); + process.exit(1); + } + // Use split+join (not regex replace) so the value is treated as a literal + // string — no risk of $-backref interpolation in the replacement. + source = source.split(`__${token}__`).join(value); +} const rules = yaml.load(source); if (!Array.isArray(rules)) {