Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .claude-plugin/plugin.json
Original file line number Diff line number Diff line change
@@ -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"
Expand Down
2 changes: 1 addition & 1 deletion .codex-plugin/plugin.json
Original file line number Diff line number Diff line change
@@ -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",
Expand Down
2 changes: 1 addition & 1 deletion .grok-plugin/plugin.json
Original file line number Diff line number Diff line change
@@ -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"
Expand Down
2 changes: 1 addition & 1 deletion .muse-plugin/plugin.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion VERSION
Original file line number Diff line number Diff line change
@@ -1 +1 @@
17.4.1
17.4.2
65 changes: 57 additions & 8 deletions bin/project-direction
Original file line number Diff line number Diff line change
Expand Up @@ -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<<EOF` feeds another fd.
Expand Down Expand Up @@ -749,10 +749,24 @@ 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 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.
Expand Down Expand Up @@ -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("-"):
Expand Down Expand Up @@ -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":
Expand All @@ -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)
Expand Down
157 changes: 157 additions & 0 deletions tests/test_elon_gate.py
Original file line number Diff line number Diff line change
Expand Up @@ -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", " &",
" <<EOF\nEOF", " <<< foo", " >out", " <in", " # comment",
" $(true)", " <(true)", " >(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.
Expand Down
2 changes: 1 addition & 1 deletion tools/versionctl/pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
Loading