fix(guard): quote-aware shell segmentation so only real daemon kills classify (Closes #787) #789
+142
-4
@@ -49,7 +49,7 @@ from __future__ import annotations
|
|||||||
|
|
||||||
import os
|
import os
|
||||||
import re
|
import re
|
||||||
from typing import Any, Iterable
|
from typing import Any, Iterable, Iterator
|
||||||
|
|
||||||
# Single source of truth for both the redactor and the gated-mutation set: the
|
# Single source of truth for both the redactor and the gated-mutation set: the
|
||||||
# #671 guard already owns them, so the two contamination models can never drift
|
# #671 guard already owns them, so the two contamination models can never drift
|
||||||
@@ -86,8 +86,26 @@ REMEDIATION = (
|
|||||||
|
|
||||||
# Split a compound command line into simple commands on shell separators so
|
# Split a compound command line into simple commands on shell separators so
|
||||||
# ``ps aux | grep mcp_server`` is analysed segment by segment and its harmless
|
# ``ps aux | grep mcp_server`` is analysed segment by segment and its harmless
|
||||||
# inspection half never reaches the kill classifier.
|
# inspection half never reaches the kill classifier. The background separator
|
||||||
_SEGMENT_SPLIT_RE = re.compile(r"(?:\|\||&&|\||;|\n)")
|
# ``&`` is a separator too: without it ``sleep 1 & pkill -f mcp_server.py`` was
|
||||||
|
# a single segment whose command position held ``sleep``, so the kill was never
|
||||||
|
# classified (#787).
|
||||||
|
#
|
||||||
|
# Splitting is *quote-aware*, and a regex alternation cannot express that, so
|
||||||
|
# the scan below replaces the earlier ``_SEGMENT_SPLIT_RE`` pattern. A separator
|
||||||
|
# only separates where it is syntactically active: outside single and double
|
||||||
|
# quotes, and not backslash-escaped. Without that, adding ``&`` made every
|
||||||
|
# benign mention of the canonical kill string classify as a real kill — a commit
|
||||||
|
# message quoting ``sleep 1 & pkill -f mcp_server.py``, an ``echo`` of the same
|
||||||
|
# sentence, a ``grep`` for it — and a false contamination marker fails review,
|
||||||
|
# merge, close and completion mutations closed until a reconciler clears it (PR
|
||||||
|
# #789 review finding F1). Quote-awareness is not specific to ``&``: it also
|
||||||
|
# retires the same false-positive class that ``;`` and ``|`` carried before #787.
|
||||||
|
_SEPARATOR_CHARS = frozenset("|&;\n")
|
||||||
|
|
||||||
|
#: Two-character logical separators, consumed whole so ``&&`` and ``||`` are
|
||||||
|
#: never split into single characters leaving a stray operator behind.
|
||||||
|
_LOGICAL_SEPARATORS = ("&&", "||")
|
||||||
|
|
||||||
_KILL_VERBS = frozenset({"kill", "pkill", "killall"})
|
_KILL_VERBS = frozenset({"kill", "pkill", "killall"})
|
||||||
|
|
||||||
@@ -133,8 +151,128 @@ def _clean(value: str | None) -> str:
|
|||||||
return (value or "").strip()
|
return (value or "").strip()
|
||||||
|
|
||||||
|
|
||||||
|
def _iter_active(text: str) -> Iterator[tuple[int, str]]:
|
||||||
|
"""Yield ``(index, char)`` for every *syntactically active* character.
|
||||||
|
|
||||||
|
Active means outside single and double quotes and not backslash-escaped —
|
||||||
|
the positions where a shell metacharacter actually carries its meaning.
|
||||||
|
Quoted runs, the quote characters themselves, and escaped characters are
|
||||||
|
skipped, so a separator written inside a commit message or a ``grep``
|
||||||
|
pattern is literal text rather than syntax. A backslash escapes nothing
|
||||||
|
inside single quotes, matching POSIX.
|
||||||
|
|
||||||
|
An unterminated quote swallows the rest of the line, exactly as it does for
|
||||||
|
the shell — which would reject such a command as a syntax error rather than
|
||||||
|
run its tail, so nothing executable hides behind it.
|
||||||
|
"""
|
||||||
|
quote: str | None = None
|
||||||
|
index = 0
|
||||||
|
end = len(text)
|
||||||
|
while index < end:
|
||||||
|
char = text[index]
|
||||||
|
if quote == "'":
|
||||||
|
if char == "'":
|
||||||
|
quote = None
|
||||||
|
index += 1
|
||||||
|
elif quote == '"':
|
||||||
|
if char == "\\" and index + 1 < end:
|
||||||
|
index += 2
|
||||||
|
else:
|
||||||
|
if char == '"':
|
||||||
|
quote = None
|
||||||
|
index += 1
|
||||||
|
elif char == "\\" and index + 1 < end:
|
||||||
|
index += 2
|
||||||
|
elif char in ("'", '"'):
|
||||||
|
quote = char
|
||||||
|
index += 1
|
||||||
|
else:
|
||||||
|
yield index, char
|
||||||
|
index += 1
|
||||||
|
|
||||||
|
|
||||||
|
def _is_redirection(command: str, index: int, active: frozenset[int]) -> bool:
|
||||||
|
"""Is the ``&``/``|`` at *index* part of a redirection, not a separator?
|
||||||
|
|
||||||
|
``2>&1`` and ``>&2`` put the character immediately after a redirection
|
||||||
|
operator, and ``&>log`` immediately before one; in neither position does it
|
||||||
|
separate commands. Without this, ``a 2>&1`` split into ``['a 2>', '1']``
|
||||||
|
(PR #789 review finding F3).
|
||||||
|
"""
|
||||||
|
previous = command[index - 1] if index else ""
|
||||||
|
if previous in ("<", ">") and (index - 1) in active:
|
||||||
|
return True
|
||||||
|
return (
|
||||||
|
command[index] == "&"
|
||||||
|
and command[index + 1:index + 2] == ">"
|
||||||
|
and (index + 1) in active
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _closes_leading_paren(body: str) -> bool:
|
||||||
|
"""Does *body* end with the active ``)`` matching a stripped leading ``(``?"""
|
||||||
|
if not body.endswith(")"):
|
||||||
|
return False
|
||||||
|
depth = 0
|
||||||
|
for index, char in _iter_active(body):
|
||||||
|
if char == "(":
|
||||||
|
depth += 1
|
||||||
|
elif char == ")":
|
||||||
|
if depth == 0:
|
||||||
|
return index == len(body) - 1
|
||||||
|
depth -= 1
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def _strip_subshell(segment: str) -> str:
|
||||||
|
"""Remove subshell wrappers so ``(pkill -f mcp_server.py)`` is classified.
|
||||||
|
|
||||||
|
The parentheses are shell syntax, not part of the simple command, so a
|
||||||
|
wrapped kill otherwise put ``(pkill`` in command position and never
|
||||||
|
reached the kill classifier (#787). Nested wrappers are unwrapped too.
|
||||||
|
|
||||||
|
A trailing ``)`` is removed only when it closes a leading ``(`` this call
|
||||||
|
stripped. Removing one unconditionally mangled balanced command
|
||||||
|
substitution — ``kill $(pgrep -f myapp)`` became ``kill $(pgrep -f myapp``
|
||||||
|
(PR #789 review finding F3). An unmatched leading ``(`` is still dropped on
|
||||||
|
its own, because splitting a wrapped compound orphans the opening half.
|
||||||
|
"""
|
||||||
|
stripped = segment.strip()
|
||||||
|
while stripped.startswith("("):
|
||||||
|
body = stripped[1:].strip()
|
||||||
|
if _closes_leading_paren(body):
|
||||||
|
body = body[:-1].strip()
|
||||||
|
stripped = body
|
||||||
|
return stripped
|
||||||
|
|
||||||
|
|
||||||
def _split_segments(command: str) -> list[str]:
|
def _split_segments(command: str) -> list[str]:
|
||||||
return [seg for seg in _SEGMENT_SPLIT_RE.split(command) if seg.strip()]
|
"""Split *command* into simple commands on syntactically active separators."""
|
||||||
|
active = frozenset(index for index, _ in _iter_active(command))
|
||||||
|
segments: list[str] = []
|
||||||
|
start = 0
|
||||||
|
index = 0
|
||||||
|
end = len(command)
|
||||||
|
while index < end:
|
||||||
|
char = command[index]
|
||||||
|
if (
|
||||||
|
char not in _SEPARATOR_CHARS
|
||||||
|
or index not in active
|
||||||
|
or (char in "&|" and _is_redirection(command, index, active))
|
||||||
|
):
|
||||||
|
index += 1
|
||||||
|
continue
|
||||||
|
width = (
|
||||||
|
2
|
||||||
|
if command[index:index + 2] in _LOGICAL_SEPARATORS
|
||||||
|
and (index + 1) in active
|
||||||
|
else 1
|
||||||
|
)
|
||||||
|
segments.append(command[start:index])
|
||||||
|
index += width
|
||||||
|
start = index
|
||||||
|
segments.append(command[start:])
|
||||||
|
return [seg for seg in (_strip_subshell(seg) for seg in segments) if seg]
|
||||||
|
|
||||||
|
|
||||||
def is_sanctioned_recovery(text: str | None) -> bool:
|
def is_sanctioned_recovery(text: str | None) -> bool:
|
||||||
|
|||||||
@@ -91,6 +91,132 @@ def test_compound_command_detects_the_kill_half():
|
|||||||
assert result["contamination"] is True
|
assert result["contamination"] is True
|
||||||
|
|
||||||
|
|
||||||
|
# ── #787: background separator and subshell forms reach the classifier ───────
|
||||||
|
|
||||||
|
def test_background_separator_kill_is_contamination():
|
||||||
|
result = guard.classify_recovery_command("sleep 1 & pkill -f mcp_server.py")
|
||||||
|
assert result["process_kill"] is True
|
||||||
|
assert result["contamination"] is True
|
||||||
|
assert result["reason_class"] == guard.REASON_MANUAL_DAEMON_KILL
|
||||||
|
assert result["ambiguous"] is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_subshell_wrapped_kill_is_contamination():
|
||||||
|
result = guard.classify_recovery_command("(pkill -f mcp_server.py)")
|
||||||
|
assert result["process_kill"] is True
|
||||||
|
assert result["contamination"] is True
|
||||||
|
assert result["reason_class"] == guard.REASON_MANUAL_DAEMON_KILL
|
||||||
|
assert result["ambiguous"] is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_further_background_and_subshell_forms_are_contamination():
|
||||||
|
for command in (
|
||||||
|
"pkill -f mcp_server.py &",
|
||||||
|
"( sudo pkill -f mcp_server.py )",
|
||||||
|
"((pkill -f gitea_mcp_server))",
|
||||||
|
"sleep 1 & killall mcp_server",
|
||||||
|
"(ps aux | grep mcp_server) & pkill -f mcp_server.py",
|
||||||
|
):
|
||||||
|
result = guard.classify_recovery_command(command)
|
||||||
|
assert result["contamination"] is True, command
|
||||||
|
assert result["reason_class"] == guard.REASON_MANUAL_DAEMON_KILL, command
|
||||||
|
|
||||||
|
|
||||||
|
def test_logical_operators_are_not_split_into_single_characters():
|
||||||
|
# ``&&``/``||`` must still be consumed whole by the separator scan.
|
||||||
|
assert guard._split_segments("a && b || c") == ["a", "b", "c"]
|
||||||
|
assert guard._split_segments("a & b") == ["a", "b"]
|
||||||
|
assert guard._split_segments("(a)") == ["a"]
|
||||||
|
assert guard._split_segments("a; b\nc | d") == ["a", "b", "c", "d"]
|
||||||
|
|
||||||
|
|
||||||
|
# ── #789 F1: separators only separate outside quoted or escaped text ─────────
|
||||||
|
|
||||||
|
# The three commands the PR #789 review measured as regressions at head
|
||||||
|
# 6b58f04: each merely *mentions* the canonical kill string inside quotes.
|
||||||
|
F1_QUOTED_COMMANDS = (
|
||||||
|
'git commit -m "block sleep 1 & pkill -f mcp_server.py as recovery"',
|
||||||
|
'echo "docs: sleep 1 & pkill -f mcp_server.py is now detected"',
|
||||||
|
'grep -rn "sleep 1 & pkill -f mcp_server.py" docs/',
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_quoted_ampersand_examples_from_review_f1_are_not_kills():
|
||||||
|
for command in F1_QUOTED_COMMANDS:
|
||||||
|
result = guard.classify_recovery_command(command)
|
||||||
|
assert result["process_kill"] is False, command
|
||||||
|
assert result["contamination"] is False, command
|
||||||
|
assert result["reason_class"] is None, command
|
||||||
|
|
||||||
|
|
||||||
|
def test_ampersand_inside_double_quotes_is_not_a_separator():
|
||||||
|
assert guard._split_segments('echo "a & b"') == ['echo "a & b"']
|
||||||
|
result = guard.classify_recovery_command(
|
||||||
|
'echo "restart it: sleep 1 & pkill -f mcp_server.py"'
|
||||||
|
)
|
||||||
|
assert result["process_kill"] is False
|
||||||
|
assert result["contamination"] is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_ampersand_inside_single_quotes_is_not_a_separator():
|
||||||
|
assert guard._split_segments("echo 'a & b'") == ["echo 'a & b'"]
|
||||||
|
result = guard.classify_recovery_command(
|
||||||
|
"git commit -m 'sleep 1 & pkill -f mcp_server.py stays quoted'"
|
||||||
|
)
|
||||||
|
assert result["process_kill"] is False
|
||||||
|
assert result["contamination"] is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_backslash_escaped_ampersand_is_not_a_separator():
|
||||||
|
command = r"echo a \& pkill -f mcp_server.py"
|
||||||
|
assert guard._split_segments(command) == [command]
|
||||||
|
result = guard.classify_recovery_command(command)
|
||||||
|
assert result["process_kill"] is False
|
||||||
|
assert result["contamination"] is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_backslash_does_not_escape_inside_single_quotes():
|
||||||
|
# POSIX: a backslash is literal inside single quotes, so the closing quote
|
||||||
|
# still closes and the following ``&`` is a genuinely active separator.
|
||||||
|
command = r"echo 'a\' & pkill -f mcp_server.py"
|
||||||
|
assert guard._split_segments(command) == [r"echo 'a\'", "pkill -f mcp_server.py"]
|
||||||
|
assert guard.classify_recovery_command(command)["contamination"] is True
|
||||||
|
|
||||||
|
|
||||||
|
def test_quote_awareness_also_retires_the_pre_existing_semicolon_and_pipe_cases():
|
||||||
|
# ``;`` and ``|`` misclassified quoted text before #787 as well. The fix is
|
||||||
|
# the quote-unawareness, not the ``&`` instance the issue happens to name.
|
||||||
|
for command in (
|
||||||
|
'git commit -m "fix; pkill -f mcp_server.py"',
|
||||||
|
'git commit -m "fix | pkill -f mcp_server.py"',
|
||||||
|
):
|
||||||
|
result = guard.classify_recovery_command(command)
|
||||||
|
assert result["process_kill"] is False, command
|
||||||
|
assert result["contamination"] is False, command
|
||||||
|
|
||||||
|
|
||||||
|
# ── #789 F3: subshell stripping and redirection stay syntactically honest ────
|
||||||
|
|
||||||
|
def test_command_substitution_is_not_mangled_by_subshell_stripping():
|
||||||
|
# Only a wrapper this call opened may be unwrapped; a ``)`` closing ``$(``
|
||||||
|
# must survive intact.
|
||||||
|
assert guard._strip_subshell("kill $(pgrep -f myapp)") == "kill $(pgrep -f myapp)"
|
||||||
|
result = guard.classify_recovery_command("kill $(pgrep -f myapp)")
|
||||||
|
assert result["contamination"] is False
|
||||||
|
assert result["ambiguous"] is True
|
||||||
|
|
||||||
|
|
||||||
|
def test_redirection_is_not_treated_as_a_background_separator():
|
||||||
|
assert guard._split_segments("a 2>&1") == ["a 2>&1"]
|
||||||
|
assert guard._split_segments("a &> log") == ["a &> log"]
|
||||||
|
assert guard._split_segments("pkill -f mcp_server.py 2>&1") == [
|
||||||
|
"pkill -f mcp_server.py 2>&1"
|
||||||
|
]
|
||||||
|
result = guard.classify_recovery_command("pkill -f mcp_server.py 2>&1")
|
||||||
|
assert result["contamination"] is True
|
||||||
|
assert result["reason_class"] == guard.REASON_MANUAL_DAEMON_KILL
|
||||||
|
|
||||||
|
|
||||||
# ── no false positives ───────────────────────────────────────────────────────
|
# ── no false positives ───────────────────────────────────────────────────────
|
||||||
|
|
||||||
def test_read_only_inspection_is_not_a_kill():
|
def test_read_only_inspection_is_not_a_kill():
|
||||||
@@ -112,6 +238,22 @@ def test_unrelated_pkill_target_is_not_contamination():
|
|||||||
assert result["ambiguous"] is False
|
assert result["ambiguous"] is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_user_scoped_pkill_of_unrelated_app_is_not_contamination():
|
||||||
|
# ``-u`` consumes ``mcpuser``; the surviving operand names no daemon (#787).
|
||||||
|
result = guard.classify_recovery_command("pkill -u mcpuser -f myapp")
|
||||||
|
assert result["process_kill"] is True
|
||||||
|
assert result["contamination"] is False
|
||||||
|
assert result["ambiguous"] is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_commit_message_quoting_the_kill_string_is_not_a_kill():
|
||||||
|
result = guard.classify_recovery_command(
|
||||||
|
'git commit -m "block pkill -f mcp_server.py as workflow recovery"'
|
||||||
|
)
|
||||||
|
assert result["process_kill"] is False
|
||||||
|
assert result["contamination"] is False
|
||||||
|
|
||||||
|
|
||||||
def test_bare_kill_of_unknown_pid_is_ambiguous_not_contamination():
|
def test_bare_kill_of_unknown_pid_is_ambiguous_not_contamination():
|
||||||
result = guard.classify_recovery_command("kill 31337")
|
result = guard.classify_recovery_command("kill 31337")
|
||||||
assert result["contamination"] is False
|
assert result["contamination"] is False
|
||||||
@@ -333,6 +475,43 @@ def test_record_tool_marks_manual_daemon_kill():
|
|||||||
assert "mcp_server.py" in loaded["command_summary"]
|
assert "mcp_server.py" in loaded["command_summary"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_record_tool_marks_background_separator_kill():
|
||||||
|
_clear_marker()
|
||||||
|
res = srv.gitea_record_daemon_process_kill_attempt(
|
||||||
|
command="sleep 1 & pkill -f mcp_server.py", remote="prgs"
|
||||||
|
)
|
||||||
|
assert res["contaminated"] is True
|
||||||
|
assert res["marked"] is True
|
||||||
|
assert res["marker"]["reason_class"] == guard.REASON_MANUAL_DAEMON_KILL
|
||||||
|
loaded = srv._load_runtime_recovery_marker("prgs")
|
||||||
|
assert loaded is not None
|
||||||
|
assert "mcp_server.py" in loaded["command_summary"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_record_tool_marks_subshell_wrapped_kill():
|
||||||
|
_clear_marker()
|
||||||
|
res = srv.gitea_record_daemon_process_kill_attempt(
|
||||||
|
command="(pkill -f mcp_server.py)", remote="prgs"
|
||||||
|
)
|
||||||
|
assert res["contaminated"] is True
|
||||||
|
assert res["marked"] is True
|
||||||
|
assert res["marker"]["reason_class"] == guard.REASON_MANUAL_DAEMON_KILL
|
||||||
|
loaded = srv._load_runtime_recovery_marker("prgs")
|
||||||
|
assert loaded is not None
|
||||||
|
assert "mcp_server.py" in loaded["command_summary"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_record_tool_does_not_mark_a_quoted_mention_of_the_kill_string():
|
||||||
|
# The marker is what fails review/merge/close closed and only a reconciler
|
||||||
|
# may clear it, so a quoted mention must never create one (PR #789 F1).
|
||||||
|
for command in F1_QUOTED_COMMANDS:
|
||||||
|
_clear_marker()
|
||||||
|
res = srv.gitea_record_daemon_process_kill_attempt(command=command, remote="prgs")
|
||||||
|
assert res["contaminated"] is False, command
|
||||||
|
assert res["marked"] is False, command
|
||||||
|
assert srv._load_runtime_recovery_marker("prgs") is None, command
|
||||||
|
|
||||||
|
|
||||||
def test_record_tool_marks_broad_sweep():
|
def test_record_tool_marks_broad_sweep():
|
||||||
_clear_marker()
|
_clear_marker()
|
||||||
res = srv.gitea_record_daemon_process_kill_attempt(
|
res = srv.gitea_record_daemon_process_kill_attempt(
|
||||||
|
|||||||
Reference in New Issue
Block a user