From 319683eb4ae07679aa2dd70a08061ebb149c7651 Mon Sep 17 00:00:00 2001 From: Jammy2211 Date: Thu, 16 Jul 2026 22:04:02 +0100 Subject: [PATCH] Add the two Phase-5 refusal guards: pipefail env + Mind commit guard (#130) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Mitigations 1+2 of docs/agent_failure_modes.md, implemented. agent_bash_env.sh applies pipefail to bash -c invocations only (BASH_EXECUTION_STRING discriminator — the $0 approach was measured wrong inside BASH_ENV before shipping); mind_commit_guard.py is a PreToolUse refusal denying PyAutoMind commits without explicit file pathspecs or with directory pathspecs (the four-incidents-in-two-days shared-index sweep, converted from the memory note that failed three times). Fail-open, escape hatches, API-gate conventions. Wiring in the workspace .claude/settings.json is local config, applied separately. Co-Authored-By: Claude Fable 5 --- bin/agent_bash_env.sh | 16 ++++ bin/mind_commit_guard.py | 143 ++++++++++++++++++++++++++++++++ tests/test_mind_commit_guard.py | 79 ++++++++++++++++++ 3 files changed, 238 insertions(+) create mode 100644 bin/agent_bash_env.sh create mode 100644 bin/mind_commit_guard.py create mode 100644 tests/test_mind_commit_guard.py diff --git a/bin/agent_bash_env.sh b/bin/agent_bash_env.sh new file mode 100644 index 0000000..d3175ce --- /dev/null +++ b/bin/agent_bash_env.sh @@ -0,0 +1,16 @@ +# agent_bash_env.sh — sourced via BASH_ENV by every non-interactive bash the +# agent harness spawns (wired in the workspace .claude/settings.json `env`). +# +# docs/agent_failure_modes.md mitigation 1: `cmd | head; $?` read the pager's +# exit status three times in one campaign day — make the INSTRUMENT loud +# instead of the agent careful. pipefail applies ONLY to `bash -c` invocations +# (the agent's top-level tool commands); script files keep their authored +# semantics — organism scripts were written against default pipeline +# behaviour and must not change under this file. +# +# Discriminator: BASH_EXECUTION_STRING is set only for `bash -c`. ($0 cannot +# be used here — inside BASH_ENV it reads "bash" even for script invocations; +# measured before shipping, which is this file's own moral.) +if [ -n "${BASH_EXECUTION_STRING:-}" ]; then + set -o pipefail +fi diff --git a/bin/mind_commit_guard.py b/bin/mind_commit_guard.py new file mode 100644 index 0000000..44020da --- /dev/null +++ b/bin/mind_commit_guard.py @@ -0,0 +1,143 @@ +#!/usr/bin/env python3 +"""PreToolUse(Bash) gate: refuse unsafe commits in the shared PyAutoMind checkout. + +Motivation (docs/agent_failure_modes.md, mitigation 2 — the highest-frequency +error in the catalogue): the canonical PyAutoMind checkout is shared by every +concurrent session, and they stage into the SAME index. Four incidents in two +days (E1 + F1 x3, in both directions) came from commits that took more than +their own files: a bare ``git commit`` takes the whole index, and a +*directory* pathspec sweeps whatever a concurrent session staged under it. A +memory note about this trap, written the same morning, did not prevent three +of the four — this guard is that note, converted into a refusal. + +Rule: a ``git commit`` that targets the PyAutoMind checkout must name explicit +**file** pathspecs after ``--``. Denied otherwise: + + - no ``--`` section → the commit takes the shared index wholesale + - a pathspec after ``--`` resolves to a directory → directory pathspecs + sweep concurrent sessions' staged work under them + +Detection is textual + cheap filesystem checks; anything ambiguous is allowed +(fail-open — a guard that misfires trains bypass-by-default). Escape hatch: +``PYAUTO_SKIP_MIND_GUARD=1`` in the environment or as a prefix in the command +text (for the deliberate exceptional case, e.g. a bulk migration). +""" + +from __future__ import annotations + +import json +import os +import re +import shlex +import sys +from pathlib import Path + +MIND_MARKER = "PyAutoMind" + + +def _allow() -> None: + sys.exit(0) + + +def _deny(reason: str) -> None: + print( + json.dumps( + { + "hookSpecificOutput": { + "hookEventName": "PreToolUse", + "permissionDecision": "deny", + "permissionDecisionReason": reason, + } + } + ) + ) + sys.exit(0) + + +def _mind_root(command: str, cwd: str) -> Path | None: + """Best-effort resolution of the PyAutoMind checkout this command targets.""" + m = re.search(r"(?:-C\s+|cd\s+)(\S*PyAutoMind)\b", command) + if m: + return Path(os.path.expanduser(m.group(1))) + if cwd and MIND_MARKER in cwd: + p = Path(cwd) + while p.name != MIND_MARKER and p != p.parent: + p = p.parent + return p if p.name == MIND_MARKER else None + return None + + +def check_command(command: str, cwd: str = "") -> str | None: + """Return a denial reason, or None to allow.""" + if "PYAUTO_SKIP_MIND_GUARD=1" in command: + return None + if "git" not in command or "commit" not in command: + return None + # Only reason about commands that clearly target PyAutoMind. + if MIND_MARKER not in command and MIND_MARKER not in (cwd or ""): + return None + root = _mind_root(command, cwd) + + # Examine each `git ... commit ...` clause in the (possibly compound) command. + for clause in re.split(r"&&|\|\||;", command): + if not re.search(r"\bgit\b.*\bcommit\b", clause): + continue + if "--amend" in clause or "--dry-run" in clause: + continue + try: + tokens = shlex.split(clause) + except ValueError: + return None # unparseable — fail open + if "--" not in tokens: + return ( + "PyAutoMind is a SHARED checkout: concurrent sessions stage into " + "the same index, and a commit without explicit `-- ` takes " + "all of it (this swept other sessions' work 4x in 2 days). " + "Re-run as: git commit -m '' -- [ ...] " + "(files, not directories). Deliberate exception: prefix " + "PYAUTO_SKIP_MIND_GUARD=1." + ) + pathspecs = tokens[tokens.index("--") + 1 :] + if not pathspecs: + return ( + "PyAutoMind commit has an empty `--` pathspec list — name the " + "exact files to commit (the shared index carries other sessions' " + "staged work)." + ) + if root and root.is_dir(): + for spec in pathspecs: + target = (root / spec) if not os.path.isabs(spec) else Path(spec) + if target.is_dir(): + return ( + f"PyAutoMind commit pathspec '{spec}' is a DIRECTORY — " + "directory pathspecs sweep whatever concurrent sessions " + "staged under them (this exact shape hit twice on " + "2026-07-16). List the individual files. Deliberate " + "exception: prefix PYAUTO_SKIP_MIND_GUARD=1." + ) + return None + + +def main() -> None: + raw = sys.stdin.read() + if os.environ.get("PYAUTO_SKIP_MIND_GUARD") == "1": + _allow() + try: + payload = json.loads(raw) + except (json.JSONDecodeError, ValueError): + _allow() + if payload.get("tool_name") != "Bash": + _allow() + command = (payload.get("tool_input") or {}).get("command") or "" + cwd = payload.get("cwd") or "" + try: + reason = check_command(command, cwd) + except Exception: + _allow() # the guard must never block on its own bugs + if reason: + _deny(reason) + _allow() + + +if __name__ == "__main__": + main() diff --git a/tests/test_mind_commit_guard.py b/tests/test_mind_commit_guard.py new file mode 100644 index 0000000..c52bb61 --- /dev/null +++ b/tests/test_mind_commit_guard.py @@ -0,0 +1,79 @@ +"""tests/test_mind_commit_guard.py — the shared-Mind commit refusal. + +docs/agent_failure_modes.md mitigation 2: E1 + F1 (x3) — four swept-index +incidents in two days. The guard refuses commits in the shared PyAutoMind +checkout that lack explicit file pathspecs, or that pass directories. +""" + +from __future__ import annotations + +import sys +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "bin")) +from mind_commit_guard import check_command # noqa: E402 + +MIND = "/home/jammy/Code/PyAutoLabs/PyAutoMind" + + +def test_bare_commit_in_mind_denied(): + r = check_command(f'cd {MIND} && git commit -m "msg"') + assert r and "explicit `-- `" in r + + +def test_commit_dash_c_without_pathspecs_denied(): + r = check_command(f'git -C {MIND} commit -m "msg"') + assert r is not None + + +def test_commit_with_file_pathspecs_allowed(tmp_path): + mind = tmp_path / "PyAutoMind" + mind.mkdir() + (mind / "active.md").write_text("x") + r = check_command(f'git -C {mind} commit -m "msg" -- active.md') + assert r is None + + +def test_commit_with_directory_pathspec_denied(tmp_path): + mind = tmp_path / "PyAutoMind" + (mind / "active").mkdir(parents=True) + r = check_command(f'git -C {mind} commit -m "msg" -- active/') + assert r and "DIRECTORY" in r + + +def test_non_mind_commit_allowed(): + assert check_command('git -C /home/x/PyAutoFit commit -m "msg"') is None + + +def test_non_commit_mind_command_allowed(): + assert check_command(f"git -C {MIND} status") is None + + +def test_escape_hatch_allows(): + assert ( + check_command(f'PYAUTO_SKIP_MIND_GUARD=1 git -C {MIND} commit -m "bulk"') + is None + ) + + +def test_cwd_detection(tmp_path): + mind = tmp_path / "PyAutoMind" + mind.mkdir() + r = check_command('git commit -m "msg"', cwd=str(mind)) + assert r is not None + + +def test_compound_command_second_clause_checked(tmp_path): + mind = tmp_path / "PyAutoMind" + mind.mkdir() + (mind / "planned.md").write_text("x") + ok = f'cd {mind} && git add planned.md && git commit -m "m" -- planned.md' + assert check_command(ok) is None + bad = f'cd {mind} && git add -A && git commit -m "m"' + assert check_command(bad) is not None + + +def test_amend_and_dry_run_are_exempt(tmp_path): + mind = tmp_path / "PyAutoMind" + mind.mkdir() + assert check_command(f'git -C {mind} commit --amend --no-edit') is None