Private
Public Access
0
0

feat(tier2): add pre-commit hook + denylist config to block sandbox-only files

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)
This commit is contained in:
2026-06-20 01:45:34 -04:00
parent fab2e55b84
commit 81e1fd7b2c
3 changed files with 398 additions and 0 deletions
@@ -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
+96
View File
@@ -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
+264
View File
@@ -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) == []