From cbe1abe13842d924ac37e0efb44ea9fe964a65bf Mon Sep 17 00:00:00 2001 From: SoundMindsAI Date: Wed, 13 May 2026 15:39:19 -0400 Subject: [PATCH 1/5] fix(ci): filename-pattern guard for .env* files Adds scripts/ci/check-no-env-files.sh + a self-test driver + a new secrets-files-guard job in pr.yml. Fails any PR that adds or modifies a .env-family file other than .env.example, regardless of file content. Defense-in-depth for bug_env_file_corrupted_during_session: catches the near-miss where a rotated .env.old slipped past .gitignore (or where .gitignore is regressed / git add -f is used). Gitleaks in pre-commit is content-based and would pass an empty .env.old; this guard is filename- based and catches the structural signal. Pure bash, no python/pytest dependency. 15 canned regression cases run as a workflow step before the real-diff check, so a broken regex fails immediately. Co-Authored-By: Claude Opus 4.7 (1M context) --- .github/workflows/pr.yml | 22 +++++++ scripts/ci/check-no-env-files.sh | 89 +++++++++++++++++++++++++++ scripts/ci/test_check_no_env_files.sh | 60 ++++++++++++++++++ 3 files changed, 171 insertions(+) create mode 100755 scripts/ci/check-no-env-files.sh create mode 100755 scripts/ci/test_check_no_env_files.sh diff --git a/.github/workflows/pr.yml b/.github/workflows/pr.yml index ed9c01a5..ce28ac91 100644 --- a/.github/workflows/pr.yml +++ b/.github/workflows/pr.yml @@ -44,6 +44,28 @@ concurrency: cancel-in-progress: true jobs: + secrets-files-guard: + # bug_env_file_corrupted_during_session — defense-in-depth. + # Fails the PR if a `.env`-family file other than `.env.example` is + # added or modified. Catches the failure mode where .gitignore is + # regressed or `git add -f` is used. Runs in parallel with the heavy + # jobs and finishes in <30s. + name: secrets files guard (no .env* except .env.example) + runs-on: ubuntu-latest + timeout-minutes: 2 + steps: + - uses: actions/checkout@v6 + with: + # Need enough history for `git diff origin/...HEAD` and + # `git diff HEAD~1 HEAD` to resolve. + fetch-depth: 50 + + - name: Self-test guard against canned inputs + run: bash scripts/ci/test_check_no_env_files.sh + + - name: Verify no forbidden .env* files in diff + run: bash scripts/ci/check-no-env-files.sh + backend: name: backend (lint + typecheck + tests + coverage) runs-on: ubuntu-latest diff --git a/scripts/ci/check-no-env-files.sh b/scripts/ci/check-no-env-files.sh new file mode 100755 index 00000000..dd730811 --- /dev/null +++ b/scripts/ci/check-no-env-files.sh @@ -0,0 +1,89 @@ +#!/usr/bin/env bash +# bug_env_file_corrupted_during_session — defense-in-depth CI guard. +# +# Fails when a PR diff (or commit on main) introduces or modifies any .env +# family file other than `.env.example`. Catches the failure mode where +# .gitignore is regressed or `git add -f` is used, even when gitleaks would +# pass (e.g. an empty .env.old still signals a rotated key). +# +# Usage: +# bash scripts/ci/check-no-env-files.sh # derives diff from GITHUB_* env +# bash scripts/ci/check-no-env-files.sh --from-stdin # reads filenames on stdin (test mode) +# +# Denylist: (^|/)(\.env(\.[^/]+)?|\.envrc)$ +# Allowlist: (^|/)\.env\.example$ +# +# Exits 0 when the diff is clean, 1 when a forbidden filename appears, 2 on +# invocation error. + +set -euo pipefail + +DENY_REGEX='(^|/)(\.env(\.[^/]+)?|\.envrc)$' +ALLOW_REGEX='(^|/)\.env\.example$' + +usage() { + cat >&2 <<'EOF' +Usage: check-no-env-files.sh [--from-stdin] + + (no args) Derive the changed-files list from GitHub Actions env vars + (GITHUB_EVENT_NAME, GITHUB_BASE_REF, GITHUB_SHA). + --from-stdin Read newline-separated filenames on stdin (regression-test mode). +EOF + exit 2 +} + +resolve_changed_files() { + if [[ "${1:-}" == "--from-stdin" ]]; then + cat + return + fi + if [[ -n "${1:-}" ]]; then + usage + fi + + local event="${GITHUB_EVENT_NAME:-}" + case "${event}" in + pull_request) + local base="${GITHUB_BASE_REF:-main}" + git fetch --no-tags --depth=50 origin "${base}" >/dev/null 2>&1 || true + git diff --name-only --diff-filter=AM "origin/${base}...HEAD" + ;; + push) + git diff --name-only --diff-filter=AM HEAD~1 HEAD + ;; + "") + echo "check-no-env-files: GITHUB_EVENT_NAME is unset; pass --from-stdin to test locally" >&2 + exit 2 + ;; + *) + echo "check-no-env-files: unsupported event ${event}" >&2 + exit 2 + ;; + esac +} + +CHANGED=$(resolve_changed_files "${1:-}") + +# Filter denylist matches, then strip allowlist matches. The `|| true` keeps +# pipeline status zero when grep finds nothing — set -e would otherwise abort. +FORBIDDEN=$(printf '%s\n' "${CHANGED}" | grep -E "${DENY_REGEX}" | grep -vE "${ALLOW_REGEX}" || true) + +if [[ -z "${FORBIDDEN}" ]]; then + echo "check-no-env-files: clean (no forbidden .env* files in diff)" + exit 0 +fi + +echo "::error::Forbidden .env file(s) in PR diff:" +printf ' - %s\n' ${FORBIDDEN} +cat <<'EOF' + +Only `.env.example` may be committed. The bare `.env` and any rotated +sibling (`.env.old`, `.env.bak`, `.env.local`, `.envrc`, etc.) carry +production-shaped values and are gitignored for a reason. See +`docs/02_product/planned_features/bug_env_file_corrupted_during_session/bug_fix.md` +for the incident this guard is defending against. + +If you genuinely need to add a new template file, name it `.env.example` +(or `.env..example`) and update this guard. +EOF +exit 1 diff --git a/scripts/ci/test_check_no_env_files.sh b/scripts/ci/test_check_no_env_files.sh new file mode 100755 index 00000000..4d88c21e --- /dev/null +++ b/scripts/ci/test_check_no_env_files.sh @@ -0,0 +1,60 @@ +#!/usr/bin/env bash +# Regression test for check-no-env-files.sh. +# +# Feeds synthetic file lists through the guard in --from-stdin mode and +# asserts pass/fail. Doubles as the bug_env_file_corrupted_during_session +# regression guard: each ALLOW case must exit 0, each DENY case must exit 1. +# +# Run locally: bash scripts/ci/test_check_no_env_files.sh +# Run in CI: invoked by the secrets-files-guard job in .github/workflows/pr.yml + +set -euo pipefail + +GUARD="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/check-no-env-files.sh" +PASS=0 +FAIL=0 + +# expect_exit +expect_exit() { + local expected="$1" + local name="$2" + shift 2 + local input + input="$(printf '%s\n' "$@")" + local actual=0 + printf '%s' "${input}" | bash "${GUARD}" --from-stdin >/dev/null 2>&1 || actual=$? + if [[ "${actual}" -eq "${expected}" ]]; then + echo " ok ${name}" + PASS=$((PASS + 1)) + else + echo " FAIL ${name} (expected exit ${expected}, got ${actual})" + FAIL=$((FAIL + 1)) + fi +} + +echo "check-no-env-files regression cases:" + +# Allow cases — exit 0. +expect_exit 0 "empty diff" +expect_exit 0 "unrelated files only" "README.md" "backend/app/api/health.py" +expect_exit 0 ".env.example at repo root" ".env.example" +expect_exit 0 ".env.example under subdir" "backend/.env.example" +expect_exit 0 "doc file foo.env (no leading dot-env)" "docs/foo.env" +expect_exit 0 ".environment.yml (env-prefix but not .env)" ".environment.yml" + +# Deny cases — exit 1. +expect_exit 1 "bare .env" ".env" +expect_exit 1 ".env.old (the canonical incident)" ".env.old" +expect_exit 1 ".env.bak" ".env.bak" +expect_exit 1 ".env.local" ".env.local" +expect_exit 1 ".envrc" ".envrc" +expect_exit 1 ".env.production" ".env.production" +expect_exit 1 "nested .env" "secrets/.env" +expect_exit 1 "nested .env.old" "backend/.env.old" +expect_exit 1 "mixed legit + bad (.env.example + .env.old)" ".env.example" ".env.old" + +echo +echo "${PASS} passed, ${FAIL} failed" +if [[ "${FAIL}" -gt 0 ]]; then + exit 1 +fi From c3169bddf615dc40050fb9533d33232bbf02c5ba Mon Sep 17 00:00:00 2001 From: SoundMindsAI Date: Wed, 13 May 2026 15:39:44 -0400 Subject: [PATCH 2/5] docs(bug): bug_fix.md for bug_env_file_corrupted_during_session + 2 tangential idea files MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit bug_fix.md documents the locked design for the secrets-files-guard job shipped in the previous commit: detection mechanism, regex shape, failure mode, position in the workflow, and the open question on what local tool originally renamed .env -> .env.old (deferred — cannot reproduce 4 days post-incident). Tangential follow-ups captured per CLAUDE.md "Tangential discoveries": - chore_ci_gitignore_paths_ignore_gap — pr.yml paths-ignore skips the whole workflow on .gitignore-only PRs, undermining the guard. - chore_ci_gitleaks_workflow_step — gitleaks runs only in pre-commit locally; CI should also content-scan to complement the filename guard. Includes MVP1 dashboard regen for the new planned-feature folders. Co-Authored-By: Claude Opus 4.7 (1M context) --- docs/00_overview/MVP1_DASHBOARD.md | 8 +- docs/00_overview/mvp1_dashboard.html | 30 ++++- .../bug_fix.md | 120 ++++++++++++++++++ .../idea.md | 74 +++++++++++ .../chore_ci_gitleaks_workflow_step/idea.md | 65 ++++++++++ 5 files changed, 291 insertions(+), 6 deletions(-) create mode 100644 docs/02_product/planned_features/bug_env_file_corrupted_during_session/bug_fix.md create mode 100644 docs/02_product/planned_features/chore_ci_gitignore_paths_ignore_gap/idea.md create mode 100644 docs/02_product/planned_features/chore_ci_gitleaks_workflow_step/idea.md diff --git a/docs/00_overview/MVP1_DASHBOARD.md b/docs/00_overview/MVP1_DASHBOARD.md index 8cba11b7..bbdcd319 100644 --- a/docs/00_overview/MVP1_DASHBOARD.md +++ b/docs/00_overview/MVP1_DASHBOARD.md @@ -13,9 +13,9 @@ Pull from the Idea backlog or capture a new feature spec. | Metric | Value | |---|---| | Scoped items done | **26 / 26** (100%) — feat_/infra_/chore_/epic_ past idea stage | -| Path to MVP1 | **9** items remaining (features + bugs + chores) | +| Path to MVP1 | **11** items remaining (features + bugs + chores) | | Open bugs | 2 | -| Open chores | 7 (idea-stage debt) | +| Open chores | 9 (idea-stage debt) | | Backlog ideas | 1 idea-only feat/infra (not yet scoped into MVP1) | | In flight | 0 feature(s) actively shipping | @@ -70,12 +70,14 @@ _None._ _None._ -### Idea (10) +### Idea (12) | Feature | Type | One-liner | Depends on | Status | |---|---|---|---|---| | [infra_arq_subprocess_test](../02_product/planned_features/infra_arq_subprocess_test/idea.md) | Infra | Idea (deferred from `feat_study_lifecycle` Phase 2 / PR #25 final GPT-5.5 review) | — | Idea (deferred from `feat_study_lifecycle` Phase 2 / PR #25 final GPT-5.5 review) | | [chore_chat_last_message_preview](../02_product/planned_features/chore_chat_last_message_preview/idea.md) | Chore | The `/chat` list page (`ui/src/app/chat/page.tsx`) shows each conversation row as `title + relative timestamp + "{N} messages"`. There is no preview of the last message — operators with several simila | — | — | +| [chore_ci_gitignore_paths_ignore_gap](../02_product/planned_features/chore_ci_gitignore_paths_ignore_gap/idea.md) | Chore | `.github/workflows/pr.yml` has a `paths-ignore` filter that skips the entire workflow when *every* changed path matches: | — | Idea — captured while implementing `bug_env_file_corrupted_during_session` | +| [chore_ci_gitleaks_workflow_step](../02_product/planned_features/chore_ci_gitleaks_workflow_step/idea.md) | Chore | Gitleaks runs only as a pre-commit hook locally. Pre-commit is opt-in (the contributor must run `pre-commit install`); on a fresh clone or a contributor who skips the install, nothing scans commit con | — | Idea — captured while implementing `bug_env_file_corrupted_during_session` | | [chore_cluster_run_query_history](../02_product/planned_features/chore_cluster_run_query_history/idea.md) | Chore | The "recent run-query history" surface in spec §3 cannot be built without backend support. The `feat_studies_ui` plan (Story 2.1) drops this from the cluster detail page and renders only the summary + | — | — | | [chore_demo_recording_mvp3](../02_product/planned_features/chore_demo_recording_mvp3/idea.md) | Chore | | — | — | | [chore_digest_worker_narrow_except](../02_product/planned_features/chore_digest_worker_narrow_except/idea.md) | Chore | … | — | Idea (deferred from Gemini Code Assist Finding #2 on [PR #92](https://github.com/SoundMindsAI/relyloop/pull/92)) | diff --git a/docs/00_overview/mvp1_dashboard.html b/docs/00_overview/mvp1_dashboard.html index a1604719..393acd72 100644 --- a/docs/00_overview/mvp1_dashboard.html +++ b/docs/00_overview/mvp1_dashboard.html @@ -334,7 +334,7 @@

