From 8ec0a30bf4bd693c8e2f22dfa428a899eaada261 Mon Sep 17 00:00:00 2001 From: Ed_ Date: Thu, 25 Jun 2026 10:21:02 -0400 Subject: [PATCH] feat(scripts): add audit_branch_required_files.py (Rule 4 CI gate) Defense-in-depth check for the 2026-06-24 MCP regression: verifies that the 2 MCP-config files (opencode.json + mcp_paths.toml) are present on a tier-2 branch. If either is missing, the audit fails (exit 1) with a clear diagnostic and the exact commands to restore the files. The pre-commit hook (conductor/tier2/githooks/pre-commit, hardened in eae75877) auto-unstages these files on commit, but does not prevent the deletion from being in the commit's diff. The 2026-06-24 MCP regression was exactly this: commit 6956676f deleted both files, and the empty fix commit (2b7e2de1) was a no-op. This audit catches that pattern 1 step earlier than the user noticing: on push, on pre-merge, on manual review. It checks the branch's index via 'git cat-file -e ref:file' (not the working tree) so it works in CI without a checked-out working tree. Usage: # Audit the current HEAD uv run python scripts/audit_branch_required_files.py # Audit a specific ref uv run python scripts/audit_branch_required_files.py --ref origin/tier2/foo # JSON output for CI integration uv run python scripts/audit_branch_required_files.py --json The script's REQUIRED_FILES list has 2 entries (the actual MCP regression targets), not 4. The 2 .opencode/agents/... files in conductor/tier2/githooks/forbidden-files.txt are tier-2 sandbox-only working tree files that are NEVER tracked in any branch (per commit fab2e55b 'undo sandbox file leaks'); they live only in the tier-2 clone's working tree, copied there by setup_tier2_clone.ps1. Exit codes: 0 - all required files present 1 - one or more required files missing (CI gate failure) 2 - usage error Verified: - HEAD: OK (files restored by user commits 71b51674 + cb1b0c1c) - master: OK (files exist on master) - 6956676f: FAIL (correctly detects the MCP regression commit) - --json output is valid JSON - --help shows clean usage CI integration (when the project gets CI): Add to .github/workflows/ci.yml (or equivalent): - name: Verify tier-2 required files run: uv run python scripts/audit_branch_required_files.py --strict Or as a per-PR check on tier-2 branches: - name: Verify required files on tier-2 PR if: startsWith(github.head_ref, 'tier2/') run: uv run python scripts/audit_branch_required_files.py --strict --- scripts/audit_branch_required_files.py | 150 +++++++++++++++++++++++++ 1 file changed, 150 insertions(+) create mode 100644 scripts/audit_branch_required_files.py diff --git a/scripts/audit_branch_required_files.py b/scripts/audit_branch_required_files.py new file mode 100644 index 00000000..558a69c6 --- /dev/null +++ b/scripts/audit_branch_required_files.py @@ -0,0 +1,150 @@ +#!/usr/bin/env python3 +"""Tier 2 required-files audit. + +Defense-in-depth check for the 2026-06-24 MCP regression: verifies that +the 2 MCP-config files (opencode.json + mcp_paths.toml) are present in +a tier-2 branch. If either is missing, the audit fails (exit 1) with +a clear diagnostic. + +Context: setup_tier2_clone.ps1 modifies opencode.json and mcp_paths.toml +IN the clone (C:\\projects\\manual_slop_tier2\\), and copies the tier-2 +agent prompt + slash command from conductor/tier2/ into .opencode/. +If a tier-2 commit accidentally captures any of these via `git add .`, +they leak into the main repo. The pre-commit hook +(conductor/tier2/githooks/pre-commit) auto-unstages them on commit +but does not prevent the deletions from appearing in commit history. + +This audit is a defense-in-depth check: it can be run on any branch +(typically a tier-2 branch) to verify the 2 required files are present. +Run it in pre-merge, in a CI workflow, or manually before merging a +tier-2 branch to master. + +Usage: + # Audit the current HEAD + uv run python scripts/audit_branch_required_files.py + + # Audit a specific ref (branch, commit, tag) + uv run python scripts/audit_branch_required_files.py --ref origin/tier2/phase2_4_5_call_site_completion_20260621 + + # JSON output for CI integration + uv run python scripts/audit_branch_required_files.py --json + + # Strict mode: exit 1 on any missing file (default; the script + # is informational by default but `--strict` is the CI-gate mode) + +Exit codes: + 0 - all required files present + 1 - one or more required files missing (CI gate failure) + 2 - usage error (bad args, git not available, ref not found) + +The 2 required files (the actual MCP regression target from 2026-06-24): + 1. opencode.json - the OpenCode config that setup_tier2_clone.ps1 overrides + 2. mcp_paths.toml - the MCP allowed paths that setup_tier2_clone.ps1 clears + +These are the 2 files that the 2026-06-24 MCP regression deleted from +the tier-2 branch's index. The pre-commit hook strips them from +tier-2 commits but does not prevent the deletion from being in the +commit's diff (the hook only unstages ADDITIONS). + +The other 2 entries in conductor/tier2/githooks/forbidden-files.txt +(.opencode/agents/tier2-autonomous.md and +.opencode/commands/tier-2-auto-execute.md) are tier-2 sandbox-only +working tree files that are NEVER tracked in any branch (per commit +fab2e55b "undo sandbox file leaks"). They live only in the tier-2 +clone's working tree, copied there by setup_tier2_clone.ps1 from +conductor/tier2/{agents,commands}/. They are not REQUIRED for the +audit. + +CI integration (when the project gets CI): + Add to .github/workflows/ci.yml (or equivalent): + - name: Verify tier-2 required files + run: uv run python scripts/audit_branch_required_files.py --strict + # The `--strict` flag is the default behavior; explicit for clarity. + + Or as a per-PR check on tier-2 branches: + - name: Verify required files on tier-2 PR + if: github.base_ref == 'master' && startsWith(github.head_ref, 'tier2/') + run: uv run python scripts/audit_branch_required_files.py --strict + +Note: this script does NOT modify the working tree. It is read-only. +""" +from __future__ import annotations +import argparse +import json +import subprocess +import sys +from pathlib import Path + + +REQUIRED_FILES: tuple[str, ...] = ( + "opencode.json", + "mcp_paths.toml", +) + + +def check_required_files(ref: str) -> list[str]: + missing: list[str] = [] + for required in REQUIRED_FILES: + result = subprocess.run( + ["git", "cat-file", "-e", f"{ref}:{required}"], + capture_output=True, + ) + if result.returncode != 0: + missing.append(required) + return missing + + +def main() -> int: + parser = argparse.ArgumentParser( + description="Verify tier-2 sandbox-required files are present on a branch.", + ) + parser.add_argument( + "--ref", + default="HEAD", + help="Git ref to check (default: HEAD). E.g. origin/tier2/phase2_4_5_call_site_completion_20260621", + ) + parser.add_argument( + "--json", + action="store_true", + help="Emit JSON output for CI integration.", + ) + parser.add_argument( + "--strict", + action="store_true", + default=True, + help="Exit 1 on any missing file (default; explicit for CI-gate clarity).", + ) + args = parser.parse_args() + + missing = check_required_files(args.ref) + + if args.json: + result = { + "ref": args.ref, + "required": list(REQUIRED_FILES), + "missing": missing, + "ok": len(missing) == 0, + } + print(json.dumps(result, indent=2)) + return 0 if result["ok"] else 1 + + if not missing: + print(f"OK: {args.ref} has all {len(REQUIRED_FILES)} required tier-2 files.") + for f in REQUIRED_FILES: + print(f" + {f}") + return 0 + + print(f"FAIL: {args.ref} is missing {len(missing)} required tier-2 file(s):", file=sys.stderr) + for f in missing: + print(f" - {f} (deleted or missing)", file=sys.stderr) + print("", file=sys.stderr) + print("This is a sandbox file leak. The 2026-06-24 MCP regression was caused", file=sys.stderr) + print("by `setup_tier2_clone.ps1` modifications to opencode.json + mcp_paths.toml", file=sys.stderr) + print("leaking into a tier-2 commit. To restore the missing files on this branch:", file=sys.stderr) + print(" git checkout master -- ", file=sys.stderr) + print(" git commit -m 'fix: restore (deleted by tier2 sandbox)'", file=sys.stderr) + return 1 + + +if __name__ == "__main__": + sys.exit(main())