From 588957e32a52c07a6e6da5591ab19cc2b15b3acb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9s=20Pe=C3=B1a?= Date: Sat, 9 May 2026 23:36:34 -0600 Subject: [PATCH 1/4] feat(rules): add warn-curl-mutating-supabase-rest (close feedback_scripts_not_db.md) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a single warn rule to the migration-discipline family that catches the last documented escape hatch from the "no ad-hoc DB writes" memory: direct curl mutations (POST / PATCH / PUT / DELETE) against the Supabase PostgREST endpoint. The neighbor warn-psql-against-supabase-remote already covers the psql / pg_dump / pg_restore vector; warn-curl-mutating-supabase-rest closes the final gap. Match is anchored on the explicit `-X ` form and narrowed to `.supabase.co/rest/v1/` so Auth, Storage, and Edge Function endpoints are not flagged. Test fixtures use sanitized hosts (example.supabase.co) — same convention the migration-discipline rules adopted in PR #15 round-2. - 19 rules (was 18) - 93 / 93 tests pass (was 86, +7) - 1.7.0 -> 1.8.0 in package.json, plugin.json, marketplace.json (top-level + nested plugin entry) - README family entry extended with the new rule --- .claude-plugin/marketplace.json | 4 +- .claude-plugin/plugin.json | 2 +- hooks/rules/README.md | 26 +++--- hooks/rules/rules.json | 87 +++++++++++++++++++ hooks/rules/rules.yaml | 71 +++++++++++++++ .../design.md | 61 +++++++++++++ .../proposal.md | 43 +++++++++ .../tasks.md | 11 +++ package.json | 2 +- 9 files changed, 292 insertions(+), 15 deletions(-) create mode 100644 openspec/changes/2026-05-hook-curl-mutating-supabase-rest/design.md create mode 100644 openspec/changes/2026-05-hook-curl-mutating-supabase-rest/proposal.md create mode 100644 openspec/changes/2026-05-hook-curl-mutating-supabase-rest/tasks.md diff --git a/.claude-plugin/marketplace.json b/.claude-plugin/marketplace.json index e770c27..fe6be04 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.7.0", + "version": "1.8.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.7.0", + "version": "1.8.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 95c8a76..364094a 100644 --- a/.claude-plugin/plugin.json +++ b/.claude-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "make-no-mistakes", - "version": "1.7.0", + "version": "1.8.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 ea3920a..9a1901e 100644 --- a/hooks/rules/README.md +++ b/hooks/rules/README.md @@ -145,17 +145,21 @@ Adding a new family is fine — just keep ids unique and follow the schema. - **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 - 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. + `block-supabase-db-push-prod`, `warn-curl-mutating-supabase-rest`) — + keep schema mutations inside versioned `supabase/migrations/` files, + nudge developers away from direct `psql` / `pg_dump` / `pg_restore` + execution against `*.supabase.co` hosts, remind PR authors to document + migration deployment, hard-block `supabase db push` aimed at the + production project ref or `--linked` (which transparently resolves to + whichever project was last linked, possibly production), and warn on + ad-hoc mutating `curl` (`POST` / `PATCH` / `PUT` / `DELETE`) against + the Supabase PostgREST endpoint (drift risk equivalent to direct `psql` + + RLS bypass — same memory ref `feedback_scripts_not_db.md`). 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 77df8d4..1669590 100644 --- a/hooks/rules/rules.json +++ b/hooks/rules/rules.json @@ -1229,5 +1229,92 @@ "expected_exit": 0 } ] + }, + { + "id": "warn-curl-mutating-supabase-rest", + "description": "Warn when curl issues a mutating HTTP method (POST/PATCH/PUT/DELETE) against the Supabase PostgREST endpoint (drift risk vs versioned migrations + RLS bypass)", + "applies_to": [ + "Bash" + ], + "match": [ + { + "field": "command", + "pattern": "curl.*-X[[:space:]]+(POST|PATCH|PUT|DELETE).*\\.supabase\\.co/rest/v1/", + "flags": "i" + } + ], + "action": "warn", + "bypass_marker": "curl-supabase-rest-mutation", + "memory_ref": "feedback_scripts_not_db.md", + "message": "WARNING: mutating curl against the Supabase PostgREST endpoint.\n\nDirect REST mutations bypass:\n - the versioned migrations workflow (drift across local DBs / envs),\n - the RLS + admin-role checks the Supabase clients enforce, and\n - the access pattern already encapsulated in src/services/api/.\n\nCorrect outlets:\n - Schema changes -> supabase/migrations/_.sql\n - Data mutations -> an Edge Function or a committed script\n - One-shot fixes -> use the API client from a versioned script\n\nUse the bypass marker (curl-supabase-rest-mutation) only for documented\nhotfixes that the team has explicitly approved.\n", + "tests": [ + { + "name": "blocks-curl-post-supabase-rest", + "input": { + "tool_input": { + "command": "curl -X POST https://example.supabase.co/rest/v1/profiles -H 'apikey: x' -d '{\"id\":1}'" + } + }, + "expected_exit": 0, + "expected_stderr_contains": "warn-curl-mutating-supabase-rest" + }, + { + "name": "blocks-curl-patch", + "input": { + "tool_input": { + "command": "curl -X PATCH https://example.supabase.co/rest/v1/profiles?id=eq.1 -d '{\"name\":\"a\"}'" + } + }, + "expected_exit": 0, + "expected_stderr_contains": "warn-curl-mutating-supabase-rest" + }, + { + "name": "blocks-curl-put", + "input": { + "tool_input": { + "command": "curl -X PUT https://example.supabase.co/rest/v1/profiles?id=eq.1 -d '{\"name\":\"b\"}'" + } + }, + "expected_exit": 0, + "expected_stderr_contains": "warn-curl-mutating-supabase-rest" + }, + { + "name": "blocks-curl-delete", + "input": { + "tool_input": { + "command": "curl -X DELETE https://example.supabase.co/rest/v1/profiles?id=eq.1" + } + }, + "expected_exit": 0, + "expected_stderr_contains": "warn-curl-mutating-supabase-rest" + }, + { + "name": "allows-curl-get-supabase-rest", + "input": { + "tool_input": { + "command": "curl https://example.supabase.co/rest/v1/profiles?select=id,name" + } + }, + "expected_exit": 0 + }, + { + "name": "allows-curl-non-supabase", + "input": { + "tool_input": { + "command": "curl -X POST https://example.com/api/v1/users -d '{\"id\":1}'" + } + }, + "expected_exit": 0 + }, + { + "name": "allows-bypass-marker", + "input": { + "tool_input": { + "command": "curl -X POST https://example.supabase.co/rest/v1/profiles -d '{\"id\":1}' # hook-bypass: curl-supabase-rest-mutation" + } + }, + "expected_exit": 0 + } + ] } ] diff --git a/hooks/rules/rules.yaml b/hooks/rules/rules.yaml index 2ccb787..b042339 100644 --- a/hooks/rules/rules.yaml +++ b/hooks/rules/rules.yaml @@ -985,3 +985,74 @@ tool_input: command: 'supabase db push --linked # hook-bypass: prod-db-push-approved' expected_exit: 0 + +- id: warn-curl-mutating-supabase-rest + description: Warn when curl issues a mutating HTTP method (POST/PATCH/PUT/DELETE) against the Supabase PostgREST endpoint (drift risk vs versioned migrations + RLS bypass) + applies_to: [Bash] + match: + # Anchor on the explicit `-X ` form. Curl defaults to GET, so any + # author issuing a mutation invariably types `-X POST/PATCH/PUT/DELETE`. + # Match `.supabase.co/rest/v1/` (PostgREST) specifically, so this rule + # does NOT fire for Supabase Auth / Storage / Edge Function endpoints — + # those have separate guidance and Edge Functions are the intended outlet. + - field: command + pattern: 'curl.*-X[[:space:]]+(POST|PATCH|PUT|DELETE).*\.supabase\.co/rest/v1/' + flags: i + action: warn + bypass_marker: curl-supabase-rest-mutation + memory_ref: feedback_scripts_not_db.md + message: | + WARNING: mutating curl against the Supabase PostgREST endpoint. + + Direct REST mutations bypass: + - the versioned migrations workflow (drift across local DBs / envs), + - the RLS + admin-role checks the Supabase clients enforce, and + - the access pattern already encapsulated in src/services/api/. + + Correct outlets: + - Schema changes -> supabase/migrations/_.sql + - Data mutations -> an Edge Function or a committed script + - One-shot fixes -> use the API client from a versioned script + + Use the bypass marker (curl-supabase-rest-mutation) only for documented + hotfixes that the team has explicitly approved. + tests: + - name: blocks-curl-post-supabase-rest + input: + tool_input: + command: "curl -X POST https://example.supabase.co/rest/v1/profiles -H 'apikey: x' -d '{\"id\":1}'" + expected_exit: 0 + expected_stderr_contains: 'warn-curl-mutating-supabase-rest' + - name: blocks-curl-patch + input: + tool_input: + command: "curl -X PATCH https://example.supabase.co/rest/v1/profiles?id=eq.1 -d '{\"name\":\"a\"}'" + expected_exit: 0 + expected_stderr_contains: 'warn-curl-mutating-supabase-rest' + - name: blocks-curl-put + input: + tool_input: + command: "curl -X PUT https://example.supabase.co/rest/v1/profiles?id=eq.1 -d '{\"name\":\"b\"}'" + expected_exit: 0 + expected_stderr_contains: 'warn-curl-mutating-supabase-rest' + - name: blocks-curl-delete + input: + tool_input: + command: 'curl -X DELETE https://example.supabase.co/rest/v1/profiles?id=eq.1' + expected_exit: 0 + expected_stderr_contains: 'warn-curl-mutating-supabase-rest' + - name: allows-curl-get-supabase-rest + input: + tool_input: + command: 'curl https://example.supabase.co/rest/v1/profiles?select=id,name' + expected_exit: 0 + - name: allows-curl-non-supabase + input: + tool_input: + command: "curl -X POST https://example.com/api/v1/users -d '{\"id\":1}'" + expected_exit: 0 + - name: allows-bypass-marker + input: + tool_input: + command: "curl -X POST https://example.supabase.co/rest/v1/profiles -d '{\"id\":1}' # hook-bypass: curl-supabase-rest-mutation" + expected_exit: 0 diff --git a/openspec/changes/2026-05-hook-curl-mutating-supabase-rest/design.md b/openspec/changes/2026-05-hook-curl-mutating-supabase-rest/design.md new file mode 100644 index 0000000..1a659a8 --- /dev/null +++ b/openspec/changes/2026-05-hook-curl-mutating-supabase-rest/design.md @@ -0,0 +1,61 @@ +# Design — `warn-curl-mutating-supabase-rest` + +## Pattern + +``` +'curl.*-X[[:space:]]+(POST|PATCH|PUT|DELETE).*\.supabase\.co/rest/v1/' +``` + +Rationale: + +- Anchor on the explicit `-X ` form. `curl` defaults to `GET`, and + any author using a mutating method invariably types it as `-X POST` / + `-X PATCH` / `-X PUT` / `-X DELETE`. (Curl also accepts `--request`, but + the long form is uncommon enough that we accept the false negative — + authors using `--request` can be expected to know the rule and self-flag.) +- Match `.supabase.co/rest/v1/` rather than just `.supabase.co` so we don't + flag mutations against Supabase Auth / Storage endpoints (those have + separate guidance) or Edge Functions (which are exempt — they're the + intended outlet). +- Use `i` flag for case-insensitivity on the method names. + +## Action + +`warn`, not `block`. The rule is a nudge, not a hard stop — there are +legitimate reasons to issue a one-shot REST mutation (e.g. seeding a +local-only fixture, debugging an Edge Function invocation chain). The +`warn-psql-against-supabase-remote` neighbor uses the same severity; they +are the same class of nudge. + +## Bypass marker + +`curl-supabase-rest-mutation` — kebab-case, unique. Documented as +"hotfix only". + +## Test fixtures + +Use a **sanitized** Supabase host (`example.supabase.co`) — same convention +as the migration-discipline rules added in PR #15 round-2. Real project +refs would either leak in the public toolkit or trip the IP-leak guard. +Tests required (≥5): + +1. `blocks-curl-post-supabase-rest` — POST mutation, expects warn. +2. `blocks-curl-patch` — PATCH mutation, expects warn. +3. `blocks-curl-put` — PUT mutation, expects warn. +4. `blocks-curl-delete` — DELETE mutation, expects warn. +5. `allows-curl-get-supabase-rest` — GET (read), expects allow. +6. `allows-curl-non-supabase` — POST against `example.com`, expects allow. +7. `allows-bypass-marker` — POST + bypass comment, expects allow. + +(Test names use the `blocks-*` prefix purely for readability symmetry with +neighboring rules. Action is `warn`, so `expected_exit` is `0` everywhere +and we use `expected_stderr_contains` to assert the warning fired.) + +## Why warn, not block + +A `block` would frustrate legitimate one-off REST debugging. The author of +`feedback_scripts_not_db.md` is consistent: they prefer a warn for +psql-against-remote (the documented neighbor rule), and the same severity +logic applies here. If repeat offenders surface in code review, we can +escalate the action to `block` in a follow-up PR without changing the +schema. diff --git a/openspec/changes/2026-05-hook-curl-mutating-supabase-rest/proposal.md b/openspec/changes/2026-05-hook-curl-mutating-supabase-rest/proposal.md new file mode 100644 index 0000000..69f66ee --- /dev/null +++ b/openspec/changes/2026-05-hook-curl-mutating-supabase-rest/proposal.md @@ -0,0 +1,43 @@ +# Proposal — `warn-curl-mutating-supabase-rest` + +## Why + +`feedback_scripts_not_db.md` mandates that schema and data mutations always go +through versioned migrations or committed scripts — never via ad-hoc `psql`, +the SQL Editor, or direct REST calls against the live Supabase project. The +existing `warn-psql-against-supabase-remote` rule covers the `psql` / +`pg_dump` / `pg_restore` path, but `curl -X POST/PATCH/PUT/DELETE` against +PostgREST (`https://.supabase.co/rest/v1/...`) is an equivalent +escape hatch that the manifest does not yet warn about. + +PostgREST mutations: + +- bypass the versioned migrations workflow (drift vs other devs' local DBs), +- skip the RLS / role checks the Supabase clients enforce, +- and circumvent the API-client layer in `src/services/api/` that already + encapsulates the same access pattern. + +## What + +Add **one new warn-only rule** to the manifest: + +``` +warn-curl-mutating-supabase-rest + applies_to: Bash + match: command contains `curl ... -X (POST|PATCH|PUT|DELETE) ... .supabase.co/rest/v1/` + action: warn + bypass_marker: curl-supabase-rest-mutation + memory_ref: feedback_scripts_not_db.md +``` + +`GET` requests (read-only inspection) are intentionally not flagged; the rule +targets mutations only. + +## Impact + +- `hooks/rules/rules.yaml` — append the new rule + ≥5 tests +- `hooks/rules/rules.json` — regenerated artifact +- `hooks/rules/README.md` — extend the "Database / migration discipline" + family entry to mention the new rule +- 3 manifests bumped `1.7.0 → 1.8.0` (`package.json`, `.claude-plugin/plugin.json`, + `.claude-plugin/marketplace.json` top-level + nested plugin entry) diff --git a/openspec/changes/2026-05-hook-curl-mutating-supabase-rest/tasks.md b/openspec/changes/2026-05-hook-curl-mutating-supabase-rest/tasks.md new file mode 100644 index 0000000..eaad32e --- /dev/null +++ b/openspec/changes/2026-05-hook-curl-mutating-supabase-rest/tasks.md @@ -0,0 +1,11 @@ +# Tasks — `warn-curl-mutating-supabase-rest` + +- [x] Append rule to `hooks/rules/rules.yaml` with ≥5 tests +- [x] Run `npm run build-rules` to regenerate `hooks/rules/rules.json` +- [x] Run `npm run test-hooks` — all tests pass (target: 86 → 93+) +- [x] Update `hooks/rules/README.md` family entry +- [x] Bump version `1.7.0 → 1.8.0` in 3 manifests (package.json, + .claude-plugin/plugin.json, .claude-plugin/marketplace.json) +- [x] Commit + push + open PR + tag Greptile +- [x] Loop until Greptile 5/5 (substantive findings only) +- [x] `gh pr merge --squash --delete-branch` diff --git a/package.json b/package.json index b2d6c18..259d3da 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@lapc506/make-no-mistakes", - "version": "1.7.0", + "version": "1.8.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 5ff41560b3fa45e09bb48e75c079932fd046ece4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9s=20Pe=C3=B1a?= Date: Sat, 9 May 2026 23:44:45 -0600 Subject: [PATCH 2/4] fix(rules): close 3 Greptile findings on warn-curl-mutating-supabase-rest MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 1. Cover the no-space `-XPOST` shorthand: relax `[[:space:]]+` to `[[:space:]]*`. Adds `warns-curl-xpost-no-space` test. 2. Cover URL-before-flag ordering: extend the pattern to a two-clause alternation that matches whether `-X METHOD` precedes or follows the `.supabase.co/rest/v1/` segment. Adds `warns-curl-url-before-flag` test. 3. Rename the four `blocks-*` test names to `warns-*` — the rule action is `warn` (expected_exit 0), so `blocks-*` was misleading. Tests: 93 -> 95 passing (+2 coverage tests). --- hooks/rules/rules.json | 30 +++++++++++++++++++++++++----- hooks/rules/rules.yaml | 31 ++++++++++++++++++++++++------- 2 files changed, 49 insertions(+), 12 deletions(-) diff --git a/hooks/rules/rules.json b/hooks/rules/rules.json index 1669590..23dc514 100644 --- a/hooks/rules/rules.json +++ b/hooks/rules/rules.json @@ -1239,7 +1239,7 @@ "match": [ { "field": "command", - "pattern": "curl.*-X[[:space:]]+(POST|PATCH|PUT|DELETE).*\\.supabase\\.co/rest/v1/", + "pattern": "curl.*((-X[[:space:]]*(POST|PATCH|PUT|DELETE).*\\.supabase\\.co/rest/v1/)|(\\.supabase\\.co/rest/v1/.*-X[[:space:]]*(POST|PATCH|PUT|DELETE)))", "flags": "i" } ], @@ -1249,7 +1249,7 @@ "message": "WARNING: mutating curl against the Supabase PostgREST endpoint.\n\nDirect REST mutations bypass:\n - the versioned migrations workflow (drift across local DBs / envs),\n - the RLS + admin-role checks the Supabase clients enforce, and\n - the access pattern already encapsulated in src/services/api/.\n\nCorrect outlets:\n - Schema changes -> supabase/migrations/_.sql\n - Data mutations -> an Edge Function or a committed script\n - One-shot fixes -> use the API client from a versioned script\n\nUse the bypass marker (curl-supabase-rest-mutation) only for documented\nhotfixes that the team has explicitly approved.\n", "tests": [ { - "name": "blocks-curl-post-supabase-rest", + "name": "warns-curl-post-supabase-rest", "input": { "tool_input": { "command": "curl -X POST https://example.supabase.co/rest/v1/profiles -H 'apikey: x' -d '{\"id\":1}'" @@ -1259,7 +1259,7 @@ "expected_stderr_contains": "warn-curl-mutating-supabase-rest" }, { - "name": "blocks-curl-patch", + "name": "warns-curl-patch", "input": { "tool_input": { "command": "curl -X PATCH https://example.supabase.co/rest/v1/profiles?id=eq.1 -d '{\"name\":\"a\"}'" @@ -1269,7 +1269,7 @@ "expected_stderr_contains": "warn-curl-mutating-supabase-rest" }, { - "name": "blocks-curl-put", + "name": "warns-curl-put", "input": { "tool_input": { "command": "curl -X PUT https://example.supabase.co/rest/v1/profiles?id=eq.1 -d '{\"name\":\"b\"}'" @@ -1279,7 +1279,7 @@ "expected_stderr_contains": "warn-curl-mutating-supabase-rest" }, { - "name": "blocks-curl-delete", + "name": "warns-curl-delete", "input": { "tool_input": { "command": "curl -X DELETE https://example.supabase.co/rest/v1/profiles?id=eq.1" @@ -1288,6 +1288,26 @@ "expected_exit": 0, "expected_stderr_contains": "warn-curl-mutating-supabase-rest" }, + { + "name": "warns-curl-xpost-no-space", + "input": { + "tool_input": { + "command": "curl -XPOST https://example.supabase.co/rest/v1/profiles -d '{\"id\":1}'" + } + }, + "expected_exit": 0, + "expected_stderr_contains": "warn-curl-mutating-supabase-rest" + }, + { + "name": "warns-curl-url-before-flag", + "input": { + "tool_input": { + "command": "curl https://example.supabase.co/rest/v1/profiles -X POST -d '{\"id\":1}'" + } + }, + "expected_exit": 0, + "expected_stderr_contains": "warn-curl-mutating-supabase-rest" + }, { "name": "allows-curl-get-supabase-rest", "input": { diff --git a/hooks/rules/rules.yaml b/hooks/rules/rules.yaml index b042339..ba4ccdc 100644 --- a/hooks/rules/rules.yaml +++ b/hooks/rules/rules.yaml @@ -986,17 +986,22 @@ command: 'supabase db push --linked # hook-bypass: prod-db-push-approved' expected_exit: 0 + - id: warn-curl-mutating-supabase-rest description: Warn when curl issues a mutating HTTP method (POST/PATCH/PUT/DELETE) against the Supabase PostgREST endpoint (drift risk vs versioned migrations + RLS bypass) applies_to: [Bash] match: # Anchor on the explicit `-X ` form. Curl defaults to GET, so any # author issuing a mutation invariably types `-X POST/PATCH/PUT/DELETE`. - # Match `.supabase.co/rest/v1/` (PostgREST) specifically, so this rule - # does NOT fire for Supabase Auth / Storage / Edge Function endpoints — + # `-X[[:space:]]*` covers both `-X POST` and the no-space `-XPOST` + # shorthand. The alternation handles both common orderings: the URL + # appearing AFTER the -X flag and the URL appearing BEFORE it (very + # common in shell scripts that build the URL into a variable). Match + # `.supabase.co/rest/v1/` (PostgREST) specifically, so this rule does + # NOT fire for Supabase Auth / Storage / Edge Function endpoints — # those have separate guidance and Edge Functions are the intended outlet. - field: command - pattern: 'curl.*-X[[:space:]]+(POST|PATCH|PUT|DELETE).*\.supabase\.co/rest/v1/' + pattern: 'curl.*((-X[[:space:]]*(POST|PATCH|PUT|DELETE).*\.supabase\.co/rest/v1/)|(\.supabase\.co/rest/v1/.*-X[[:space:]]*(POST|PATCH|PUT|DELETE)))' flags: i action: warn bypass_marker: curl-supabase-rest-mutation @@ -1017,30 +1022,42 @@ Use the bypass marker (curl-supabase-rest-mutation) only for documented hotfixes that the team has explicitly approved. tests: - - name: blocks-curl-post-supabase-rest + - name: warns-curl-post-supabase-rest input: tool_input: command: "curl -X POST https://example.supabase.co/rest/v1/profiles -H 'apikey: x' -d '{\"id\":1}'" expected_exit: 0 expected_stderr_contains: 'warn-curl-mutating-supabase-rest' - - name: blocks-curl-patch + - name: warns-curl-patch input: tool_input: command: "curl -X PATCH https://example.supabase.co/rest/v1/profiles?id=eq.1 -d '{\"name\":\"a\"}'" expected_exit: 0 expected_stderr_contains: 'warn-curl-mutating-supabase-rest' - - name: blocks-curl-put + - name: warns-curl-put input: tool_input: command: "curl -X PUT https://example.supabase.co/rest/v1/profiles?id=eq.1 -d '{\"name\":\"b\"}'" expected_exit: 0 expected_stderr_contains: 'warn-curl-mutating-supabase-rest' - - name: blocks-curl-delete + - name: warns-curl-delete input: tool_input: command: 'curl -X DELETE https://example.supabase.co/rest/v1/profiles?id=eq.1' expected_exit: 0 expected_stderr_contains: 'warn-curl-mutating-supabase-rest' + - name: warns-curl-xpost-no-space + input: + tool_input: + command: "curl -XPOST https://example.supabase.co/rest/v1/profiles -d '{\"id\":1}'" + expected_exit: 0 + expected_stderr_contains: 'warn-curl-mutating-supabase-rest' + - name: warns-curl-url-before-flag + input: + tool_input: + command: "curl https://example.supabase.co/rest/v1/profiles -X POST -d '{\"id\":1}'" + expected_exit: 0 + expected_stderr_contains: 'warn-curl-mutating-supabase-rest' - name: allows-curl-get-supabase-rest input: tool_input: From ac5b9312a646837adbe1c722c776113112719bd7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9s=20Pe=C3=B1a?= Date: Sat, 9 May 2026 23:52:31 -0600 Subject: [PATCH 3/4] fix(rules): close round-2 Greptile findings on warn-curl-mutating-supabase-rest MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 1. **Implicit POST via -d/--data is now caught** — extend the pattern alternation to fire when `-d`, `--data`, `--data-raw`, or `--data-binary` are present without `-X`. Curl auto-promotes to POST in that case, so a one-liner like curl -d '{"id":1}' https://.supabase.co/rest/v1/profiles would otherwise pass silently. Three new tests cover -d before URL, --data-raw before URL, and URL before -d. 2. **design.md test list refreshed** — replace the stale `blocks-*` names with the actual `warns-*` names and list all 12 tests after rounds 1-2. Drop the old "blocks- naming for symmetry" rationale that was made obsolete by round 1. 3. **Pattern comment refactored** — document the three vectors the alternation covers (explicit -X, URL ordering, implicit POST via data flags). Tests: 95 -> 98 passing. --- hooks/rules/rules.json | 32 +++++++++++++- hooks/rules/rules.yaml | 42 ++++++++++++++----- .../design.md | 36 ++++++++++------ 3 files changed, 86 insertions(+), 24 deletions(-) diff --git a/hooks/rules/rules.json b/hooks/rules/rules.json index 23dc514..52783a0 100644 --- a/hooks/rules/rules.json +++ b/hooks/rules/rules.json @@ -1239,7 +1239,7 @@ "match": [ { "field": "command", - "pattern": "curl.*((-X[[:space:]]*(POST|PATCH|PUT|DELETE).*\\.supabase\\.co/rest/v1/)|(\\.supabase\\.co/rest/v1/.*-X[[:space:]]*(POST|PATCH|PUT|DELETE)))", + "pattern": "curl.*((-X[[:space:]]*(POST|PATCH|PUT|DELETE).*\\.supabase\\.co/rest/v1/)|(\\.supabase\\.co/rest/v1/.*-X[[:space:]]*(POST|PATCH|PUT|DELETE))|((-d[[:space:]]|--data([[:space:]]|=|-raw[[:space:]]|-raw=|-binary[[:space:]]|-binary=)).*\\.supabase\\.co/rest/v1/)|(\\.supabase\\.co/rest/v1/.*(-d[[:space:]]|--data([[:space:]]|=|-raw[[:space:]]|-raw=|-binary[[:space:]]|-binary=))))", "flags": "i" } ], @@ -1326,6 +1326,36 @@ }, "expected_exit": 0 }, + { + "name": "warns-curl-implicit-post-via-d", + "input": { + "tool_input": { + "command": "curl -d '{\"id\":1}' https://example.supabase.co/rest/v1/profiles" + } + }, + "expected_exit": 0, + "expected_stderr_contains": "warn-curl-mutating-supabase-rest" + }, + { + "name": "warns-curl-implicit-post-via-data-raw", + "input": { + "tool_input": { + "command": "curl --data-raw '{\"id\":1}' https://example.supabase.co/rest/v1/profiles" + } + }, + "expected_exit": 0, + "expected_stderr_contains": "warn-curl-mutating-supabase-rest" + }, + { + "name": "warns-curl-supabase-then-d", + "input": { + "tool_input": { + "command": "curl https://example.supabase.co/rest/v1/profiles -d '{\"id\":1}'" + } + }, + "expected_exit": 0, + "expected_stderr_contains": "warn-curl-mutating-supabase-rest" + }, { "name": "allows-bypass-marker", "input": { diff --git a/hooks/rules/rules.yaml b/hooks/rules/rules.yaml index ba4ccdc..6f9bede 100644 --- a/hooks/rules/rules.yaml +++ b/hooks/rules/rules.yaml @@ -991,17 +991,21 @@ description: Warn when curl issues a mutating HTTP method (POST/PATCH/PUT/DELETE) against the Supabase PostgREST endpoint (drift risk vs versioned migrations + RLS bypass) applies_to: [Bash] match: - # Anchor on the explicit `-X ` form. Curl defaults to GET, so any - # author issuing a mutation invariably types `-X POST/PATCH/PUT/DELETE`. - # `-X[[:space:]]*` covers both `-X POST` and the no-space `-XPOST` - # shorthand. The alternation handles both common orderings: the URL - # appearing AFTER the -X flag and the URL appearing BEFORE it (very - # common in shell scripts that build the URL into a variable). Match - # `.supabase.co/rest/v1/` (PostgREST) specifically, so this rule does - # NOT fire for Supabase Auth / Storage / Edge Function endpoints — - # those have separate guidance and Edge Functions are the intended outlet. + # The pattern catches three mutation vectors against Supabase PostgREST: + # 1. Explicit `-X METHOD` (with or without space; `-X POST` and the + # no-space `-XPOST` shorthand both fire). + # 2. URL-before-flag ordering (`curl -X POST ...`), which is + # common in shell scripts that build the URL into a variable. + # 3. Implicit POST via `-d` / `--data` / `--data-raw` / `--data-binary`. + # Curl auto-promotes to POST when these flags are present even + # without `-X`, so a one-liner like `curl -d '...' ` is a + # live mutation that would otherwise pass silently. + # The match is narrowed to `.supabase.co/rest/v1/` (PostgREST) so this + # rule does NOT fire for Supabase Auth / Storage / Edge Function + # endpoints — those have separate guidance and Edge Functions are the + # intended outlet. - field: command - pattern: 'curl.*((-X[[:space:]]*(POST|PATCH|PUT|DELETE).*\.supabase\.co/rest/v1/)|(\.supabase\.co/rest/v1/.*-X[[:space:]]*(POST|PATCH|PUT|DELETE)))' + pattern: 'curl.*((-X[[:space:]]*(POST|PATCH|PUT|DELETE).*\.supabase\.co/rest/v1/)|(\.supabase\.co/rest/v1/.*-X[[:space:]]*(POST|PATCH|PUT|DELETE))|((-d[[:space:]]|--data([[:space:]]|=|-raw[[:space:]]|-raw=|-binary[[:space:]]|-binary=)).*\.supabase\.co/rest/v1/)|(\.supabase\.co/rest/v1/.*(-d[[:space:]]|--data([[:space:]]|=|-raw[[:space:]]|-raw=|-binary[[:space:]]|-binary=))))' flags: i action: warn bypass_marker: curl-supabase-rest-mutation @@ -1068,6 +1072,24 @@ tool_input: command: "curl -X POST https://example.com/api/v1/users -d '{\"id\":1}'" expected_exit: 0 + - name: warns-curl-implicit-post-via-d + input: + tool_input: + command: "curl -d '{\"id\":1}' https://example.supabase.co/rest/v1/profiles" + expected_exit: 0 + expected_stderr_contains: 'warn-curl-mutating-supabase-rest' + - name: warns-curl-implicit-post-via-data-raw + input: + tool_input: + command: "curl --data-raw '{\"id\":1}' https://example.supabase.co/rest/v1/profiles" + expected_exit: 0 + expected_stderr_contains: 'warn-curl-mutating-supabase-rest' + - name: warns-curl-supabase-then-d + input: + tool_input: + command: "curl https://example.supabase.co/rest/v1/profiles -d '{\"id\":1}'" + expected_exit: 0 + expected_stderr_contains: 'warn-curl-mutating-supabase-rest' - name: allows-bypass-marker input: tool_input: diff --git a/openspec/changes/2026-05-hook-curl-mutating-supabase-rest/design.md b/openspec/changes/2026-05-hook-curl-mutating-supabase-rest/design.md index 1a659a8..f190199 100644 --- a/openspec/changes/2026-05-hook-curl-mutating-supabase-rest/design.md +++ b/openspec/changes/2026-05-hook-curl-mutating-supabase-rest/design.md @@ -37,19 +37,29 @@ are the same class of nudge. Use a **sanitized** Supabase host (`example.supabase.co`) — same convention as the migration-discipline rules added in PR #15 round-2. Real project refs would either leak in the public toolkit or trip the IP-leak guard. -Tests required (≥5): - -1. `blocks-curl-post-supabase-rest` — POST mutation, expects warn. -2. `blocks-curl-patch` — PATCH mutation, expects warn. -3. `blocks-curl-put` — PUT mutation, expects warn. -4. `blocks-curl-delete` — DELETE mutation, expects warn. -5. `allows-curl-get-supabase-rest` — GET (read), expects allow. -6. `allows-curl-non-supabase` — POST against `example.com`, expects allow. -7. `allows-bypass-marker` — POST + bypass comment, expects allow. - -(Test names use the `blocks-*` prefix purely for readability symmetry with -neighboring rules. Action is `warn`, so `expected_exit` is `0` everywhere -and we use `expected_stderr_contains` to assert the warning fired.) + +Final tests after Greptile rounds 1+2 (12 cases): + +1. `warns-curl-post-supabase-rest` — POST mutation, expects warn. +2. `warns-curl-patch` — PATCH mutation, expects warn. +3. `warns-curl-put` — PUT mutation, expects warn. +4. `warns-curl-delete` — DELETE mutation, expects warn. +5. `warns-curl-xpost-no-space` — no-space `-XPOST` shorthand, expects warn. +6. `warns-curl-url-before-flag` — URL precedes `-X POST`, expects warn. +7. `warns-curl-implicit-post-via-d` — `-d` body without `-X`, expects warn. +8. `warns-curl-implicit-post-via-data-raw` — `--data-raw` body without `-X`, + expects warn. +9. `warns-curl-supabase-then-d` — URL first then `-d`, expects warn. +10. `allows-curl-get-supabase-rest` — GET (read), expects allow. +11. `allows-curl-non-supabase` — POST against `example.com`, expects allow. +12. `allows-bypass-marker` — POST + bypass comment, expects allow. + +Action is `warn`, so `expected_exit` is `0` everywhere and we use +`expected_stderr_contains` to assert the warning fired. + +Round-1 Greptile findings closed: `-XPOST` no-space + URL-before-flag +ordering. Round-2 finding closed: implicit POST via `-d` / `--data*` is +also caught. ## Why warn, not block From 83bff91d24ab9cecde14925285a95357e294d6bc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9s=20Pe=C3=B1a?= Date: Sat, 9 May 2026 23:58:41 -0600 Subject: [PATCH 4/4] fix(rules): close round-3 Greptile findings on warn-curl-mutating-supabase-rest MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 1. **--data-urlencode now caught** — extend the `--data(...)` alternation to include `-urlencode[[:space:]]` and `-urlencode=`. Curl auto-promotes to POST for `--data-urlencode` the same way it does for `-d`/`--data`/`--data-raw`/`--data-binary`, so a one-liner like curl --data-urlencode 'name=Alice' https://.supabase.co/rest/v1/profiles would otherwise pass silently. Adds `warns-curl-implicit-post-via-data-urlencode` test. 2. **design.md Pattern section refreshed** — replace the original single-clause regex with the actual 4-clause alternation, documenting each vector (explicit -X, URL ordering, implicit POST via data flags, implicit POST via --data-urlencode). The accepted false-negatives subsection now also mentions `-G` (which overrides auto-promotion). Tests: 98 -> 99 passing. Greptile already at 5/5 on round 3 commit ac5b931 — these are belt-and-suspenders fixes for the two nits Greptile flagged in its 5/5 review. --- hooks/rules/rules.json | 12 ++++- hooks/rules/rules.yaml | 8 +++- .../design.md | 44 +++++++++++++------ 3 files changed, 48 insertions(+), 16 deletions(-) diff --git a/hooks/rules/rules.json b/hooks/rules/rules.json index 52783a0..221762e 100644 --- a/hooks/rules/rules.json +++ b/hooks/rules/rules.json @@ -1239,7 +1239,7 @@ "match": [ { "field": "command", - "pattern": "curl.*((-X[[:space:]]*(POST|PATCH|PUT|DELETE).*\\.supabase\\.co/rest/v1/)|(\\.supabase\\.co/rest/v1/.*-X[[:space:]]*(POST|PATCH|PUT|DELETE))|((-d[[:space:]]|--data([[:space:]]|=|-raw[[:space:]]|-raw=|-binary[[:space:]]|-binary=)).*\\.supabase\\.co/rest/v1/)|(\\.supabase\\.co/rest/v1/.*(-d[[:space:]]|--data([[:space:]]|=|-raw[[:space:]]|-raw=|-binary[[:space:]]|-binary=))))", + "pattern": "curl.*((-X[[:space:]]*(POST|PATCH|PUT|DELETE).*\\.supabase\\.co/rest/v1/)|(\\.supabase\\.co/rest/v1/.*-X[[:space:]]*(POST|PATCH|PUT|DELETE))|((-d[[:space:]]|--data([[:space:]]|=|-raw[[:space:]]|-raw=|-binary[[:space:]]|-binary=|-urlencode[[:space:]]|-urlencode=)).*\\.supabase\\.co/rest/v1/)|(\\.supabase\\.co/rest/v1/.*(-d[[:space:]]|--data([[:space:]]|=|-raw[[:space:]]|-raw=|-binary[[:space:]]|-binary=|-urlencode[[:space:]]|-urlencode=))))", "flags": "i" } ], @@ -1356,6 +1356,16 @@ "expected_exit": 0, "expected_stderr_contains": "warn-curl-mutating-supabase-rest" }, + { + "name": "warns-curl-implicit-post-via-data-urlencode", + "input": { + "tool_input": { + "command": "curl --data-urlencode 'name=Alice' https://example.supabase.co/rest/v1/profiles" + } + }, + "expected_exit": 0, + "expected_stderr_contains": "warn-curl-mutating-supabase-rest" + }, { "name": "allows-bypass-marker", "input": { diff --git a/hooks/rules/rules.yaml b/hooks/rules/rules.yaml index 6f9bede..21127ae 100644 --- a/hooks/rules/rules.yaml +++ b/hooks/rules/rules.yaml @@ -1005,7 +1005,7 @@ # endpoints — those have separate guidance and Edge Functions are the # intended outlet. - field: command - pattern: 'curl.*((-X[[:space:]]*(POST|PATCH|PUT|DELETE).*\.supabase\.co/rest/v1/)|(\.supabase\.co/rest/v1/.*-X[[:space:]]*(POST|PATCH|PUT|DELETE))|((-d[[:space:]]|--data([[:space:]]|=|-raw[[:space:]]|-raw=|-binary[[:space:]]|-binary=)).*\.supabase\.co/rest/v1/)|(\.supabase\.co/rest/v1/.*(-d[[:space:]]|--data([[:space:]]|=|-raw[[:space:]]|-raw=|-binary[[:space:]]|-binary=))))' + pattern: 'curl.*((-X[[:space:]]*(POST|PATCH|PUT|DELETE).*\.supabase\.co/rest/v1/)|(\.supabase\.co/rest/v1/.*-X[[:space:]]*(POST|PATCH|PUT|DELETE))|((-d[[:space:]]|--data([[:space:]]|=|-raw[[:space:]]|-raw=|-binary[[:space:]]|-binary=|-urlencode[[:space:]]|-urlencode=)).*\.supabase\.co/rest/v1/)|(\.supabase\.co/rest/v1/.*(-d[[:space:]]|--data([[:space:]]|=|-raw[[:space:]]|-raw=|-binary[[:space:]]|-binary=|-urlencode[[:space:]]|-urlencode=))))' flags: i action: warn bypass_marker: curl-supabase-rest-mutation @@ -1090,6 +1090,12 @@ command: "curl https://example.supabase.co/rest/v1/profiles -d '{\"id\":1}'" expected_exit: 0 expected_stderr_contains: 'warn-curl-mutating-supabase-rest' + - name: warns-curl-implicit-post-via-data-urlencode + input: + tool_input: + command: "curl --data-urlencode 'name=Alice' https://example.supabase.co/rest/v1/profiles" + expected_exit: 0 + expected_stderr_contains: 'warn-curl-mutating-supabase-rest' - name: allows-bypass-marker input: tool_input: diff --git a/openspec/changes/2026-05-hook-curl-mutating-supabase-rest/design.md b/openspec/changes/2026-05-hook-curl-mutating-supabase-rest/design.md index f190199..88a18bf 100644 --- a/openspec/changes/2026-05-hook-curl-mutating-supabase-rest/design.md +++ b/openspec/changes/2026-05-hook-curl-mutating-supabase-rest/design.md @@ -1,23 +1,39 @@ # Design — `warn-curl-mutating-supabase-rest` -## Pattern +## Pattern (final, after Greptile rounds 1-3) ``` -'curl.*-X[[:space:]]+(POST|PATCH|PUT|DELETE).*\.supabase\.co/rest/v1/' +'curl.*((-X[[:space:]]*(POST|PATCH|PUT|DELETE).*\.supabase\.co/rest/v1/)|(\.supabase\.co/rest/v1/.*-X[[:space:]]*(POST|PATCH|PUT|DELETE))|((-d[[:space:]]|--data([[:space:]]|=|-raw[[:space:]]|-raw=|-binary[[:space:]]|-binary=|-urlencode[[:space:]]|-urlencode=)).*\.supabase\.co/rest/v1/)|(\.supabase\.co/rest/v1/.*(-d[[:space:]]|--data([[:space:]]|=|-raw[[:space:]]|-raw=|-binary[[:space:]]|-binary=|-urlencode[[:space:]]|-urlencode=))))' ``` -Rationale: - -- Anchor on the explicit `-X ` form. `curl` defaults to `GET`, and - any author using a mutating method invariably types it as `-X POST` / - `-X PATCH` / `-X PUT` / `-X DELETE`. (Curl also accepts `--request`, but - the long form is uncommon enough that we accept the false negative — - authors using `--request` can be expected to know the rule and self-flag.) -- Match `.supabase.co/rest/v1/` rather than just `.supabase.co` so we don't - flag mutations against Supabase Auth / Storage endpoints (those have - separate guidance) or Edge Functions (which are exempt — they're the - intended outlet). -- Use `i` flag for case-insensitivity on the method names. +The pattern is a 4-clause alternation that catches every realistic +mutation vector against `.supabase.co/rest/v1/`: + +1. **Explicit `-X METHOD`** — `-X[[:space:]]*` covers both `-X POST` and + the no-space `-XPOST` shorthand. Methods covered: `POST`, `PATCH`, + `PUT`, `DELETE`. (Round 1 fix.) +2. **URL-before-flag ordering** — `curl -X POST ...` is common in + shell scripts that build the URL into a variable. The second + alternation clause catches this. (Round 1 fix.) +3. **Implicit POST via `-d` / `--data` / `--data-raw` / `--data-binary`** + — curl auto-promotes to POST when these flags are present even + without `-X`. (Round 2 fix.) +4. **Implicit POST via `--data-urlencode`** — same auto-promotion as + `-d`, common for form-style payloads. (Round 3 fix.) + +Match is narrowed to `.supabase.co/rest/v1/` (PostgREST) so this rule +does NOT fire for Supabase Auth / Storage / Edge Function endpoints — +those have separate guidance and Edge Functions are the intended outlet. + +The `i` flag is used for case-insensitivity on the method names. + +### Accepted false negatives + +- `curl --request POST` — uncommon long form; authors using it are + expected to know the rule and self-flag. +- `curl -G ... -d ...` — `-G` overrides curl's auto-promotion and forces + GET; `-d` becomes a query-string. Treating this as a mutation would be + incorrect. ## Action