From 81e1fd7b2ceaa0323eef2d4d801a22331502263c Mon Sep 17 00:00:00 2001 From: Ed_ Date: Sat, 20 Jun 2026 01:45:34 -0400 Subject: [PATCH] feat(tier2): add pre-commit hook + denylist config to block sandbox-only files MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a tier-2 pre-commit hook that auto-unstages sandbox-only files from any tier-2 commit, preventing the leak that hit master in 00e5a3f2 (the offender commit that was just selectively reverted in fab2e55b). The hook is paired with a config file that lists the forbidden paths as substring patterns. Design: - Hook reads conductor/tier2/githooks/forbidden-files.txt (one substring pattern per line; # comments and blanks ignored) - For each staged file, checks if any pattern is a substring of the path. If a match is found, the file is auto-unstaged via `git rm --cached --force` (force is required when the index has content that differs from BOTH HEAD and the working tree) - Hook always exits 0 — it removes the leak rather than blocking the commit. A hard reject would leave tier-2 stuck mid-flow (tier-2 cannot run `git restore --staged`, which is banned by the sandbox permission rules) - The hook's config file lives at the project root so it ships with the clone. setup_tier2_clone.ps1 will install the hook in a follow-up commit; existing clones need to re-run setup to get the hook Forbidden patterns (substring matches): - .opencode/agents/tier2-autonomous (sandbox agent prompt) - .opencode/commands/tier-2-auto-execute (sandbox slash command) - opencode.json (MCP path / default_agent / model override) - mcp_paths.toml (extra_dirs cleared in clone) Patterns are SPECIFIC (not prefix-based) so they do not match the legitimate interactive tier-2 tech-lead prompt at .opencode/agents/tier2-tech-lead.md. Tests (tests/test_tier2_pre_commit_hook.py, 12 cases): - Empty staged set: git's standard "nothing to commit" error - Allowed files: commit succeeds normally - Each forbidden file (agent, command, opencode.json, mcp_paths.toml) staged: auto-unstaged, commit proceeds - Mixed staged set: only forbidden are unstaged - Hook silent when no leaks detected - Hook warns (stderr) when unstaging - Config-driven: replacing forbidden-files.txt changes the denylist without modifying the hook - Paths with spaces: handled correctly via git diff -z Defense-in-depth context: - Layer 1: OpenCode permission system (denies direct edits to these files from the tier2-autonomous agent) - Layer 2 (this commit): pre-commit hook (removes the leak at the commit boundary) - Layer 3 (follow-up commit): scripts/audit_tier2_leaks.py (scans working tree, CI gate) --- conductor/tier2/githooks/forbidden-files.txt | 38 +++ conductor/tier2/githooks/pre-commit | 96 +++++++ tests/test_tier2_pre_commit_hook.py | 264 +++++++++++++++++++ 3 files changed, 398 insertions(+) create mode 100644 conductor/tier2/githooks/forbidden-files.txt create mode 100644 conductor/tier2/githooks/pre-commit create mode 100644 tests/test_tier2_pre_commit_hook.py diff --git a/conductor/tier2/githooks/forbidden-files.txt b/conductor/tier2/githooks/forbidden-files.txt new file mode 100644 index 00000000..f8f03c8e --- /dev/null +++ b/conductor/tier2/githooks/forbidden-files.txt @@ -0,0 +1,38 @@ +# Tier 2 autonomous mode: file denylist for pre-commit hook. +# +# One pattern per line. Each pattern is matched as a substring against +# the staged file's relative path. Lines starting with `#` and blank +# lines are ignored. +# +# These files are tier-2 sandbox-specific: +# - setup_tier2_clone.ps1 modifies opencode.json and mcp_paths.toml +# IN the clone (points MCP server at the clone, clears extra_dirs) +# - The .opencode/agents/tier2-autonomous.md and +# .opencode/commands/tier-2-auto-execute.md files are copied from +# conductor/tier2/agents/ and conductor/tier2/commands/ into the +# clone by setup_tier2_clone.ps1 +# +# If any of these end up in a tier-2 commit (via accidental `git add .`), +# the main repo would absorb the sandbox's local config drift. +# +# PATTERN SCOPE: the patterns below are SPECIFIC (not prefix-based) so +# they do not match the interactive Tier 2 agent prompt at +# .opencode/agents/tier2-tech-lead.md (which legitimately lives in the +# main repo). Edit this file when adding new tier-2 sandbox-specific +# paths. + +# Tier-2 autonomous agent prompt (only in clone, canonical source: +# conductor/tier2/agents/tier2-autonomous.md) +.opencode/agents/tier2-autonomous + +# Tier-2 autonomous slash command (only in clone, canonical source: +# conductor/tier2/commands/tier-2-auto-execute.md) +.opencode/commands/tier-2-auto-execute + +# OpenCode config: setup_tier2_clone.ps1 overrides MCP server path + +# default_agent + model in the clone's copy of this file +opencode.json + +# MCP allowed paths: setup_tier2_clone.ps1 clears extra_dirs in the +# clone's copy of this file +mcp_paths.toml \ No newline at end of file diff --git a/conductor/tier2/githooks/pre-commit b/conductor/tier2/githooks/pre-commit new file mode 100644 index 00000000..5a943dfb --- /dev/null +++ b/conductor/tier2/githooks/pre-commit @@ -0,0 +1,96 @@ +#!/bin/sh +# Tier 2 autonomous mode: prevent sandbox-only file leaks. +# +# 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 captures any of these via `git add .`, the main repo +# would absorb the sandbox's local config drift. +# +# This hook runs on `git commit` in the tier-2 clone. It reads the +# denylist from conductor/tier2/githooks/forbidden-files.txt and +# auto-unstages any staged file whose path contains a forbidden +# substring. The commit then proceeds with only the legitimate work. +# +# Layer 1 (OpenCode permission system) blocks the tier-2 agent from +# editing these files directly. This hook is the backup layer at the +# commit boundary. Layer 3 is the audit script +# scripts/audit_tier2_leaks.py in the main repo. +# +# Why auto-unstage instead of exit 1: tier-2 cannot run `git restore +# --staged` (banned by the sandbox permission rules), so a hard reject +# would leave the agent stuck mid-flow. Auto-unstage + warn is the +# recoverable behavior. +# +# Why exit 0 always: the hook must never block the agent. Its job is to +# remove the leak, not to gate the commit. The failcount machinery in +# scripts/tier2/failcount.py tracks repeated red-phase failures and +# gives up the run; adding a hook-induced exit 1 would pollute that +# signal. + +CONFIG="conductor/tier2/githooks/forbidden-files.txt" + +if [ ! -f "$CONFIG" ]; then + exit 0 +fi + +# POSIX shells cannot store NUL bytes in variables (command substitution +# strips them). So we cannot do `STAGED=$(git diff -z)` and iterate. +# Instead, pipe `git diff -z` into a `while read -d ''` loop in a +# subshell, and write leaked paths to a temp file. The parent shell then +# reads the temp file and unstages via `git rm --cached`. +TMPFILE="./.tier2_leaked_$$" +trap 'rm -f "$TMPFILE" 2>/dev/null' EXIT + +# Check if any staged file matches any forbidden substring. +# Pattern matching strategy: for each staged file, iterate the config +# file's non-comment, non-blank lines. Each pattern is a substring to +# look for in the file path. `case "$f" in *"$pattern"*)` is faster +# than spawning `grep` per file. +# +# CRITICAL: the config file may have CRLF line endings (the test writes +# it via Python's text mode on Windows). Strip trailing \r from each +# pattern before matching, otherwise `*pattern*` will not match a +# clean path because the pattern contains a stray carriage return. +git diff --cached --name-only -z | while IFS= read -r -d '' f; do + [ -z "$f" ] && continue + while IFS= read -r pattern || [ -n "$pattern" ]; do + # Strip trailing \r (CRLF line endings on Windows) + pattern=$(printf '%s' "$pattern" | tr -d '\r') + case "$pattern" in + ''|'#'*) continue ;; + esac + case "$f" in + *"$pattern"*) + printf '%s\n' "$f" >> "$TMPFILE" + break + ;; + esac + done < "$CONFIG" +done + +if [ ! -s "$TMPFILE" ]; then + exit 0 +fi + +echo "Tier 2: removing sandbox-only files from staging" >&2 +echo "(these files belong in the main repo, not in tier-2 commits):" >&2 +while IFS= read -r f; do + [ -z "$f" ] && continue + echo " - $f" >&2 + # `git rm --cached` works on tracked files (unstages modifications) + # AND on newly-added files (unstages the addition, file becomes + # untracked again). NOT `git restore` (banned in sandbox). + # + # `--force` is required when the index has content that differs from + # BOTH HEAD and the working tree (e.g., the file was modified, + # staged, then modified again in the working tree). Without + # --force, git refuses to discard the staged content. + git rm --cached --quiet --force "$f" 2>/dev/null || true +done < "$TMPFILE" + +echo "" >&2 +echo "Commit will proceed without these files. To inspect what was" >&2 +echo "removed, run: git status" >&2 + +exit 0 \ No newline at end of file diff --git a/tests/test_tier2_pre_commit_hook.py b/tests/test_tier2_pre_commit_hook.py new file mode 100644 index 00000000..9a30359b --- /dev/null +++ b/tests/test_tier2_pre_commit_hook.py @@ -0,0 +1,264 @@ +"""Tests for the Tier 2 pre-commit hook that prevents sandbox file leaks. + +Background: setup_tier2_clone.ps1 modifies opencode.json and +mcp_paths.toml IN the clone (pointing them at the clone's MCP server +and clearing extra_dirs). If a tier-2 commit captures these +modifications via `git add .`, they leak into the main repo. + +The pre-commit hook in conductor/tier2/githooks/pre-commit detects +these files (and other tier-2-only paths) in the staged set and +auto-unstages them via `git rm --cached` so the commit only contains +legitimate work. The hook reads its denylist from +conductor/tier2/githooks/forbidden-files.txt (one substring pattern +per line; `#` starts a comment, blank lines are ignored). + +These tests create a temporary git repo, install the hook + config, +stage various files, and verify the hook: +- Allows commits that contain only allowed files +- Auto-unstages tier-2 sandbox files when staged (does NOT block + the commit, so tier-2 isn't stuck mid-flow) +- Always exits 0 (per design) +""" +import os +import re +import subprocess +from pathlib import Path + +import pytest + +HOOK_SOURCE = Path("conductor/tier2/githooks/pre-commit").resolve() +CONFIG_SOURCE = Path("conductor/tier2/githooks/forbidden-files.txt").resolve() + + +@pytest.fixture +def fake_clone(tmp_path: Path) -> Path: + """Create a temporary git repo with the pre-commit hook + config installed.""" + clone = tmp_path / "fake_clone" + clone.mkdir() + clone_str = str(clone) + subprocess.run(["git", "init"], cwd=clone_str, check=True, capture_output=True) + subprocess.run(["git", "config", "user.email", "test@test"], cwd=clone_str, check=True) + subprocess.run(["git", "config", "user.name", "Test"], cwd=clone_str, check=True) + # Mirror the repo layout: conductor/tier2/githooks/ inside the clone + # (so the hook can find its config relative to the project root). + config_dir = clone / "conductor" / "tier2" / "githooks" + config_dir.mkdir(parents=True) + config_dir.joinpath("forbidden-files.txt").write_bytes(CONFIG_SOURCE.read_bytes()) + # Commit the config as part of the initial state. This prevents + # `git add -A` from accidentally re-staging the config in every test. + subprocess.run(["git", "add", "-A"], cwd=clone_str, check=True) + subprocess.run(["git", "commit", "-m", "init config"], cwd=clone_str, check=True) + hooks_dir = clone / ".git" / "hooks" + hooks_dir.mkdir(parents=True, exist_ok=True) + hooks_dir.joinpath("pre-commit").write_bytes(HOOK_SOURCE.read_bytes()) + os.chmod(hooks_dir / "pre-commit", 0o755) + return clone + + +def _run(cwd: Path, *args: str) -> subprocess.CompletedProcess: + return subprocess.run( + list(args), + cwd=str(cwd), + capture_output=True, + text=True, + ) + + +def _staged_files(clone: Path) -> list[str]: + """Return the list of files currently in the index.""" + result = _run(clone, "git", "diff", "--cached", "--name-only") + return [line for line in result.stdout.splitlines() if line] + + +def _commit(clone: Path, message: str = "test") -> subprocess.CompletedProcess: + return _run(clone, "git", "commit", "-m", message) + + +def test_hook_allows_commits_with_no_staged_files(fake_clone: Path) -> None: + """Empty staged set: git refuses the commit, hook does not interfere.""" + # All files in the clone are now committed. Staging nothing should + # produce git's standard "nothing to commit" error. + result = _commit(fake_clone, "empty") + assert result.returncode != 0, "commit with no staged files should fail" + combined = (result.stdout + result.stderr).lower() + assert "nothing to commit" in combined or "nothing added to commit" in combined, ( + f"expected git's standard nothing-to-commit error, got stdout={result.stdout!r} stderr={result.stderr!r}" + ) + + +def test_hook_allows_allowed_files(fake_clone: Path) -> None: + """Files NOT in the denylist commit normally.""" + (fake_clone / "src.py").write_text("print('hi')\n") + _run(fake_clone, "git", "add", "src.py") + staged_before = _staged_files(fake_clone) + assert staged_before == ["src.py"] + result = _commit(fake_clone, "add src") + assert result.returncode == 0, f"commit failed: {result.stderr}" + assert _staged_files(fake_clone) == [], "staged set should be empty after commit" + + +def test_hook_unstages_forbidden_opencode_agent_file(fake_clone: Path) -> None: + """A staged .opencode/agents/tier2-*.md is auto-unstaged; commit proceeds without it.""" + opencode_dir = fake_clone / ".opencode" / "agents" + opencode_dir.mkdir(parents=True) + forbidden = opencode_dir / "tier2-autonomous.md" + forbidden.write_text("# fake tier-2 agent\n") + _run(fake_clone, "git", "add", ".opencode/agents/tier2-autonomous.md") + assert _staged_files(fake_clone) == [".opencode/agents/tier2-autonomous.md"] + result = _commit(fake_clone, "leak attempt") + # Hook must NOT block the commit (exit 0); commit succeeds with empty diff + assert result.returncode == 0, f"hook unexpectedly blocked commit: {result.stderr}" + # File must have been unstaged + assert _staged_files(fake_clone) == [], "forbidden file was not auto-unstaged" + # Working tree still has the modification (hook only unstaged) + assert forbidden.exists(), "hook should not delete the file from working tree" + + +def test_hook_unstages_forbidden_opencode_command_file(fake_clone: Path) -> None: + """A staged .opencode/commands/tier-2-*.md is auto-unstaged.""" + cmd_dir = fake_clone / ".opencode" / "commands" + cmd_dir.mkdir(parents=True) + forbidden = cmd_dir / "tier-2-auto-execute.md" + forbidden.write_text("# fake tier-2 command\n") + _run(fake_clone, "git", "add", ".opencode/commands/tier-2-auto-execute.md") + result = _commit(fake_clone, "leak attempt") + assert result.returncode == 0, f"hook blocked commit: {result.stderr}" + assert _staged_files(fake_clone) == [] + + +def test_hook_unstages_modified_opencode_json(fake_clone: Path) -> None: + """opencode.json is forbidden even when modified (the setup script modifies it locally).""" + opencode_json = fake_clone / "opencode.json" + opencode_json.write_text('{"version": 1}\n') + _run(fake_clone, "git", "add", "opencode.json") + _run(fake_clone, "git", "commit", "-m", "add opencode.json") + # Modify it (simulating the setup script's MCP path override) + opencode_json.write_text('{"version": 1, "tier2-modified": true}\n') + _run(fake_clone, "git", "add", "opencode.json") + result = _commit(fake_clone, "leak attempt") + assert result.returncode == 0, f"hook blocked commit: {result.stderr}" + assert _staged_files(fake_clone) == [] + + +def test_hook_unstages_modified_mcp_paths_toml(fake_clone: Path) -> None: + """mcp_paths.toml is forbidden even when modified.""" + mcp_paths = fake_clone / "mcp_paths.toml" + mcp_paths.write_text('[allowed_paths]\nextra_dirs = []\n') + _run(fake_clone, "git", "add", "mcp_paths.toml") + _run(fake_clone, "git", "commit", "-m", "add mcp_paths.toml") + mcp_paths.write_text('[allowed_paths]\nextra_dirs = ["leaked"]\n') + _run(fake_clone, "git", "add", "mcp_paths.toml") + result = _commit(fake_clone, "leak attempt") + assert result.returncode == 0, f"hook blocked commit: {result.stderr}" + assert _staged_files(fake_clone) == [] + + +def test_hook_unstages_all_forbidden_files_at_once(fake_clone: Path) -> None: + """Multiple forbidden files staged: all are unstaged in one pass.""" + (fake_clone / ".opencode" / "agents").mkdir(parents=True) + (fake_clone / ".opencode" / "commands").mkdir(parents=True) + (fake_clone / ".opencode" / "agents" / "tier2-autonomous.md").write_text("a\n") + (fake_clone / ".opencode" / "commands" / "tier-2-auto-execute.md").write_text("b\n") + (fake_clone / "opencode.json").write_text("c\n") + (fake_clone / "mcp_paths.toml").write_text("d\n") + # Stage each explicitly so we know exactly what the hook sees + _run(fake_clone, "git", "add", + ".opencode/agents/tier2-autonomous.md", + ".opencode/commands/tier-2-auto-execute.md", + "opencode.json", + "mcp_paths.toml") + staged = sorted(_staged_files(fake_clone)) + assert len(staged) == 4, f"setup failed; staged={staged}" + result = _commit(fake_clone, "multi-leak") + assert result.returncode == 0, f"hook blocked commit: {result.stderr}" + assert _staged_files(fake_clone) == [] + + +def test_hook_keeps_allowed_files_alongside_forbidden(fake_clone: Path) -> None: + """Mixed staged set: forbidden unstaged, allowed committed normally.""" + (fake_clone / ".opencode" / "agents").mkdir(parents=True) + (fake_clone / ".opencode" / "agents" / "tier2-autonomous.md").write_text("leak\n") + (fake_clone / "legit.py").write_text("print('legit work')\n") + _run(fake_clone, "git", "add", + ".opencode/agents/tier2-autonomous.md", "legit.py") + result = _commit(fake_clone, "mixed") + assert result.returncode == 0, f"hook blocked commit: {result.stderr}" + # Allowed file should be in HEAD + head_files = _run(fake_clone, "git", "ls-tree", "--name-only", "HEAD").stdout.split() + assert "legit.py" in head_files, f"legit.py missing from HEAD: {head_files}" + assert ".opencode/agents/tier2-autonomous.md" not in head_files, ( + f"forbidden file leaked into HEAD: {head_files}" + ) + # Forbidden file should be unstaged but still on disk + assert _staged_files(fake_clone) == [] + assert (fake_clone / ".opencode" / "agents" / "tier2-autonomous.md").exists() + + +def test_hook_silent_when_no_forbidden_files(fake_clone: Path) -> None: + """Hook prints nothing to stderr/stdout when nothing is forbidden.""" + (fake_clone / "clean.py").write_text("x = 1\n") + _run(fake_clone, "git", "add", "clean.py") + result = _commit(fake_clone, "clean") + assert result.returncode == 0, f"commit failed: {result.stderr}" + # The hook's warning text must NOT appear when no leaks were detected. + combined = (result.stdout + result.stderr).lower() + assert "removing" not in combined, ( + f"hook printed warning despite no leak: stdout={result.stdout!r} stderr={result.stderr!r}" + ) + + +def test_hook_warns_when_unstaging(fake_clone: Path) -> None: + """Hook prints a clear warning when it unstages a forbidden file.""" + (fake_clone / ".opencode" / "agents").mkdir(parents=True) + (fake_clone / ".opencode" / "agents" / "tier2-autonomous.md").write_text("leak\n") + _run(fake_clone, "git", "add", ".opencode/agents/tier2-autonomous.md") + result = _commit(fake_clone, "leak") + assert result.returncode == 0 + # Hook output should mention the leak (so tier-2 sees what happened) + combined = (result.stdout + result.stderr).lower() + assert re.search(r"tier.?2|removing|sandbox", combined), ( + f"expected warning text in commit output, got: stdout={result.stdout!r} stderr={result.stderr!r}" + ) + # And it should mention the specific file + assert "tier2-autonomous" in combined, ( + f"expected filename in warning, got: stdout={result.stdout!r} stderr={result.stderr!r}" + ) + + +def test_hook_uses_config_from_project_root(fake_clone: Path) -> None: + """Hook reads forbidden-files.txt from conductor/tier2/githooks/ in the project root. + Replacing the config changes the hook's denylist without modifying the hook itself. + """ + custom = fake_clone / "conductor" / "tier2" / "githooks" / "forbidden-files.txt" + custom.write_text("custom_forbidden.txt\n") + (fake_clone / "custom_forbidden.txt").write_text("leak\n") + # opencode.json is NOT in the custom config — it should NOT be unstaged. + (fake_clone / "opencode.json").write_text('{"version": 1}\n') + _run(fake_clone, "git", "add", + "custom_forbidden.txt", "opencode.json") + result = _commit(fake_clone, "mixed") + assert result.returncode == 0, f"hook blocked commit: {result.stderr}" + # Check HEAD (committed tree), not staged (empty after successful commit). + head_files = _run(fake_clone, "git", "ls-tree", "--name-only", "HEAD").stdout.split() + # custom_forbidden.txt must NOT be in HEAD (unstaged by hook) + assert "custom_forbidden.txt" not in head_files, ( + f"custom_forbidden.txt leaked into HEAD: {head_files}" + ) + # opencode.json MUST be in HEAD (not in custom config, so hook left it alone) + assert "opencode.json" in head_files, ( + f"opencode.json missing from HEAD (hook over-unstaged): {head_files}" + ) + + +def test_hook_handles_paths_with_spaces(fake_clone: Path) -> None: + """A forbidden file whose path contains spaces is still detected and unstaged.""" + (fake_clone / ".opencode" / "agents").mkdir(parents=True) + weird = fake_clone / ".opencode" / "agents" / "tier2 my agent.md" + weird.write_text("x\n") + # Add with quoting so git stores the path with spaces + _run(fake_clone, "git", "add", ".opencode/agents/tier2 my agent.md") + staged = _staged_files(fake_clone) + assert staged == [".opencode/agents/tier2 my agent.md"], f"setup failed: {staged}" + result = _commit(fake_clone, "spaces") + assert result.returncode == 0, f"hook blocked commit: {result.stderr}" + assert _staged_files(fake_clone) == [] \ No newline at end of file