diff --git a/code_puppy/plugins/paw_sign/__init__.py b/code_puppy/plugins/paw_sign/__init__.py new file mode 100644 index 000000000..60b8ad186 --- /dev/null +++ b/code_puppy/plugins/paw_sign/__init__.py @@ -0,0 +1,7 @@ +"""Paw-sign plugin. + +Appends a playful puppy-themed signature to commit messages that +code-puppy composes, so the audience knows the commit came from a very +good boy. Intercepts ``git commit -m`` shell commands via the +``pre_tool_call`` hook and tacks on an extra ``-m`` paragraph. +""" diff --git a/code_puppy/plugins/paw_sign/detector.py b/code_puppy/plugins/paw_sign/detector.py new file mode 100644 index 000000000..0b4f7189a --- /dev/null +++ b/code_puppy/plugins/paw_sign/detector.py @@ -0,0 +1,107 @@ +"""Detection + rewriting for paw-signing git commit commands. + +The single public entry point is :func:`paw_sign_command`. It is deliberately +conservative: it only rewrites a command when doing so is provably safe, and +returns ``None`` (leave it alone) for anything ambiguous. A corrupted +``git commit`` is far worse than a missing paw print. + +Safety strategy +--------------- +We use :mod:`shlex` purely to *analyse* the command (respecting quotes), never +to rebuild it. We only append when the ``git commit -m`` invocation is the +**last** command in the chain -- then a plain string append unambiguously +flows into that commit, preserving any shell substitutions, heredocs, or exotic +quoting in the original untouched. + +Examples that get signed:: + + git commit -m "fix the thing" + git add . && git commit -m "fix the thing" + git commit --message "wip" --amend + +Examples we leave alone (return ``None``):: + + git commit -m "wip" && git push # commit isn't last + git commit --amend # editor commit, no -m + git status # not a commit at all +""" + +import shlex +from typing import List, Optional + +# NOTE: the paw print is written as a unicode escape (U+1F43E) on purpose. +# This repo's emoji_filter strips literal emoji from file writes, which would +# silently eat the paw. The escape is plain ASCII in source and resolves to the +# real glyph at runtime. +PAW_SIGN = "[-- paw-signed: code-puppy \U0001f43e --]" + +# A stable substring (no emoji) used for idempotency -- survives even if the +# emoji has been stripped from a previously-signed command. +_ALREADY_SIGNED = "paw-signed: code-puppy" + +# Shell operators that separate one command from the next. +_OPERATORS = frozenset({"&&", "||", ";", "|", "&"}) + + +def _split_segments(tokens: List[str]) -> List[List[str]]: + """Split a flat token list into command segments on shell operators.""" + segments: List[List[str]] = [[]] + for tok in tokens: + if tok in _OPERATORS: + segments.append([]) + else: + segments[-1].append(tok) + return segments + + +def _is_commit_with_message(segment: List[str]) -> bool: + """Return True if a token segment is ``git commit`` carrying a message flag. + + Handles ``-m``, ``--message``, ``--message=...`` and combined short flags + such as ``-am`` / ``-sm``. Deliberately rejects editor-style commits + (``git commit`` / ``git commit --amend`` with no message flag). + """ + if len(segment) < 2 or segment[0] != "git" or segment[1] != "commit": + return False + for tok in segment[2:]: + if tok in ("-m", "--message") or tok.startswith("--message="): + return True + # Combined short flags like -am, -sm. Guard against long flags such as + # --amend, which contain an "m" but are not message flags. + if tok.startswith("-") and not tok.startswith("--") and "m" in tok: + return True + return False + + +def paw_sign_command(command: str) -> Optional[str]: + """Return ``command`` with the paw-sign appended, or ``None`` for no change. + + Args: + command: The shell command about to run. + + Returns: + The rewritten command string when it is a safe-to-sign ``git commit`` + whose message-bearing invocation is the last in the chain; otherwise + ``None`` (meaning: leave the original command untouched). + """ + # Cheap pre-filters before paying for shlex. + if not command or "commit" not in command: + return None + if _ALREADY_SIGNED in command: + return None + + try: + tokens = shlex.split(command) + except ValueError: + # Unbalanced quotes / un-parseable -- never risk mangling it. + return None + + if not tokens: + return None + + segments = _split_segments(tokens) + # Only safe to append at the end when the *last* segment is the commit. + if not _is_commit_with_message(segments[-1]): + return None + + return f"{command.rstrip()} -m {shlex.quote(PAW_SIGN)}" diff --git a/code_puppy/plugins/paw_sign/register_callbacks.py b/code_puppy/plugins/paw_sign/register_callbacks.py new file mode 100644 index 000000000..2939c0eed --- /dev/null +++ b/code_puppy/plugins/paw_sign/register_callbacks.py @@ -0,0 +1,54 @@ +"""Wire the paw-sign plugin into the runtime. + +Hooks ``pre_tool_call`` and mutates the ``agent_run_shell_command`` args in +place, appending a puppy signature to ``git commit -m`` messages composed by +code-puppy. Never blocks a tool call; failures are swallowed so a bad rewrite +can never crash the agent. +""" + +import logging +from typing import Any, Dict, Optional + +from code_puppy.callbacks import register_callback +from code_puppy.plugins.paw_sign.detector import paw_sign_command + +logger = logging.getLogger(__name__) + +_SHELL_TOOL = "agent_run_shell_command" + + +def _on_pre_tool_call( + tool_name: str, tool_args: Dict[str, Any], context: Any = None +) -> Optional[Dict[str, Any]]: + """Append the paw-sign to qualifying ``git commit`` shell commands. + + Mutates ``tool_args["command"]`` in place (the same pattern the emoji_filter + plugin uses). Returns ``None`` always -- this is a silent, non-blocking + rewrite. + """ + if tool_name != _SHELL_TOOL or not isinstance(tool_args, dict): + return None + + command = tool_args.get("command") + if not isinstance(command, str): + return None + + try: + signed = paw_sign_command(command) + except Exception as exc: # never break tool execution over a signature + logger.debug("paw_sign pre_tool_call failed: %s", exc) + return None + + if signed is not None: + tool_args["command"] = signed + + return None + + +def register() -> None: + """Register the paw-sign pre_tool_call callback.""" + register_callback("pre_tool_call", _on_pre_tool_call) + + +# Auto-register when this module is imported by the plugin loader. +register() diff --git a/code_puppy/plugins/paw_sign/test_detector.py b/code_puppy/plugins/paw_sign/test_detector.py new file mode 100644 index 000000000..b9beb12cd --- /dev/null +++ b/code_puppy/plugins/paw_sign/test_detector.py @@ -0,0 +1,116 @@ +"""Tests for the paw-sign command detector.""" + +from code_puppy.plugins.paw_sign.detector import ( + PAW_SIGN, + _is_commit_with_message, + _split_segments, + paw_sign_command, +) + + +# --- the happy path: commands that should get signed ------------------------ + + +def test_simple_commit_gets_signed(): + out = paw_sign_command('git commit -m "fix the thing"') + assert out == "git commit -m \"fix the thing\" -m '%s'" % PAW_SIGN + + +def test_add_then_commit_signs_the_commit(): + out = paw_sign_command('git add . && git commit -m "wip"') + assert out is not None + assert out.endswith("-m '%s'" % PAW_SIGN) + # original commit body untouched + assert 'git commit -m "wip"' in out + + +def test_long_message_flag(): + out = paw_sign_command('git commit --message "hello"') + assert out is not None + assert out.endswith("-m '%s'" % PAW_SIGN) + + +def test_message_equals_form(): + out = paw_sign_command('git commit --message="hello"') + assert out is not None + assert out.endswith("-m '%s'" % PAW_SIGN) + + +def test_combined_short_flags(): + out = paw_sign_command('git commit -am "quick"') + assert out is not None + assert out.endswith("-m '%s'" % PAW_SIGN) + + +def test_amend_with_message_is_signed(): + out = paw_sign_command('git commit --amend -m "redo"') + assert out is not None + assert out.endswith("-m '%s'" % PAW_SIGN) + + +def test_paw_sign_contains_emoji_glyph(): + # The escape really does resolve to the paw-print glyph at runtime. + assert "\U0001f43e" in PAW_SIGN + + +# --- the conservative path: commands we must leave alone -------------------- + + +def test_editor_commit_not_signed(): + assert paw_sign_command("git commit") is None + assert paw_sign_command("git commit --amend") is None + + +def test_commit_not_last_in_chain_not_signed(): + # Appending at the end would attach to `git push`, so we bail. + assert paw_sign_command('git commit -m "wip" && git push') is None + + +def test_non_commit_command_not_signed(): + assert paw_sign_command("git status") is None + assert paw_sign_command("echo hello") is None + assert paw_sign_command("ls -la") is None + + +def test_empty_or_none_not_signed(): + assert paw_sign_command("") is None + assert paw_sign_command(None) is None + + +def test_unbalanced_quotes_not_signed(): + assert paw_sign_command('git commit -m "unterminated') is None + + +def test_idempotent_already_signed(): + once = paw_sign_command('git commit -m "fix"') + assert once is not None + # Running it again must be a no-op (no double paw). + assert paw_sign_command(once) is None + + +def test_commit_word_in_string_not_misfired(): + # "commit" appears but it's not a git commit command. + assert paw_sign_command('echo "let us commit to this"') is None + + +def test_trailing_operator_not_signed(): + # A trailing ; leaves an empty last segment -> not safe to append. + assert paw_sign_command('git commit -m "wip" ;') is None + + +def test_signoff_no_message_not_signed(): + assert paw_sign_command("git commit -s") is None + + +# --- helper-level unit tests ------------------------------------------------ + + +def test_split_segments_basic(): + segs = _split_segments(["git", "add", ".", "&&", "git", "commit"]) + assert segs == [["git", "add", "."], ["git", "commit"]] + + +def test_is_commit_with_message_rejects_long_m_flags(): + # --amend contains an "m" but must NOT count as a message flag. + assert _is_commit_with_message(["git", "commit", "--amend"]) is False + assert _is_commit_with_message(["git", "commit", "-m", "x"]) is True