MVP1 Progress

Path to MVP1
-
9
+
11
items left = features + bugs + chores
@@ -344,7 +344,7 @@

MVP1 Progress

Open chores
-
7
+
9
idea-stage chore_* (debt)
@@ -372,7 +372,7 @@

Pipeline

-

Idea 10

+

Idea 12

@@ -398,6 +398,30 @@

Idea 10

+
+ +
+ Chore + +
+
`.github/workflows/pr.yml` has a `paths-ignore` filter that skips the entire workflow when *every* changed path matches:
+ + +
+ + +
+ +
+ Chore + +
+
Gitleaks runs only as a pre-commit hook locally. Pre-commit is opt-in (the contributor must run `pre-commit install`); on a fresh clone or a contributor who skips the install, nothing scans commit con
+ + +
+ +
diff --git a/docs/02_product/planned_features/bug_env_file_corrupted_during_session/bug_fix.md b/docs/02_product/planned_features/bug_env_file_corrupted_during_session/bug_fix.md new file mode 100644 index 00000000..67dc6ad8 --- /dev/null +++ b/docs/02_product/planned_features/bug_env_file_corrupted_during_session/bug_fix.md @@ -0,0 +1,120 @@ +# Bug fix — bug_env_file_corrupted_during_session + +**Source idea:** [idea.md](./idea.md) +**Branch:** `bug/env-file-ci-guard` +**Type:** bug fix — medium (defense-in-depth scope; cause investigation deferred) +**Date:** 2026-05-13 + +## Problem + +During the `infra_foundation` (PR #4) implementation session on 2026-05-09, the +user's `.env` (180 bytes, containing a real `OPENAI_API_KEY`) was renamed to +`.env.old` and replaced with an empty `.env`. The rotation happened at the same +timestamp the agent wrote `.env.example`, but no pre-commit hook, agent action, +or known repo tool writes `.env.old`. The `.env.example` was the new file; the +rotation came from somewhere on the user's local machine (IDE save behavior, +direnv/1Password CLI/asdf, or a coincidental FS event). + +The near-miss: `.env.old` was not in `.gitignore` at the time, so a `git add -A` +would have staged the rotated key. The PR's `.gitignore` patch added `.env.old` +/`.env.bak`/`.env.local`/`.envrc`, but CI has **no filename-pattern guard** — +the only defense if `.gitignore` regresses or someone uses `git add -f` is the +human reviewer. + +## Reproduction + +**Original FS event:** cannot reproduce 4 days post-incident. `.env.old` is +gone (user restored `.env` at 17:11 on 2026-05-09); no recurrence in subsequent +PRs. Cause remains undetermined; classified as one-off and user-environment- +local. See "Open questions" below. + +**Latent CI gap (this skill's scope):** a synthetic file list containing +`.env.old` should be rejected by a CI guard. Currently no such guard exists: + +```bash +grep gitleaks .github/workflows/pr.yml # returns empty — no content scan in CI +grep -E "env" .github/workflows/pr.yml # only matches step-level `env:` blocks +``` + +After the fix, the regression test exercises the gap: + +```bash +bash scripts/ci/test_check_no_env_files.sh +# 15 passed, 0 failed — synthetic .env.old / .env.local / .envrc inputs all rejected +``` + +## Root cause + +Two distinct concerns: + +- **Original FS event:** owning layer = **user's local filesystem / tooling** + (IDE save flow, direnv, 1Password CLI, asdf, VS Code extensions). Agent has + no visibility into the user's machine and cannot reproduce. Out of scope for + code change. +- **Defense gap (this fix's target):** owning layer = **CI / repo policy**. + Origin: [.github/workflows/pr.yml](../../../../.github/workflows/pr.yml) + has no filename guard for `.env*` patterns. Propagation: + [.gitignore:153-163](../../../../.gitignore#L153-L163) is the only structural + defense, and gitleaks (in [.pre-commit-config.yaml](../../../../.pre-commit-config.yaml)) + is content-based — an empty `.env.old` would pass gitleaks even though it + signals a rotated key. + +## Fix design (locked decisions) + +1. **Detection — `git diff --name-only` + grep regex.** Cheap, hermetic, no + language runtime. Cites: [scripts/ci/verify_enum_source_of_truth.sh](../../../../scripts/ci/verify_enum_source_of_truth.sh) + precedent (same `scripts/ci/.sh` pure-bash shape). +2. **Pattern — deny `(^|/)(\.env(\.[^/]+)?|\.envrc)$`, allow `(^|/)\.env\.example$`.** + Catches bare `.env`, dotted `.env.`, and direnv's `.envrc`; subdir matches + included; excludes `foo.env` / `.environment.yml` false-positives. Cites: + idea.md §"Proposed work" item 3. +3. **Failure mode — hard fail with `::error::` annotation + runbook pointer.** + Cites: CLAUDE.md Absolute Rule 10 (never log/expose secrets). +4. **Workflow position — standalone `secrets-files-guard` job, ~20s, parallel + with `backend`/`frontend`/`docker`.** Independent PR check; no service + containers needed. Cites: existing job-per-concern pattern in pr.yml. +5. **Diff filter — `--diff-filter=AM`.** Block additions + modifications; + removals are not security risks. +6. **Regression test — `--from-stdin` mode + canned inputs in CI step.** Keeps + the test in the same workflow run (`scripts/ci/test_check_no_env_files.sh`) + so the guard's own logic is exercised before it runs against the real diff. + +### Open questions (deferred to user) + +These belong to the user's local environment, not the repo. They remain open +after this fix: + +- **Which tool renamed `.env` → `.env.old`?** Recommended: next time the user + observes a `.env` rotation, run `fs_usage -w -f filesys | grep '\.env'` + before opening the IDE, then trigger the suspected action. The candidate + list (from idea.md): IDE save handler, direnv, 1Password CLI, asdf, VS Code + extension. If reproduced, file a follow-up bug under the offending tool. + +## Regression test plan + +| Layer | Path | What it asserts | +|---|---|---| +| CI guard | [scripts/ci/test_check_no_env_files.sh](../../../../scripts/ci/test_check_no_env_files.sh) | 15 cases — `.env.example` passes, bare `.env` / `.env.old` / `.env.bak` / `.env.local` / `.envrc` / nested variants all fail with exit 1. Run as a workflow step *before* the real-diff check so a broken guard regex is caught immediately. | + +The test fails on `main` (script does not exist) and passes on this branch. +The workflow step (`secrets-files-guard` job) invokes the same test in CI. + +## Rollout + +Code-only change. No migration, no feature flag, no operator action. The new +job appears as a fast (≤30s) check in the PR check list; first PR on this +branch is its own smoke test. If the regex ever needs to allow a new template +filename (`.env.staging.example`, etc.), update `ALLOW_REGEX` in +`scripts/ci/check-no-env-files.sh` and add a test case. + +## Tangential observations + +- [chore_ci_gitignore_paths_ignore_gap](../chore_ci_gitignore_paths_ignore_gap/idea.md) — + `pr.yml`'s `paths-ignore` includes `.gitignore` and `docs/**`, so a PR that + *only* tampers with `.gitignore` (removing `.env.old`) or *only* adds + `docs/.env.old` skips the workflow entirely. Reviewer is still the + backstop; consider tightening paths-ignore. +- [chore_ci_gitleaks_workflow_step](../chore_ci_gitleaks_workflow_step/idea.md) — + gitleaks is in `.pre-commit-config.yaml` but not invoked from CI. Local + pre-commit is opt-in; CI should also run a content scan to complement the + filename guard added here. diff --git a/docs/02_product/planned_features/chore_ci_gitignore_paths_ignore_gap/idea.md b/docs/02_product/planned_features/chore_ci_gitignore_paths_ignore_gap/idea.md new file mode 100644 index 00000000..b78a5581 --- /dev/null +++ b/docs/02_product/planned_features/chore_ci_gitignore_paths_ignore_gap/idea.md @@ -0,0 +1,74 @@ +# chore_ci_gitignore_paths_ignore_gap — Idea + +**Date:** 2026-05-13 +**Status:** Idea — captured while implementing `bug_env_file_corrupted_during_session` +**Origin:** Surfaced during `/bug-fix` Phase 5 implementation of the +`secrets-files-guard` job in [.github/workflows/pr.yml](../../../../.github/workflows/pr.yml). +**Depends on:** `bug_env_file_corrupted_during_session` (this PR) — the +`secrets-files-guard` job whose effectiveness this gap limits. + +## Problem + +`.github/workflows/pr.yml` has a `paths-ignore` filter that skips the entire +workflow when *every* changed path matches: + +```yaml +paths-ignore: + - 'docs/**' + - '*.md' + - '.gitignore' + - 'LICENSE' + - 'release-notes-*.md' +``` + +Two failure modes follow: + +1. **`.gitignore`-only PR** — a PR that removes `.env.old` (or any other + `.env*` line) from `.gitignore` and nothing else does **not** trigger the + workflow at all. Neither the new `secrets-files-guard` nor any other check + runs. The human reviewer is the sole defense. +2. **`docs/.env.old`** — a `.env*` file placed under `docs/` matches + `docs/**` and the workflow is skipped. The guard never runs. + +Neither is a realistic attack today (both are obvious in code review), but +they undermine the defense-in-depth claim of the `secrets-files-guard` job +shipped in `bug_env_file_corrupted_during_session`. + +## Proposed capabilities + +### Tighten paths-ignore + +- Remove `.gitignore` from `paths-ignore`. `.gitignore` changes are + security-relevant; they should always trigger at least the guard job. +- Optionally narrow `docs/**` to `docs/**/*.md` so a non-markdown file + smuggled into `docs/` still triggers CI. +- Verify the change does not re-introduce noise for docs-only PRs (the + guard job costs ≤30s; backend/frontend should stay path-filtered). + +### Or split the guard out + +- Alternative: move `secrets-files-guard` into a *separate workflow file* + with no `paths-ignore`. Always runs on every PR, regardless of which + paths changed. Keeps the heavy `pr.yml` jobs path-filtered for cost. + +## Scope signals + +- **Backend:** none +- **Frontend:** none +- **Migration:** none +- **Config:** `.github/workflows/pr.yml` (or new `.github/workflows/secrets-files-guard.yml`) +- **Audit events:** N/A + +## Why deferred + +Out of scope for the immediate bug fix — the `secrets-files-guard` job +itself is the primary defense and is already shipping. The paths-ignore gap +is a known second-order limitation that should be closed in a follow-up but +does not block the bug-fix PR. + +## Relationship to other work + +- Extends `bug_env_file_corrupted_during_session` — same defense-in-depth + intent, narrower fix. +- Coordinate with `chore_ci_gitleaks_workflow_step` if both land — gitleaks + in CI is the content-scanning peer to this filename-pattern guard. diff --git a/docs/02_product/planned_features/chore_ci_gitleaks_workflow_step/idea.md b/docs/02_product/planned_features/chore_ci_gitleaks_workflow_step/idea.md new file mode 100644 index 00000000..71faa516 --- /dev/null +++ b/docs/02_product/planned_features/chore_ci_gitleaks_workflow_step/idea.md @@ -0,0 +1,65 @@ +# chore_ci_gitleaks_workflow_step — Idea + +**Date:** 2026-05-13 +**Status:** Idea — captured while implementing `bug_env_file_corrupted_during_session` +**Origin:** Surfaced during `/bug-fix` Phase 3 root-cause trace. `grep gitleaks +.github/workflows/pr.yml` returned empty even though gitleaks is configured in +[.pre-commit-config.yaml](../../../../.pre-commit-config.yaml). +**Depends on:** None (parallel to `bug_env_file_corrupted_during_session`) + +## Problem + +Gitleaks runs only as a pre-commit hook locally. Pre-commit is opt-in (the +contributor must run `pre-commit install`); on a fresh clone or a contributor +who skips the install, nothing scans commit content for secrets. CI is the +universal backstop, and CI does not currently invoke gitleaks. + +The new filename-pattern guard from `bug_env_file_corrupted_during_session` +catches `.env*` filenames but not: + +- An `OPENAI_API_KEY=sk-...` line accidentally pasted into a `.md` doc. +- A leaked Postgres URL inside a Compose override at an arbitrary path. +- Any high-entropy / known-credential pattern outside the `.env*` filename + convention. + +Defense-in-depth pairing: filename guard (this PR) + content scan (this idea). + +## Proposed capabilities + +### Add gitleaks step to pr.yml + +- Pin to the same `gitleaks` version that pre-commit uses (`v8.21.2` per + `.pre-commit-config.yaml`). +- Run as part of the `secrets-files-guard` job (or its own fast job) + alongside the filename guard. +- Scan PR diff only (not full history) — keeps cost minimal. +- Use the project's existing `.gitleaks.toml` config if present, else the + shipped defaults. + +### Document override path + +- Runbook entry for how to allowlist a false-positive (the canonical example + is `OPENAI_BASE_URL` URLs that resemble secrets but aren't). + +## Scope signals + +- **Backend:** none +- **Frontend:** none +- **Migration:** none +- **Config:** `.github/workflows/pr.yml` (or split workflow), optional `.gitleaks.toml` +- **Audit events:** N/A + +## Why deferred + +The filename guard shipped in `bug_env_file_corrupted_during_session` closes +the specific failure mode that motivated the bug (rotated `.env.old` smuggled +past `.gitignore`). Content scanning is a separate, broader concern that +deserves its own design discussion (false-positive policy, scan scope, +performance budget). Not a blocker for the bug fix. + +## Relationship to other work + +- Complements `bug_env_file_corrupted_during_session` (filename pattern + defense) by adding content-pattern defense. +- Coordinate with `chore_ci_gitignore_paths_ignore_gap` — same workflow file + changes; could land in a single PR. From 1588434b51f4d20b3ebc4d445996262c8b6768a7 Mon Sep 17 00:00:00 2001 From: SoundMindsAI Date: Wed, 13 May 2026 15:48:24 -0400 Subject: [PATCH 3/5] style(ci): add shellcheck SC2086 disable for intentional word-split MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The unquoted ${FORBIDDEN} expansion in check-no-env-files.sh is deliberate — newline-splitting drives printf format-reuse so each forbidden file prints on its own bullet line. Quoting would collapse the output to a single line. Annotate the intent so future shellcheck runs don't re-flag. Co-Authored-By: Claude Opus 4.7 (1M context) --- scripts/ci/check-no-env-files.sh | 1 + 1 file changed, 1 insertion(+) diff --git a/scripts/ci/check-no-env-files.sh b/scripts/ci/check-no-env-files.sh index dd730811..985c2efc 100755 --- a/scripts/ci/check-no-env-files.sh +++ b/scripts/ci/check-no-env-files.sh @@ -74,6 +74,7 @@ if [[ -z "${FORBIDDEN}" ]]; then fi echo "::error::Forbidden .env file(s) in PR diff:" +# shellcheck disable=SC2086 # Word-split is intentional: one bullet per file via printf format-reuse. printf ' - %s\n' ${FORBIDDEN} cat <<'EOF' From 6397bcd39e8783875add0d48fad34a4b11976884 Mon Sep 17 00:00:00 2001 From: SoundMindsAI Date: Wed, 13 May 2026 15:57:56 -0400 Subject: [PATCH 4/5] fix(ci): widen guard to catch renames + handle spaces in filenames (Gemini #2/3/4) Accepted findings from Gemini Code Assist on PR #94: - --diff-filter=AM -> ACMR. High-similarity `git mv foo.txt .env.old` is reported as Renamed by git, not Added; AM alone would miss it. ACMR (Added, Copied, Modified, Renamed) closes the rename/copy bypass. - Replace unquoted ${FORBIDDEN} word-split with `sed 's/^/ - /'`. The regex's `[^/]+` permits spaces in `.env.<...>` filenames; the previous word-splitting approach would mis-split on space. The shellcheck SC2086 disable is no longer needed and is removed. - New regression case `.env.foo bar.bak` proves the space-handling fix. All 16 cases pass. Rejected finding #1 (Gemini High): claimed pr.yml workflow update was missing. Counter-evidence: `gh pr view 94 --json files` shows .github/workflows/pr.yml with +22/-0 in this PR (the secrets-files-guard job). Gemini reviewed hunks without seeing the workflow change in the same PR. Co-Authored-By: Claude Opus 4.7 (1M context) --- scripts/ci/check-no-env-files.sh | 12 ++++++++---- scripts/ci/test_check_no_env_files.sh | 1 + 2 files changed, 9 insertions(+), 4 deletions(-) diff --git a/scripts/ci/check-no-env-files.sh b/scripts/ci/check-no-env-files.sh index 985c2efc..ea856f1d 100755 --- a/scripts/ci/check-no-env-files.sh +++ b/scripts/ci/check-no-env-files.sh @@ -42,14 +42,17 @@ resolve_changed_files() { fi local event="${GITHUB_EVENT_NAME:-}" + # --diff-filter=ACMR: Added, Copied, Modified, Renamed. R is non-obvious + # but important — `git mv foo.txt .env.old` with high content similarity + # is reported as a Rename, not Added; AM alone would miss it. case "${event}" in pull_request) local base="${GITHUB_BASE_REF:-main}" git fetch --no-tags --depth=50 origin "${base}" >/dev/null 2>&1 || true - git diff --name-only --diff-filter=AM "origin/${base}...HEAD" + git diff --name-only --diff-filter=ACMR "origin/${base}...HEAD" ;; push) - git diff --name-only --diff-filter=AM HEAD~1 HEAD + git diff --name-only --diff-filter=ACMR HEAD~1 HEAD ;; "") echo "check-no-env-files: GITHUB_EVENT_NAME is unset; pass --from-stdin to test locally" >&2 @@ -74,8 +77,9 @@ if [[ -z "${FORBIDDEN}" ]]; then fi echo "::error::Forbidden .env file(s) in PR diff:" -# shellcheck disable=SC2086 # Word-split is intentional: one bullet per file via printf format-reuse. -printf ' - %s\n' ${FORBIDDEN} +# Use sed to prefix each newline-separated entry. Robust against filenames +# containing spaces (where unquoted printf word-splitting would mis-split). +printf '%s\n' "${FORBIDDEN}" | sed 's/^/ - /' cat <<'EOF' Only `.env.example` may be committed. The bare `.env` and any rotated diff --git a/scripts/ci/test_check_no_env_files.sh b/scripts/ci/test_check_no_env_files.sh index 4d88c21e..a9d12ce7 100755 --- a/scripts/ci/test_check_no_env_files.sh +++ b/scripts/ci/test_check_no_env_files.sh @@ -52,6 +52,7 @@ expect_exit 1 ".env.production" ".env.production" expect_exit 1 "nested .env" "secrets/.env" expect_exit 1 "nested .env.old" "backend/.env.old" expect_exit 1 "mixed legit + bad (.env.example + .env.old)" ".env.example" ".env.old" +expect_exit 1 ".env.foo bar.bak (space in name)" ".env.foo bar.bak" echo echo "${PASS} passed, ${FAIL} failed" From 83961ef2c8900ba986a00f6ff73c522339d26b00 Mon Sep 17 00:00:00 2001 From: SoundMindsAI Date: Wed, 13 May 2026 16:12:49 -0400 Subject: [PATCH 5/5] fix(ci): explicit refspec for origin/ resolution (GPT-5.5 #1) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Accepted GPT-5.5 final-review finding #1: the implicit `git fetch origin ` does not deterministically create refs/remotes/origin/; under some actions/checkout modes (especially forked PRs) the diff would silently fail to enumerate changes and the guard would no-op. Replace with an explicit refspec and add an `origin/` rev-parse check that exits 2 with a clear error if resolution fails — refusing to silently skip the guard. CI runs prior to this fix passed because actions/checkout's fetch-depth=50 already populated origin/main in the typical case; the fix removes the brittleness rather than fixing a current-observable bug. Deferred GPT-5.5 #2 (workflow paths-ignore inheritance): already captured as chore_ci_gitignore_paths_ignore_gap. Counter-evidence: bug_fix.md Tangential Observations §1. Deferred GPT-5.5 #3 (regex misses .env-old / .env_bak / .env~): captured as a new chore_env_guard_extend_deny_pattern idea file. Not a regression of this PR — the gitignore and the guard are currently consistent (both dotted-only); broadening one requires broadening the other, and that paired change deserves its own focused PR. Includes MVP1 dashboard regen for the new chore_env_guard_extend_deny_pattern folder. Co-Authored-By: Claude Opus 4.7 (1M context) --- docs/00_overview/MVP1_DASHBOARD.md | 7 +- docs/00_overview/mvp1_dashboard.html | 18 +++- .../idea.md | 93 +++++++++++++++++++ scripts/ci/check-no-env-files.sh | 11 ++- 4 files changed, 122 insertions(+), 7 deletions(-) create mode 100644 docs/02_product/planned_features/chore_env_guard_extend_deny_pattern/idea.md diff --git a/docs/00_overview/MVP1_DASHBOARD.md b/docs/00_overview/MVP1_DASHBOARD.md index bbdcd319..2b0962c8 100644 --- a/docs/00_overview/MVP1_DASHBOARD.md +++ b/docs/00_overview/MVP1_DASHBOARD.md @@ -13,9 +13,9 @@ Pull from the Idea backlog or capture a new feature spec. | Metric | Value | |---|---| | Scoped items done | **26 / 26** (100%) — feat_/infra_/chore_/epic_ past idea stage | -| Path to MVP1 | **11** items remaining (features + bugs + chores) | +| Path to MVP1 | **12** items remaining (features + bugs + chores) | | Open bugs | 2 | -| Open chores | 9 (idea-stage debt) | +| Open chores | 10 (idea-stage debt) | | Backlog ideas | 1 idea-only feat/infra (not yet scoped into MVP1) | | In flight | 0 feature(s) actively shipping | @@ -70,7 +70,7 @@ _None._ _None._ -### Idea (12) +### Idea (13) | Feature | Type | One-liner | Depends on | Status | |---|---|---|---|---| @@ -81,6 +81,7 @@ _None._ | [chore_cluster_run_query_history](../02_product/planned_features/chore_cluster_run_query_history/idea.md) | Chore | The "recent run-query history" surface in spec §3 cannot be built without backend support. The `feat_studies_ui` plan (Story 2.1) drops this from the cluster detail page and renders only the summary + | — | — | | [chore_demo_recording_mvp3](../02_product/planned_features/chore_demo_recording_mvp3/idea.md) | Chore | | — | — | | [chore_digest_worker_narrow_except](../02_product/planned_features/chore_digest_worker_narrow_except/idea.md) | Chore | … | — | Idea (deferred from Gemini Code Assist Finding #2 on [PR #92](https://github.com/SoundMindsAI/relyloop/pull/92)) | +| [chore_env_guard_extend_deny_pattern](../02_product/planned_features/chore_env_guard_extend_deny_pattern/idea.md) | Chore | The shipped guard regex matches `(\.env(\.[^/]+)?\|\.envrc)$` — i.e. bare `.env`, dotted `.env.`, and `.envrc`. It does **not** match plausible backup/rotation spellings that an editor or user-side | — | Idea — deferred GPT-5.5 review finding on PR #94 | | [chore_judgments_periodic_resume_sweep](../02_product/planned_features/chore_judgments_periodic_resume_sweep/idea.md) | Chore | `feat_llm_judgments` Story 2.1 ships a **boot-time** resume sweep in `backend/workers/all.py:on_startup`: every `judgment_lists.status='generating'` row gets re-enqueued at worker boot, covering the c | — | Idea — deferred from feat_llm_judgments cycle-2 plan review | | [chore_query_inline_edit_delete](../02_product/planned_features/chore_query_inline_edit_delete/idea.md) | Chore | The `feat_studies_ui` plan called for a "view-only queries table" on the query-set detail page (Story 2.2). During implementation we discovered there is **no GET endpoint to list individual queries ei | — | — | | [chore_studies_ui_shadcn_polish](../02_product/planned_features/chore_studies_ui_shadcn_polish/idea.md) | Chore | | — | — | diff --git a/docs/00_overview/mvp1_dashboard.html b/docs/00_overview/mvp1_dashboard.html index 393acd72..ecaca27b 100644 --- a/docs/00_overview/mvp1_dashboard.html +++ b/docs/00_overview/mvp1_dashboard.html @@ -334,7 +334,7 @@

