diff --git a/bin/mind_commit_guard.py b/bin/mind_commit_guard.py index 44020da..1da7ee4 100644 --- a/bin/mind_commit_guard.py +++ b/bin/mind_commit_guard.py @@ -67,6 +67,33 @@ def _mind_root(command: str, cwd: str) -> Path | None: return None +def _clauses(command: str): + """Token-level clause split that respects quoting (v1.1). + + v1.0 split clauses with a regex over the raw text, which cut inside + quoted commit messages and heredoc bodies — its first live firing was a + false positive on its own author's closeout command, minutes after + deployment. shlex with punctuation_chars keeps `&&`/`;`/`|` as tokens + while quoted strings (commit messages, gh comment bodies) stay single + tokens that cannot leak trigger words or swallow the `--`. + """ + lex = shlex.shlex(command, posix=True, punctuation_chars=";&|") + lex.whitespace_split = True + clause: list[str] = [] + try: + for tok in lex: + if tok and set(tok) <= set(";&|"): + if clause: + yield clause + clause = [] + else: + clause.append(tok) + except ValueError: + return # unparseable (heredocs, unbalanced quotes) — fail open + if clause: + yield clause + + 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: @@ -78,16 +105,14 @@ def check_command(command: str, cwd: str = "") -> str | None: 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): + # Examine each clause of the (possibly compound) command at token level. + for tokens in _clauses(command): + if "git" not in tokens or "commit" not in tokens: + continue + if tokens.index("git") > tokens.index("commit"): continue - if "--amend" in clause or "--dry-run" in clause: + if "--amend" in tokens or "--dry-run" in tokens: 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 " diff --git a/tests/test_mind_commit_guard.py b/tests/test_mind_commit_guard.py index c52bb61..703e825 100644 --- a/tests/test_mind_commit_guard.py +++ b/tests/test_mind_commit_guard.py @@ -77,3 +77,37 @@ 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 + + +# --- v1.1: the two live false positives from the guard's first hour ------------ + +def test_gh_comment_prose_does_not_trigger(): + # First live firing: a `gh issue comment` whose BODY prose mentioned the + # trigger words. Quoted bodies are single tokens; no git-commit clause. + cmd = ( + 'gh issue comment 130 --repo X --body "the Mind commit guard denies ' + 'bare commits in the shared PyAutoMind checkout via git" ' + "&& echo done" + ) + assert check_command(cmd) is None + + +def test_semicolon_inside_commit_message_keeps_pathspecs(tmp_path): + # Second live firing: a `;` INSIDE the quoted -m message made v1.0's raw + # regex split strand the `--` in the next pseudo-clause. + mind = tmp_path / "PyAutoMind" + mind.mkdir() + (mind / "active.md").write_text("x") + cmd = ( + f'cd {mind} && git pull --ff-only -q && ' + f'git commit -q -m "prompt: task complete; all phases complete" ' + f"-- active.md && git push -q origin main" + ) + assert check_command(cmd) is None + + +def test_bare_commit_still_denied_in_compound_with_quotes(tmp_path): + mind = tmp_path / "PyAutoMind" + mind.mkdir() + cmd = f'cd {mind} && git commit -q -m "msg; with semicolon" && echo ok' + assert check_command(cmd) is not None