diff --git a/.claude-plugin/plugin.json b/.claude-plugin/plugin.json index 8703749..318244d 100644 --- a/.claude-plugin/plugin.json +++ b/.claude-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "agentsmd", - "version": "17.4.1", + "version": "17.4.2", "description": "Portable agent rules, Project Direction, Delivery System profiles, owned workflow Skills, and deterministic version control.", "author": { "name": "lukaszmaj" diff --git a/.codex-plugin/plugin.json b/.codex-plugin/plugin.json index 93962d8..aaad0b3 100644 --- a/.codex-plugin/plugin.json +++ b/.codex-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "agentsmd", - "version": "17.4.1", + "version": "17.4.2", "description": "Portable agent rules, Project Direction, Delivery System profiles, owned workflow Skills, and deterministic version control.", "author": { "name": "lukaszmaj", diff --git a/.grok-plugin/plugin.json b/.grok-plugin/plugin.json index af9bbc7..1f94bd5 100644 --- a/.grok-plugin/plugin.json +++ b/.grok-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "agentsmd", - "version": "17.4.1", + "version": "17.4.2", "description": "Portable agent rules, Project Direction, Delivery System profiles, owned workflow Skills, and deterministic version control.", "author": { "name": "lukaszmaj" diff --git a/.muse-plugin/plugin.json b/.muse-plugin/plugin.json index 778b060..31bd368 100644 --- a/.muse-plugin/plugin.json +++ b/.muse-plugin/plugin.json @@ -2,7 +2,7 @@ "schemaVersion": 1, "name": "agentsmd", "displayName": "AgentsMD", - "version": "17.4.1", + "version": "17.4.2", "description": "Portable agent rules, Project Direction, Delivery System profiles, owned workflow Skills, and deterministic version control.", "compat": { "source": "native", diff --git a/CHANGELOG.md b/CHANGELOG.md index 81bf767..b67b3d0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,12 @@ All completed repository versions are recorded here. Release entries follow SemVer and identify one user-consumable repository state. +## [17.4.2] - 2026-10-19 + +### Changed + +- Fix Elon gate false positives for gh create help while preserving shell comment boundaries (#247) + ## [17.4.1] - 2026-10-19 ### Changed diff --git a/VERSION b/VERSION index ae704d6..d68ad08 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -17.4.1 +17.4.2 diff --git a/bin/project-direction b/bin/project-direction index 8ef7796..f36d614 100755 --- a/bin/project-direction +++ b/bin/project-direction @@ -713,7 +713,7 @@ def shell_segments(command: str) -> list[tuple[str, str | None, bool]]: """Split a shell command into simple commands, each with its heredoc text and whether a pipe feeds it from the previous one. - Quotes, escapes and heredocs are respected; `&&`, `||`, `;`, `|` and + Quotes, escapes, comments and heredocs are respected; `&&`, `||`, `;`, `|` and unquoted newlines end a segment. A heredoc belongs to the segment that declares it, even when later segments share its line. As in the shell, only the last heredoc on stdin reaches the command; `3< list[tuple[str, str | None, bool]]: index += 1 continue if char == "\\" and index + 1 < length: - current.append(command[index:index + 2]) + # Bash removes an unquoted backslash-LF before recognizing words + # and comments. CRLF is not a continuation. + if command[index + 1] != "\n": + current.append(command[index:index + 2]) index += 2 continue - if command.startswith("<<", index) and not command.startswith("<<<", index): + if char == "#" and (not current or current[-1].isspace()): + # Only an unquoted word starting with # opens a shell comment. + # Leave its newline for segment and pending-heredoc handling. + newline = command.find("\n", index) + index = length if newline < 0 else newline + continue + if command.startswith("<<<", index): + # Consume the whole here-string operator, not a heredoc at its tail. + current.append("<<<") + index += 3 + continue + if command.startswith("<<", index): match = re.match(r"<<(-?)\s*(['\"]?)([A-Za-z0-9_]+)\2", command[index:]) if match: # Digits that start a word before `<<` name the fd it feeds. @@ -887,16 +901,44 @@ def option_value(words: list[str], index: int, long: str, short: str) -> tuple[s return None, 0 -def gh_create(words: list[str]) -> str | None: +def gh_create_help(words: list[str]) -> bool: + """Match only bare create help, optionally with one owner/repo selector.""" + if (len(words) < 4 or os.path.basename(words[0]) != "gh" + or words[1] not in ("issue", "pr") or words[2] != "create"): + return False + args = words[3:] + repo = None + if len(args) == 3 and args[0] in ("-R", "--repo"): + repo, args = args[1], args[2:] + elif len(args) == 3 and args[1] in ("-R", "--repo"): + repo, args = args[2], args[:1] + elif len(args) == 2 and args[0].startswith("--repo="): + repo, args = args[0][len("--repo="):], args[1:] + elif len(args) == 2 and args[1].startswith("--repo="): + repo, args = args[1][len("--repo="):], args[:1] + if repo is not None and re.fullmatch(r"[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+", repo) is None: + return False + return len(args) == 1 and args[0] in ( + "--help", "-h", "--help=true", "--help=TRUE", "--help=True", + "--help=1", "--help=t", "--help=T", + ) + + +def gh_create(words: list[str], *, allow_help: bool = False) -> str | None: """Return `issue` or `pr` when words run `gh issue|pr create`.""" + if allow_help and gh_create_help(words): + return None for index, word in enumerate(words): if os.path.basename(word) != "gh": continue rest: list[str] = [] skip = False - for word in words[index + 1:]: + operands, _ = redirections(words[index:]) + for word in operands: if skip: skip = False + elif word == "--": + break elif word in VALUE_FLAGS: skip = True elif not word.startswith("-"): @@ -1044,18 +1086,25 @@ def command_strings(event_input: dict[str, object]) -> list[str]: def elon_gate(event_input: dict[str, object], cwd: str) -> str | None: """Return a block message when a `gh` call creating an Issue or PR lacks the Elon record.""" - for text in command_strings(event_input): + commands = command_strings(event_input) + for text in commands: if "gh" not in text or ("create" not in text and "api" not in text): continue directory = Path(cwd) # What the command itself leaves in files before `gh` runs; None is unknown. written: dict[str, str | None] = {} stdout: str | None = None - for segment, heredoc, piped in shell_segments(text): + segments = shell_segments(text) + # Help is exempt only for one plain invocation whose raw characters + # are all explicitly allowed. Unknown characters never authorize help. + allow_help = (len(commands) == 1 and len(segments) == 1 + and re.fullmatch(r"[-A-Za-z0-9 _./=:,@+]+", text) is not None) + for segment, heredoc, piped in segments: try: words = shlex.split(segment, posix=True) except ValueError: words = segment.split() + allow_help = False # A heredoc is stdin; otherwise a pipe from a `cat <<'EOF'` may be. stdin = heredoc if heredoc is not None else (stdout if piped else None) if len(words) >= 2 and words[0] == "cd": @@ -1064,7 +1113,7 @@ def elon_gate(event_input: dict[str, object], cwd: str) -> str | None: stdout = None continue stdout = track_writes(words, stdin, directory, written) - kind = gh_create(words) + kind = gh_create(words, allow_help=allow_help) api = kind is None if api: kind = gh_api_create(words) diff --git a/tests/test_elon_gate.py b/tests/test_elon_gate.py index f8a0e6b..a8867db 100644 --- a/tests/test_elon_gate.py +++ b/tests/test_elon_gate.py @@ -90,6 +90,163 @@ def test_other_tool_calls_pass_silently(self) -> None: result = self.hook(tool_input) self.assertEqual((result.returncode, result.stdout, result.stderr), (0, "", "")) + def test_create_help_passes_without_a_record(self) -> None: + for kind in ("issue", "pr"): + for help_flag in ("--help", "-h", "--help=true", "--help=TRUE", "--help=True", "--help=1", "--help=t", "--help=T"): + for args in (f"{kind} create {help_flag}", + f"{kind} create -R o/r {help_flag}", + f"{kind} create {help_flag} -R o/r", + f"{kind} create --repo o/r {help_flag}", + f"{kind} create {help_flag} --repo o/r", + f"{kind} create --repo=o/r {help_flag}", + f"{kind} create {help_flag} --repo=o/r"): + with self.subTest(args=args): + result = self.hook({"command": "gh " + args}) + self.assertEqual((result.returncode, result.stdout, result.stderr), (0, "", "")) + + def test_help_does_not_exempt_another_create_in_the_command(self) -> None: + for operator in (";", "&&", "||", "|", "|&", "&", "\n"): + for first, second in (("issue", "pr"), ("pr", "issue")): + for help_first in (True, False): + help_call = f"gh {first} create --help" + create = f"gh {second} create --title x --body hello" + calls = (help_call, create) if help_first else (create, help_call) + command = f" {operator} ".join(calls) + with self.subTest(command=command): + self.assertBlocked(self.hook({"command": command})) + + def test_help_text_used_as_a_value_does_not_exempt_a_create(self) -> None: + for kind in ("issue", "pr"): + for flag in ("-R", "--repo", "--hostname", "-a", "--assignee", "--attach", + "--blocked-by", "--blocking", "-B", "--base", "-b", "--body", + "-F", "--body-file", "-H", "--head", "-l", "--label", "-m", + "--milestone", "--parent", "-p", "--project", "--recover", + "-r", "--reviewer", "-T", "--template", "-t", "--title", "--type"): + for value in ("--help", "-h"): + with self.subTest(kind=kind, flag=flag, value=value): + self.assertBlocked(self.hook({"command": f"gh {kind} create {flag} '{value}'"})) + self.assertBlocked(self.hook({"command": f"gh {kind} create -- --help"})) + for redirect in (">", "<", "2>"): + self.assertBlocked(self.hook({"command": f"gh {kind} create --body hello {redirect} --help"})) + + def test_comments_cannot_supply_help_or_a_body(self) -> None: + for kind in ("issue", "pr"): + for comment in ("# --help", "# -h", "#comment --help"): + for args in (f"--title x --body hello {comment}", + f"{comment} --title x --body hello"): + with self.subTest(kind=kind, args=args): + self.assertBlocked(self.hook({"command": f"gh {kind} create {args}"})) + with self.subTest(kind=kind, comment="body"): + self.assertBlocked(self.hook({"command": f"gh {kind} create # --body " + shlex.quote(RECORD)})) + self.assertBlocked(self.hook({"command": "gh api repos/o/r/issues -f body=hello # -f body=" + + shlex.quote(RECORD)})) + + def test_hashes_in_values_are_not_comments(self) -> None: + for kind in ("issue", "pr"): + for title in ("x#", '"a # b"', '"#"', r"\#", r"a\ #"): + with self.subTest(kind=kind, title=title): + command = f"gh {kind} create --title {title}" + self.assertBlocked(self.hook({"command": command + " --body hello"})) + self.assertBlocked(self.hook({"command": command + " --help"})) + self.assertEqual(self.hook({"command": command + " --body " + shlex.quote(RECORD)}).returncode, 0) + for value in ("false", "FALSE", "False", "0", "f", "F"): + self.assertBlocked(self.hook({"command": f"gh {kind} create --help={value}"})) + + def test_line_continuations_before_help_and_comments(self) -> None: + continuation = "\\\n" + for kind in ("issue", "pr"): + create = f"gh {kind} create --title x --body hello " + cases = ( + (create + continuation + "# --help", False), + (create + continuation + "# -h", False), + (create + continuation + "--help", False), + # Removing the continuation joins a word; x# is still data. + (f"gh {kind} create --title x" + continuation + "# --help", False), + # An escaped hash remains data after a continuation. + (create + continuation + r"\# --help", False), + # A comment's trailing backslash does not continue the comment. + (create + "--help # ignored " + continuation + "gh issue list", False), + ("gh issue list # ignored " + continuation + create, False), + # Backslash-CRLF escapes CR, not LF; the next line is separate. + (create + "\\\r\n# --help", False), + ) + for command, allowed in cases: + with self.subTest(command=command): + result = self.hook({"command": command}) + if allowed: + self.assertEqual(result.returncode, 0, result.stderr) + else: + self.assertBlocked(result) + self.assertBlocked(self.hook({"command": "gh api repos/o/r/issues -f body=hello " + continuation + + "# -f body=" + shlex.quote(RECORD)})) + + def test_comment_ends_at_newline_not_a_commented_separator(self) -> None: + command = "gh issue list # ignored ; gh pr create --body hello" + self.assertEqual(self.hook({"command": command}).returncode, 0) + self.assertBlocked(self.hook({"command": command + "\ngh pr create --body hello"})) + + def test_help_exemption_rejects_shell_syntax_in_the_raw_command(self) -> None: + for kind in ("issue", "pr"): + help_call = f"gh {kind} create --help" + for suffix in ("\n", "\r", ";", " && true", " || true", " | cat", " |& cat", " &", + " <out", " (true)", " `true`", + " --title 'a; b'", " --title 'a # b'"): + with self.subTest(kind=kind, suffix=suffix): + self.assertBlocked(self.hook({"command": help_call + suffix})) + # Even a later create with a valid record cannot exempt compound help. + command = help_call + "; gh pr create --body " + shlex.quote(RECORD) + self.assertBlocked(self.hook({"command": command})) + self.assertBlocked(self.hook({"command": "env " + help_call})) + self.assertBlocked(self.hook({"command": ["gh issue create --help", + "gh pr create --body " + shlex.quote(RECORD)]})) + + def test_here_string_does_not_swallow_a_later_create(self) -> None: + for prefix in ("cat <<< foo", "gh issue create --help <<< foo"): + for create in ("gh pr create --title x --body hello", + "gh api repos/o/r/issues -f body="): + with self.subTest(prefix=prefix, create=create): + self.assertBlocked(self.hook({"command": prefix + "\n" + create})) + + def test_malformed_command_cannot_get_the_help_exemption(self) -> None: + for suffix in (" '", ' "', " \\"): + with self.subTest(suffix=suffix): + self.assertBlocked(self.hook({"command": "gh issue create --help" + suffix})) + + def test_help_exemption_requires_the_raw_character_allow_list(self) -> None: + for kind in ("issue", "pr"): + commands = ( + f"gh {kind} create --title x --body hello\x00 --help", + f"gh\t{kind}\tcreate\t--help", + f'gh {kind} create --title "a b" --help', + f"gh {kind} create --title 'a b' --help", + f"gh {kind} create --title café --help", + f"gh {kind} create --title {{x,y}} --help", + f"gh {kind} create --title a\\ b --help", + ) + for command in commands: + with self.subTest(command=command): + self.assertBlocked(self.hook({"command": command})) + # Allowed raw characters alone do not make a non-bare help call exempt. + command = f"gh {kind} create --title Az09-_./=:,@+ --help" + self.assertBlocked(self.hook({"command": command})) + + def test_only_bare_help_with_at_most_one_repo_selector_is_exempt(self) -> None: + for command in ("gh pr create -dt --help -b x", "gh pr create -fb --help"): + with self.subTest(command=command): + self.assertBlocked(self.hook({"command": command})) + for kind in ("issue", "pr"): + for args in ("--title x --help", "--help --title x", "--help x", "x --help", + "--help --help", "-R o/r --help --repo o/r", "--help -R o/r -R o/r", + "--repo= --help", "--repo=--help --help", "-R --help --help", + "--repo host/o/r --help", "--repo o/ --help", "--repo /r --help", + "--help --fill", "--draft --help", "--help --body x"): + with self.subTest(kind=kind, args=args): + self.assertBlocked(self.hook({"command": f"gh {kind} create {args}"})) + for command in (f"gh -R o/r {kind} create --help", f"gh {kind} --help create"): + with self.subTest(command=command): + self.assertBlocked(self.hook({"command": command})) + def test_text_that_only_mentions_creation_passes(self) -> None: # Regression from 2026-10-01: a research spawn was blocked because its # task text quoted the command under investigation. diff --git a/tools/versionctl/pyproject.toml b/tools/versionctl/pyproject.toml index c0ada86..9661b58 100644 --- a/tools/versionctl/pyproject.toml +++ b/tools/versionctl/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "toolboxmd-versionctl" -version = "17.4.1" +version = "17.4.2" description = "Deterministic repository version mechanics for coding agents" readme = "README.md" requires-python = ">=3.11"