MVP1 Progress

Path to MVP1
-
11
+
12
items left = features + bugs + chores
@@ -344,7 +344,7 @@

MVP1 Progress

Open chores
-
9
+
10
idea-stage chore_* (debt)
@@ -372,7 +372,7 @@

Pipeline

-

Idea 12

+

Idea 13

@@ -458,6 +458,18 @@

Idea 12

+
+ +
+ Chore + +
+
The shipped guard regex matches `(\.env(\.[^/]+)?|\.envrc)$` — i.e. bare `.env`, dotted `.env.<x>`, and `.envrc`. It does **not** match plausible backup/rotation spellings that an editor or user-side
+ + +
+ +
diff --git a/docs/02_product/planned_features/chore_env_guard_extend_deny_pattern/idea.md b/docs/02_product/planned_features/chore_env_guard_extend_deny_pattern/idea.md new file mode 100644 index 00000000..7cce73bb --- /dev/null +++ b/docs/02_product/planned_features/chore_env_guard_extend_deny_pattern/idea.md @@ -0,0 +1,93 @@ +# chore_env_guard_extend_deny_pattern — Idea + +**Date:** 2026-05-13 +**Status:** Idea — deferred GPT-5.5 review finding on PR #94 +**Origin:** GPT-5.5 final-review Low-severity finding #3 against +[`scripts/ci/check-no-env-files.sh`](../../../../scripts/ci/check-no-env-files.sh) introduced in PR #94 +(`bug_env_file_corrupted_during_session`). +**Depends on:** `bug_env_file_corrupted_during_session` (PR #94) — the +filename-pattern guard whose denylist this idea extends. + +## Problem + +The shipped guard regex matches `(\.env(\.[^/]+)?|\.envrc)$` — i.e. bare +`.env`, dotted `.env.`, and `.envrc`. It does **not** match plausible +backup/rotation spellings that an editor or user-side script could produce: + +- `.env-old` (dash separator) +- `.env_bak` (underscore separator) +- `.env~` (tilde — common editor backup suffix) +- `.env.swp` / `.env.swo` (vim swap files — *do* match the current regex, so already caught) + +The risk surface is real but speculative — the original incident +(`bug_env_file_corrupted_during_session`) was a *dotted* rotation +(`.env.old`), and `.gitignore` lines 153-157 only enumerate dotted spellings +(`.env`, `.env.old`, `.env.bak`, `.env.local`, `.envrc`). The guard and the +gitignore are currently consistent: both catch dotted, both miss non-dotted. + +## Proposed capabilities + +### Broaden the deny regex + +Expand `DENY_REGEX` to also match `.env` followed by `_`, `-`, or `~` plus +optional content. Tentative pattern: + +```bash +DENY_REGEX='(^|/)(\.env([._\-~][^/]*)?|\.envrc)$' +``` + +False-positive checks (must continue to PASS the existing test suite): + +- `.environment.yml` → `.env` + `ironment.yml`; `i` not in `[._\-~]`. ✓ +- `.envoy.conf` → `.env` + `oy.conf`; `o` not in `[._\-~]`. ✓ +- `foo.env` / `docs/foo.env` → preceding char is letter, not `(^|/)`. ✓ + +New deny cases to add to `test_check_no_env_files.sh`: + +- `.env-old` +- `.env_bak` +- `.env~` +- `backend/.env-prod` (subdirectory variant) + +### Parallel `.gitignore` update + +To keep the guard and the gitignore aligned, extend `.gitignore` at the +same time: + +```gitignore +.env-* +.env_* +.env~ +``` + +Without this, a non-dotted variant would still slip past local +`git add -A` even with the guard in CI. + +## Scope signals + +- **Backend:** none +- **Frontend:** none +- **Migration:** none +- **Config:** `scripts/ci/check-no-env-files.sh` (regex), `scripts/ci/test_check_no_env_files.sh` (4 new cases), `.gitignore` (3 new patterns) +- **Audit events:** N/A + +## Why deferred + +The shipped guard fully addresses the **documented** incident pattern +(`.env.old`, dotted). Non-dotted variants are theoretical bypass surface +that has never been observed in this codebase or its dependencies. Mixing +the regex broadening + parallel `.gitignore` updates + their test cases +into PR #94 would blur the PR's scope (the focused defense for an +observed incident vs. a speculative pattern-broadening exercise). + +A separate PR keeps each change reviewable and the test suite shape clean +(this is the file pattern review will land on). + +## Relationship to other work + +- Extends `bug_env_file_corrupted_during_session` (PR #94) — same defense + surface, broader pattern. +- Could land alongside `chore_ci_gitleaks_workflow_step` if a single PR + covers both content-scan + broader filename pattern. +- Independent of `chore_ci_gitignore_paths_ignore_gap` (workflow-level + paths-ignore is a separate failure mode). diff --git a/scripts/ci/check-no-env-files.sh b/scripts/ci/check-no-env-files.sh index ea856f1d..d7ee2a03 100755 --- a/scripts/ci/check-no-env-files.sh +++ b/scripts/ci/check-no-env-files.sh @@ -48,7 +48,16 @@ resolve_changed_files() { case "${event}" in pull_request) local base="${GITHUB_BASE_REF:-main}" - git fetch --no-tags --depth=50 origin "${base}" >/dev/null 2>&1 || true + # Explicit refspec creates refs/remotes/origin/ deterministically. + # actions/checkout's fetch-depth=50 usually populates this already, but + # an explicit refspec makes the behavior robust to checkout-mode changes + # and forked-PR semantics. + git fetch --no-tags --depth=50 origin \ + "+refs/heads/${base}:refs/remotes/origin/${base}" >/dev/null 2>&1 || true + if ! git rev-parse --verify --quiet "origin/${base}" >/dev/null; then + echo "::error::check-no-env-files: cannot resolve origin/${base} — refusing to skip the guard. Check the workflow's fetch-depth or actions/checkout configuration." >&2 + exit 2 + fi git diff --name-only --diff-filter=ACMR "origin/${base}...HEAD" ;; push)