From 729b24467dc083ef64db3aeea664ffde84b67e31 Mon Sep 17 00:00:00 2001 From: Luke Maj Date: Mon, 19 Oct 2026 15:31:16 +0200 Subject: [PATCH 1/6] fix: allow gh create help through the Elon gate (#247) --- bin/project-direction | 16 ++++++++++++++-- tests/test_elon_gate.py | 31 +++++++++++++++++++++++++++++++ 2 files changed, 45 insertions(+), 2 deletions(-) diff --git a/bin/project-direction b/bin/project-direction index 8ef7796..b7ef819 100755 --- a/bin/project-direction +++ b/bin/project-direction @@ -658,6 +658,13 @@ PLACEHOLDER = re.compile(r"^<[^>]*>$") HEADING = re.compile(r"^\s*#{1,6}\s") SEPARATORS = ("&&", "||", "|&", ";", "|", "\n") VALUE_FLAGS = ("-R", "--repo", "--hostname") +# Values may themselves be `--help` or `-h`; only a help flag is read-only. +CREATE_VALUE_FLAGS = VALUE_FLAGS + ( + "-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", +) # Tools whose `command` is patch data, and tools whose `command` a shell runs. PATCH_TOOLS = ("apply_patch",) SHELL_TOOLS = ("Bash", "bash", "shell", "local_shell", "exec_command", "run_terminal_command") @@ -894,11 +901,16 @@ def gh_create(words: list[str]) -> str | None: 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 in VALUE_FLAGS: + elif word == "--": + break + elif word in CREATE_VALUE_FLAGS: skip = True + elif word in ("--help", "-h"): + return None elif not word.startswith("-"): rest.append(word) if len(rest) >= 2 and rest[0] in ("issue", "pr") and rest[1] == "create": diff --git a/tests/test_elon_gate.py b/tests/test_elon_gate.py index f8a0e6b..044ba82 100644 --- a/tests/test_elon_gate.py +++ b/tests/test_elon_gate.py @@ -90,6 +90,37 @@ 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"): + for args in (f"{kind} create {help_flag}", + f"-R toolboxmd/agentsmd {kind} create {help_flag}", + f"{kind} {help_flag} create"): + 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 ("--title", "-t", "--body", "-b", "--body-file", "-F", "--label", "--repo"): + 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_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. From d3185f591f79ab751dafae3c36a0ad18cf3d58bf Mon Sep 17 00:00:00 2001 From: Luke Maj Date: Mon, 19 Oct 2026 15:52:26 +0200 Subject: [PATCH 2/6] fix: exclude shell comments from Elon gate parsing --- bin/project-direction | 10 ++++++++-- tests/test_elon_gate.py | 38 +++++++++++++++++++++++++++++++++++--- 2 files changed, 43 insertions(+), 5 deletions(-) diff --git a/bin/project-direction b/bin/project-direction index b7ef819..2d29f20 100755 --- a/bin/project-direction +++ b/bin/project-direction @@ -720,7 +720,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]]: current.append(command[index:index + 2]) index += 2 continue + 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) and not command.startswith("<<<", index): match = re.match(r"<<(-?)\s*(['\"]?)([A-Za-z0-9_]+)\2", command[index:]) if match: @@ -909,7 +915,7 @@ def gh_create(words: list[str]) -> str | None: break elif word in CREATE_VALUE_FLAGS: skip = True - elif word in ("--help", "-h"): + elif word in ("--help", "-h", "--help=true"): return None elif not word.startswith("-"): rest.append(word) diff --git a/tests/test_elon_gate.py b/tests/test_elon_gate.py index 044ba82..560e87c 100644 --- a/tests/test_elon_gate.py +++ b/tests/test_elon_gate.py @@ -5,17 +5,22 @@ import json import os +import runpy import shlex import shutil import subprocess +import sys import tempfile import unittest from pathlib import Path +from unittest.mock import patch ROOT = Path(__file__).resolve().parents[1] LOADER = ROOT / "bin/project-direction" PLUGIN = ROOT / "opencode/agentsmd-project-direction.js" NODE = shutil.which("node") +with patch("sys.path", [str(LOADER.parent), *sys.path]): + CREATE_VALUE_FLAGS = runpy.run_path(str(LOADER))["CREATE_VALUE_FLAGS"] RECORD = ( "- **Requirements and who asked:** the user asked for enforcement\n" @@ -92,7 +97,7 @@ def test_other_tool_calls_pass_silently(self) -> None: def test_create_help_passes_without_a_record(self) -> None: for kind in ("issue", "pr"): - for help_flag in ("--help", "-h"): + for help_flag in ("--help", "-h", "--help=true"): for args in (f"{kind} create {help_flag}", f"-R toolboxmd/agentsmd {kind} create {help_flag}", f"{kind} {help_flag} create"): @@ -101,7 +106,7 @@ def test_create_help_passes_without_a_record(self) -> None: 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 operator in (";", "&&", "||", "|", "|&", "&", "\n"): for first, second in (("issue", "pr"), ("pr", "issue")): for help_first in (True, False): help_call = f"gh {first} create --help" @@ -113,7 +118,7 @@ def test_help_does_not_exempt_another_create_in_the_command(self) -> None: def test_help_text_used_as_a_value_does_not_exempt_a_create(self) -> None: for kind in ("issue", "pr"): - for flag in ("--title", "-t", "--body", "-b", "--body-file", "-F", "--label", "--repo"): + for flag in CREATE_VALUE_FLAGS: 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}'"})) @@ -121,6 +126,33 @@ def test_help_text_used_as_a_value_does_not_exempt_a_create(self) -> None: 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.assertEqual(self.hook({"command": command + " --help"}).returncode, 0) + for value in ("false", "0"): + self.assertBlocked(self.hook({"command": f"gh {kind} create --help={value}"})) + + def test_comment_ends_at_newline_not_a_commented_separator(self) -> None: + command = "gh issue create --help # 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_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. From 3c9d83ee49defc85c747f6170a6a80ee9c381cd5 Mon Sep 17 00:00:00 2001 From: Luke Maj Date: Mon, 19 Oct 2026 16:16:18 +0200 Subject: [PATCH 3/6] fix: handle shell line continuations before Elon gate comments --- .claude-plugin/plugin.json | 2 +- .codex-plugin/plugin.json | 2 +- .grok-plugin/plugin.json | 2 +- .muse-plugin/plugin.json | 2 +- CHANGELOG.md | 6 ++++++ VERSION | 2 +- bin/project-direction | 8 ++++++-- tests/test_elon_gate.py | 32 ++++++++++++++++++++++++++++++-- tools/versionctl/pyproject.toml | 2 +- 9 files changed, 48 insertions(+), 10 deletions(-) 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 2d29f20..d6bba0d 100755 --- a/bin/project-direction +++ b/bin/project-direction @@ -756,7 +756,10 @@ def shell_segments(command: str) -> 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 char == "#" and (not current or current[-1].isspace()): @@ -915,7 +918,8 @@ def gh_create(words: list[str]) -> str | None: break elif word in CREATE_VALUE_FLAGS: skip = True - elif word in ("--help", "-h", "--help=true"): + elif word in ("--help", "-h", "--help=true", "--help=TRUE", "--help=True", + "--help=1", "--help=t", "--help=T"): return None elif not word.startswith("-"): rest.append(word) diff --git a/tests/test_elon_gate.py b/tests/test_elon_gate.py index 560e87c..d2f6354 100644 --- a/tests/test_elon_gate.py +++ b/tests/test_elon_gate.py @@ -97,7 +97,7 @@ def test_other_tool_calls_pass_silently(self) -> None: def test_create_help_passes_without_a_record(self) -> None: for kind in ("issue", "pr"): - for help_flag in ("--help", "-h", "--help=true"): + 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"-R toolboxmd/agentsmd {kind} create {help_flag}", f"{kind} {help_flag} create"): @@ -145,9 +145,37 @@ def test_hashes_in_values_are_not_comments(self) -> None: command = f"gh {kind} create --title {title}" self.assertBlocked(self.hook({"command": command + " --body hello"})) self.assertEqual(self.hook({"command": command + " --help"}).returncode, 0) - for value in ("false", "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", True), + # Removing the continuation joins a word; x# is still data. + (f"gh {kind} create --title x" + continuation + "# --help", True), + # An escaped hash remains data after a continuation. + (create + continuation + r"\# --help", True), + # A comment's trailing backslash does not continue the comment. + (create + "--help # ignored " + continuation + "gh issue list", True), + ("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 create --help # ignored ; gh pr create --body hello" self.assertEqual(self.hook({"command": command}).returncode, 0) 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" From 83660115976d47dac906f755a44980b9b98a1f9a Mon Sep 17 00:00:00 2001 From: Luke Maj Date: Mon, 19 Oct 2026 16:34:50 +0200 Subject: [PATCH 4/6] fix: restrict Elon help exemption to plain commands --- bin/project-direction | 29 ++++++++++++++++++++++------- tests/test_elon_gate.py | 41 +++++++++++++++++++++++++++++++++++------ 2 files changed, 57 insertions(+), 13 deletions(-) diff --git a/bin/project-direction b/bin/project-direction index d6bba0d..f9a6ba1 100755 --- a/bin/project-direction +++ b/bin/project-direction @@ -768,7 +768,12 @@ def shell_segments(command: str) -> list[tuple[str, str | None, bool]]: newline = command.find("\n", index) index = length if newline < 0 else newline continue - if command.startswith("<<", index) and not command.startswith("<<<", index): + 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. @@ -903,7 +908,7 @@ 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(words: list[str], *, allow_help: bool = False) -> str | None: """Return `issue` or `pr` when words run `gh issue|pr create`.""" for index, word in enumerate(words): if os.path.basename(word) != "gh": @@ -918,8 +923,10 @@ def gh_create(words: list[str]) -> str | None: break elif word in CREATE_VALUE_FLAGS: skip = True - elif word in ("--help", "-h", "--help=true", "--help=TRUE", "--help=True", - "--help=1", "--help=t", "--help=T"): + elif allow_help and index == 0 and word in ( + "--help", "-h", "--help=true", "--help=TRUE", "--help=True", + "--help=1", "--help=t", "--help=T", + ): return None elif not word.startswith("-"): rest.append(word) @@ -1066,18 +1073,26 @@ 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. Check raw syntax so a + # shell-parser disagreement can never grant the help exemption. Even + # quoted occurrences of these characters conservatively disable it. + allow_help = (len(commands) == 1 and len(segments) == 1 + and not re.search(r"[\r\n;&|<>`#$()]", text)) + 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": @@ -1086,7 +1101,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 d2f6354..a2d762b 100644 --- a/tests/test_elon_gate.py +++ b/tests/test_elon_gate.py @@ -144,7 +144,8 @@ def test_hashes_in_values_are_not_comments(self) -> None: with self.subTest(kind=kind, title=title): command = f"gh {kind} create --title {title}" self.assertBlocked(self.hook({"command": command + " --body hello"})) - self.assertEqual(self.hook({"command": command + " --help"}).returncode, 0) + 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}"})) @@ -155,13 +156,13 @@ def test_line_continuations_before_help_and_comments(self) -> None: cases = ( (create + continuation + "# --help", False), (create + continuation + "# -h", False), - (create + continuation + "--help", True), + (create + continuation + "--help", False), # Removing the continuation joins a word; x# is still data. - (f"gh {kind} create --title x" + continuation + "# --help", True), + (f"gh {kind} create --title x" + continuation + "# --help", False), # An escaped hash remains data after a continuation. - (create + continuation + r"\# --help", True), + (create + continuation + r"\# --help", False), # A comment's trailing backslash does not continue the comment. - (create + "--help # ignored " + continuation + "gh issue list", True), + (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), @@ -177,10 +178,38 @@ def test_line_continuations_before_help_and_comments(self) -> None: + "# -f body=" + shlex.quote(RECORD)})) def test_comment_ends_at_newline_not_a_commented_separator(self) -> None: - command = "gh issue create --help # ignored ; gh pr create --body hello" + 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_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. From 197011399039d6a2cee98d7519884e4255f71cd7 Mon Sep 17 00:00:00 2001 From: Luke Maj Date: Mon, 19 Oct 2026 17:11:54 +0200 Subject: [PATCH 5/6] fix: allow-list raw characters for Elon help exemption --- bin/project-direction | 7 +++---- tests/test_elon_gate.py | 18 ++++++++++++++++++ 2 files changed, 21 insertions(+), 4 deletions(-) diff --git a/bin/project-direction b/bin/project-direction index f9a6ba1..aa279da 100755 --- a/bin/project-direction +++ b/bin/project-direction @@ -1082,11 +1082,10 @@ def elon_gate(event_input: dict[str, object], cwd: str) -> str | None: written: dict[str, str | None] = {} stdout: str | None = None segments = shell_segments(text) - # Help is exempt only for one plain invocation. Check raw syntax so a - # shell-parser disagreement can never grant the help exemption. Even - # quoted occurrences of these characters conservatively disable it. + # 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 not re.search(r"[\r\n;&|<>`#$()]", text)) + 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) diff --git a/tests/test_elon_gate.py b/tests/test_elon_gate.py index a2d762b..15c1294 100644 --- a/tests/test_elon_gate.py +++ b/tests/test_elon_gate.py @@ -210,6 +210,24 @@ def test_malformed_command_cannot_get_the_help_exemption(self) -> None: 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})) + # Exercise every allowed punctuation character without shell quoting. + command = f"gh {kind} create --title Az09-_./=:,@+ --help" + self.assertEqual(self.hook({"command": command}).returncode, 0) + 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. From d83f20ede2e102c2ae3a48369c5526e7c76709c2 Mon Sep 17 00:00:00 2001 From: Luke Maj Date: Mon, 19 Oct 2026 17:55:14 +0200 Subject: [PATCH 6/6] fix: exempt only bare gh create help with optional repo --- bin/project-direction | 39 ++++++++++++++++++++++++++------------- tests/test_elon_gate.py | 39 +++++++++++++++++++++++++++++---------- 2 files changed, 55 insertions(+), 23 deletions(-) diff --git a/bin/project-direction b/bin/project-direction index aa279da..f36d614 100755 --- a/bin/project-direction +++ b/bin/project-direction @@ -658,13 +658,6 @@ PLACEHOLDER = re.compile(r"^<[^>]*>$") HEADING = re.compile(r"^\s*#{1,6}\s") SEPARATORS = ("&&", "||", "|&", ";", "|", "\n") VALUE_FLAGS = ("-R", "--repo", "--hostname") -# Values may themselves be `--help` or `-h`; only a help flag is read-only. -CREATE_VALUE_FLAGS = VALUE_FLAGS + ( - "-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", -) # Tools whose `command` is patch data, and tools whose `command` a shell runs. PATCH_TOOLS = ("apply_patch",) SHELL_TOOLS = ("Bash", "bash", "shell", "local_shell", "exec_command", "run_terminal_command") @@ -908,8 +901,33 @@ def option_value(words: list[str], index: int, long: str, short: str) -> tuple[s return None, 0 +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 @@ -921,13 +939,8 @@ def gh_create(words: list[str], *, allow_help: bool = False) -> str | None: skip = False elif word == "--": break - elif word in CREATE_VALUE_FLAGS: + elif word in VALUE_FLAGS: skip = True - elif allow_help and index == 0 and word in ( - "--help", "-h", "--help=true", "--help=TRUE", "--help=True", - "--help=1", "--help=t", "--help=T", - ): - return None elif not word.startswith("-"): rest.append(word) if len(rest) >= 2 and rest[0] in ("issue", "pr") and rest[1] == "create": diff --git a/tests/test_elon_gate.py b/tests/test_elon_gate.py index 15c1294..a8867db 100644 --- a/tests/test_elon_gate.py +++ b/tests/test_elon_gate.py @@ -5,22 +5,17 @@ import json import os -import runpy import shlex import shutil import subprocess -import sys import tempfile import unittest from pathlib import Path -from unittest.mock import patch ROOT = Path(__file__).resolve().parents[1] LOADER = ROOT / "bin/project-direction" PLUGIN = ROOT / "opencode/agentsmd-project-direction.js" NODE = shutil.which("node") -with patch("sys.path", [str(LOADER.parent), *sys.path]): - CREATE_VALUE_FLAGS = runpy.run_path(str(LOADER))["CREATE_VALUE_FLAGS"] RECORD = ( "- **Requirements and who asked:** the user asked for enforcement\n" @@ -99,8 +94,12 @@ 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"-R toolboxmd/agentsmd {kind} create {help_flag}", - f"{kind} {help_flag} create"): + 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, "", "")) @@ -118,7 +117,11 @@ def test_help_does_not_exempt_another_create_in_the_command(self) -> None: def test_help_text_used_as_a_value_does_not_exempt_a_create(self) -> None: for kind in ("issue", "pr"): - for flag in CREATE_VALUE_FLAGS: + 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}'"})) @@ -224,9 +227,25 @@ def test_help_exemption_requires_the_raw_character_allow_list(self) -> None: for command in commands: with self.subTest(command=command): self.assertBlocked(self.hook({"command": command})) - # Exercise every allowed punctuation character without shell quoting. + # Allowed raw characters alone do not make a non-bare help call exempt. command = f"gh {kind} create --title Az09-_./=:,@+ --help" - self.assertEqual(self.hook({"command": command}).returncode, 0) + 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