From 40e6aa05cfc28c57682e5d849e972a67aef5f210 Mon Sep 17 00:00:00 2001 From: Thomas Luizon Rodrigues Gregorio Date: Fri, 24 Jul 2026 14:19:37 -0300 Subject: [PATCH 1/5] chore(ci): gate EF migration index idempotency + worker guardrail docs The EF raw-index idempotency rule (raw CREATE INDEX needs IF NOT EXISTS, raw DROP INDEX needs IF EXISTS) ran only as an orbit-ui-mobile Claude PostToolUse hook, so a codex worker or a hook-bypassing commit could merge a non-idempotent migration; the only catch was a failed Render deploy (Postgres 42P07). - Add .github/scripts/check_migration_idempotency.py, a faithful port of the checkEfMigrationRawIndex pure function (balanced-paren Sql() bodies, split on ;, flags raw CREATE/DROP INDEX lacking IF [NOT] EXISTS; the fluent CreateIndex/DropIndex API is not flagged). Verified fixture-for-fixture against the JS rule: identical verdicts. - Wire it into the Guard Migrations job over changed Migrations/*.cs files. - AGENTS.md: add the worker guardrail rules (no push to main, no --no-verify, no --no-gpg-sign, no worktree remove --force, EF index idempotency) and state they are enforced by CI/branch-protection/lefthook, not the Claude hooks. - lefthook.yml: add an engine-agnostic pre-push guard blocking push to main/master (backstop that fires under any tool, unlike the session hooks). Paired with orbit-ui-mobile chore/worker-guardrails-agents-md. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../scripts/check_migration_idempotency.py | 74 +++++++++++++++++++ .github/workflows/test.yml | 12 +++ AGENTS.md | 19 +++++ lefthook.yml | 19 +++++ 4 files changed, 124 insertions(+) create mode 100644 .github/scripts/check_migration_idempotency.py diff --git a/.github/scripts/check_migration_idempotency.py b/.github/scripts/check_migration_idempotency.py new file mode 100644 index 00000000..a801f610 --- /dev/null +++ b/.github/scripts/check_migration_idempotency.py @@ -0,0 +1,74 @@ +#!/usr/bin/env python3 +"""Fail CI on non-idempotent raw index SQL in EF migrations. + +EF applies migrations at startup on Render; a raw CREATE INDEX for an index that +already exists throws Postgres 42P07 and fails the deploy. Every raw CREATE INDEX +inside migrationBuilder.Sql(...) must carry IF NOT EXISTS, and every raw DROP +INDEX must carry IF EXISTS. The fluent migrationBuilder.CreateIndex(...) / +DropIndex(...) API is already idempotent-safe and is not flagged (its method +names carry no whitespace, so they never match the raw-SQL patterns). + +Mirrors checkEfMigrationRawIndex in the orbit-ui-mobile consumer repo +(.claude/hooks/_lib/rules-source.mjs): only the balanced-paren body of each +migrationBuilder.Sql(...) call is scanned, split into ;-separated statements so +one statement's IF [NOT] EXISTS clause cannot mask a sibling that lacks its own. + + check_migration_idempotency.py [ ...] +""" +import re +import sys + +SQL_CALL = re.compile(r"migrationBuilder\.Sql\s*\(") +CREATE_INDEX = re.compile(r"\bCREATE\s+(?:UNIQUE\s+)?INDEX\b", re.IGNORECASE) +IF_NOT_EXISTS = re.compile(r"\bIF\s+NOT\s+EXISTS\b", re.IGNORECASE) +DROP_INDEX = re.compile(r"\bDROP\s+INDEX\b", re.IGNORECASE) +IF_EXISTS = re.compile(r"\bIF\s+EXISTS\b", re.IGNORECASE) + + +def find_violations(path: str, contents: str) -> list[str]: + """Return one message per non-idempotent raw index statement in the file.""" + findings: list[str] = [] + for call in SQL_CALL.finditer(contents): + index = call.end() + depth = 1 + start = index + while index < len(contents) and depth > 0: + char = contents[index] + if char == "(": + depth += 1 + elif char == ")": + depth -= 1 + index += 1 + sql = contents[start : index - 1] + line_number = contents.count("\n", 0, call.start()) + 1 + for statement in sql.split(";"): + if CREATE_INDEX.search(statement) and not IF_NOT_EXISTS.search(statement): + findings.append(f"{path}:{line_number} raw CREATE INDEX without IF NOT EXISTS") + if DROP_INDEX.search(statement) and not IF_EXISTS.search(statement): + findings.append(f"{path}:{line_number} raw DROP INDEX without IF EXISTS") + return findings + + +def main() -> int: + violations: list[str] = [] + for path in sys.argv[1:]: + try: + with open(path, encoding="utf-8") as handle: + contents = handle.read() + except (OSError, UnicodeDecodeError) as error: + print(f"Skipping unreadable file {path}: {error}", file=sys.stderr) + continue + violations.extend(find_violations(path, contents)) + + if violations: + print("::error::Non-idempotent raw index SQL in EF migration(s). Use CREATE INDEX IF NOT EXISTS / DROP INDEX IF EXISTS so a startup re-apply cannot throw Postgres 42P07 and fail the Render deploy.") + for violation in violations: + print(violation) + return 1 + + print("OK: no non-idempotent raw index SQL in the checked migrations.") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index db68aa8d..e890e0b7 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -113,6 +113,18 @@ jobs: fi echo "OK: no existing migration was edited (new migrations are allowed)." + - name: Fail on non-idempotent raw index SQL in new migrations + run: | + git fetch origin "${{ github.base_ref }}" + changed=$(git diff --name-only --diff-filter=d "origin/${{ github.base_ref }}...HEAD" -- ':(glob)**/Migrations/*.cs') + if [ -z "$changed" ]; then + echo "OK: no migration files changed." + exit 0 + fi + echo "Checking migrations for idempotent raw index SQL:" + echo "$changed" + python3 .github/scripts/check_migration_idempotency.py $changed + convention-guard: name: Guard Conventions runs-on: ubuntu-latest diff --git a/AGENTS.md b/AGENTS.md index f462986c..65bbb2b3 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -21,6 +21,25 @@ CLAUDE.md; this file holds the worker contract and the review rules and DEFERS t - DTO/contract changes are append-only and deploy-API-first; the TypeScript consumer (orbit-ui-mobile) updates in lockstep via its own ticket that this one blocks. +### Guardrails you must not trip + +These hold for EVERY worker and every engine. They are enforced by CI, GitHub branch +protection, and the lefthook pre-commit/pre-push hooks, NOT by the Claude Code session +hooks (those do not run under `codex exec` or a raw shell). This list is the readable +copy; the gates are the enforcement. + +- Never push or force-push to `main`/`master`. Branch to `feature/`|`fix/`|`chore/`, + open a PR, squash-merge only. Never reuse a squash-merged branch. +- Never bypass the git hooks: no `--no-verify` (or its `-n` commit alias), no + `--no-gpg-sign` and no `commit.gpgsign=false`. Fix what a hook flags, then commit. +- Never `git worktree remove --force`: on Windows it follows a junction and deletes the + link target. Remove the junctions first, then remove the worktree without `--force`. +- EF migrations must be idempotent: raw `CREATE INDEX` inside `migrationBuilder.Sql(...)` + needs `IF NOT EXISTS`, and raw `DROP INDEX` needs `IF EXISTS` (EF re-applies at startup + on Render; a duplicate raw `CREATE INDEX` throws Postgres 42P07 and fails the deploy). + The fluent `migrationBuilder.CreateIndex(...)`/`DropIndex(...)` API is already safe. + The Guard Migrations CI job enforces this over changed `Migrations/*.cs` files. + ## Code Review Rules Only what no gate can check; mechanical findings belong to CI. Flag P0/P1 only. diff --git a/lefthook.yml b/lefthook.yml index f0c90b97..ba8da510 100644 --- a/lefthook.yml +++ b/lefthook.yml @@ -7,3 +7,22 @@ pre-commit: stage_fixed: true dashes: run: node tools/check-dashes.mjs --files {staged_files} +pre-push: + commands: + protect-main: + use_stdin: true + run: | + blocked=0 + while read -r _local_ref _local_sha remote_ref _remote_sha; do + case "$remote_ref" in + refs/heads/main|refs/heads/master) blocked=1 ;; + esac + done + branch=$(git rev-parse --abbrev-ref HEAD 2>/dev/null || echo "") + case "$branch" in + main|master) blocked=1 ;; + esac + if [ "$blocked" -eq 1 ]; then + echo "BLOCKED: direct push to main/master is forbidden (branch protection, squash-merge only). Open a PR." + exit 1 + fi From becd4e94fbde8dd4bc45fc5f1937246741ed1797 Mon Sep 17 00:00:00 2001 From: Thomas Luizon Rodrigues Gregorio Date: Fri, 24 Jul 2026 14:39:59 -0300 Subject: [PATCH 2/5] fix(ci): address review on the EF idempotency gate Review feedback on PR #433 (SonarCloud "C Security Rating on New Code" gate + a Medium correctness note): - test.yml: stop word-splitting the changed-file list. The migration paths now flow null-delimited (git diff --name-only -z) into xargs -0, so no unquoted shell expansion or glob reaches python3 argv. Clears the SonarCloud new-code security finding while keeping identical behavior. - lefthook.yml: drop the local-branch-name check from the pre-push guard. The stdin remote_ref loop already blocks any push whose destination is main/master (including a bare push while on main, since git passes refs/heads/main as the remote ref). The extra HEAD-name check over-fired on legitimate non-protected pushes made while checked out on main (a tag push, a backup-ref push) with a misleading "push to main" message. Co-Authored-By: Claude Opus 4.8 (1M context) --- .github/workflows/test.yml | 12 ++++-------- lefthook.yml | 6 +----- 2 files changed, 5 insertions(+), 13 deletions(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index e890e0b7..65ae9f61 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -116,14 +116,10 @@ jobs: - name: Fail on non-idempotent raw index SQL in new migrations run: | git fetch origin "${{ github.base_ref }}" - changed=$(git diff --name-only --diff-filter=d "origin/${{ github.base_ref }}...HEAD" -- ':(glob)**/Migrations/*.cs') - if [ -z "$changed" ]; then - echo "OK: no migration files changed." - exit 0 - fi - echo "Checking migrations for idempotent raw index SQL:" - echo "$changed" - python3 .github/scripts/check_migration_idempotency.py $changed + echo "Changed migration files:" + git diff --name-only --diff-filter=d "origin/${{ github.base_ref }}...HEAD" -- ':(glob)**/Migrations/*.cs' + git diff --name-only -z --diff-filter=d "origin/${{ github.base_ref }}...HEAD" -- ':(glob)**/Migrations/*.cs' \ + | xargs -0 --no-run-if-empty python3 .github/scripts/check_migration_idempotency.py convention-guard: name: Guard Conventions diff --git a/lefthook.yml b/lefthook.yml index ba8da510..8fbc38ff 100644 --- a/lefthook.yml +++ b/lefthook.yml @@ -18,11 +18,7 @@ pre-push: refs/heads/main|refs/heads/master) blocked=1 ;; esac done - branch=$(git rev-parse --abbrev-ref HEAD 2>/dev/null || echo "") - case "$branch" in - main|master) blocked=1 ;; - esac if [ "$blocked" -eq 1 ]; then - echo "BLOCKED: direct push to main/master is forbidden (branch protection, squash-merge only). Open a PR." + echo "BLOCKED: pushing to main/master is forbidden (branch protection, squash-merge only). Open a PR." exit 1 fi From e3c8845b109974fdeb52ed185b067c0504c46a4a Mon Sep 17 00:00:00 2001 From: Thomas Luizon Rodrigues Gregorio Date: Fri, 24 Jul 2026 14:40:50 -0300 Subject: [PATCH 3/5] fix(ci): pass github.base_ref via env, not run-script interpolation SonarCloud flags `${{ github.base_ref }}` expanded directly inside a run: block as a code-injection hotspot on new code (the C Security Rating that failed the gate). Bind it once to an env var and reference "$BASE_REF" in the script, the canonical remediation. No behavior change. Co-Authored-By: Claude Opus 4.8 (1M context) --- .github/workflows/test.yml | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 65ae9f61..a0f44e1e 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -114,11 +114,13 @@ jobs: echo "OK: no existing migration was edited (new migrations are allowed)." - name: Fail on non-idempotent raw index SQL in new migrations + env: + BASE_REF: ${{ github.base_ref }} run: | - git fetch origin "${{ github.base_ref }}" + git fetch origin "$BASE_REF" echo "Changed migration files:" - git diff --name-only --diff-filter=d "origin/${{ github.base_ref }}...HEAD" -- ':(glob)**/Migrations/*.cs' - git diff --name-only -z --diff-filter=d "origin/${{ github.base_ref }}...HEAD" -- ':(glob)**/Migrations/*.cs' \ + git diff --name-only --diff-filter=d "origin/$BASE_REF...HEAD" -- ':(glob)**/Migrations/*.cs' + git diff --name-only -z --diff-filter=d "origin/$BASE_REF...HEAD" -- ':(glob)**/Migrations/*.cs' \ | xargs -0 --no-run-if-empty python3 .github/scripts/check_migration_idempotency.py convention-guard: From 2d83bc5e60fb2bb744c6fa9b900cebcdbba15108 Mon Sep 17 00:00:00 2001 From: Thomas Luizon Rodrigues Gregorio Date: Fri, 24 Jul 2026 14:43:17 -0300 Subject: [PATCH 4/5] fix(ci): confine migration paths before reading (SonarCloud S8707) The real SonarCloud "C Security Rating on New Code" finding was pythonsecurity:S8707 at check_migration_idempotency.py: the script opened a CLI-argument path without validating it stays inside the repo, so a crafted argument could read a file outside the tree (path traversal). Add the same `_confine` guard the sibling check_coverage.py already uses: resolve each path with os.path.realpath and confirm it is within the working directory before opening it; a path that escapes is reported and skipped. Verified: relative migration paths pass, a `../` traversal path is refused. Co-Authored-By: Claude Opus 4.8 (1M context) --- ...check_migration_idempotency.cpython-313.pyc | Bin 0 -> 5734 bytes .github/scripts/check_migration_idempotency.py | 17 ++++++++++++++++- 2 files changed, 16 insertions(+), 1 deletion(-) create mode 100644 .github/scripts/__pycache__/check_migration_idempotency.cpython-313.pyc diff --git a/.github/scripts/__pycache__/check_migration_idempotency.cpython-313.pyc b/.github/scripts/__pycache__/check_migration_idempotency.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..96140643703393114181e1e8d3eaaf5cf97f4863 GIT binary patch literal 5734 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 [ ...] """ +import os import re import sys @@ -49,11 +50,25 @@ def find_violations(path: str, contents: str) -> list[str]: return findings +def _confine(candidate: str, base: str) -> str: + """Resolve candidate and confirm it stays within base, rejecting path traversal.""" + resolved = os.path.realpath(candidate) + if resolved != base and not resolved.startswith(base + os.sep): + raise ValueError(f"Refusing path outside {base}: {candidate}") + return resolved + + def main() -> int: + base = os.path.realpath(os.getcwd()) violations: list[str] = [] for path in sys.argv[1:]: try: - with open(path, encoding="utf-8") as handle: + safe_path = _confine(path, base) + except ValueError as error: + print(error, file=sys.stderr) + continue + try: + with open(safe_path, encoding="utf-8") as handle: contents = handle.read() except (OSError, UnicodeDecodeError) as error: print(f"Skipping unreadable file {path}: {error}", file=sys.stderr) From 7a80583db19960d8022df15bb8213c2084d0df28 Mon Sep 17 00:00:00 2001 From: Thomas Luizon Rodrigues Gregorio Date: Fri, 24 Jul 2026 14:43:28 -0300 Subject: [PATCH 5/5] chore(ci): drop accidentally committed __pycache__ artifact Co-Authored-By: Claude Opus 4.8 (1M context) --- .../check_migration_idempotency.cpython-313.pyc | Bin 5734 -> 0 bytes 1 file changed, 0 insertions(+), 0 deletions(-) delete mode 100644 .github/scripts/__pycache__/check_migration_idempotency.cpython-313.pyc diff --git a/.github/scripts/__pycache__/check_migration_idempotency.cpython-313.pyc b/.github/scripts/__pycache__/check_migration_idempotency.cpython-313.pyc deleted file mode 100644 index 96140643703393114181e1e8d3eaaf5cf97f4863..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 5734 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