Skip to content

Commit 7f98de2

Browse files
fix(merge-guard): narrow gh-comment carrier-strip for #1037 over-block (v4.4.45) (#1058)
* fix(merge-guard): extend the gh carrier-strip to gh issue/pr comment (#1037) Add gh issue/pr comment to the step-7 _gh_carrier_span verb alternation, mirroring the existing create/edit handling. The comment --body value is a non-executing GitHub-API string, so blanking it before the dangerous-pattern scan clears the over-block false-positive without weakening detection. The strip is doubly-anchored (span must start with a gh issue/pr carrier verb AND only blanks the value directly after --body/--title) and inherits the existing indirection guards, so it cannot blank a live op: an op outside the body, a process-substitution/command-substitution inside the body, and the general grep/echo cases all still block (verified). Narrow replacement for the abandoned general suppressor. * test(merge-guard): regression canaries for the narrow gh-comment carrier-strip (#1037) 18 canaries locking in the gh-comment carrier-strip extension: comment --body FP carriers now allow; the doubly-anchored strip's block cases (op after the body separator, command-substitution inside the body, escaped-quote outside the body) stay blocked; the inert escaped-quote-inside-body allow case; create/edit behavior unchanged. Two non-vacuity proof classes (verb-discrimination + mechanism assertion for allow; guard-flip for block). New file to avoid the shared-file commit race. * chore(release): bump version to 4.4.45 (#1037 narrow gh-comment carrier-strip) * test(merge-guard): add CLASS-1/CLASS-2 flag-anchoring canaries for the gh-comment carrier-strip (#1037) Adds the anchoring canaries from the independent security re-probe: op in a quoted value NOT after a carrier flag, process-substitution hosting a nested executor (incl privileged-flag and force-push forms), and escaped-quote-then-real-op. Each asserts the op survives the strip and blocks. Non-vacuity: a whole-segment loosen of the inner strip flips 4/4 flag-anchoring canaries to under-block (measured) — proving the flag-anchoring, not whole-segment blanking, is what keeps the carrier-strip safe. Test count 18->25; test-only, no version re-bump.
1 parent bee5da8 commit 7f98de2

6 files changed

Lines changed: 317 additions & 11 deletions

File tree

.claude-plugin/marketplace.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@
1212
"name": "PACT",
1313
"source": "./pact-plugin",
1414
"description": "Orchestration harness that turns Claude Code into a coordinated team of specialist AI agents",
15-
"version": "4.4.44",
15+
"version": "4.4.45",
1616
"author": {
1717
"name": "Synaptic-Labs-AI"
1818
},

README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -605,7 +605,7 @@ When installed as a plugin, PACT lives in your plugin cache:
605605
│ └── cache/
606606
│ └── pact-plugin/
607607
│ └── PACT/
608-
│ └── 4.4.44/ # Plugin version
608+
│ └── 4.4.45/ # Plugin version
609609
│ ├── agents/
610610
│ ├── commands/
611611
│ ├── skills/

pact-plugin/.claude-plugin/plugin.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "PACT",
3-
"version": "4.4.44",
3+
"version": "4.4.45",
44
"description": "Orchestration harness that turns Claude Code into a coordinated team of specialist AI agents",
55
"author": {
66
"name": "Synaptic-Labs-AI",

pact-plugin/README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
# PACT — Orchestration Harness for Claude Code
22

3-
> **Version**: 4.4.44
3+
> **Version**: 4.4.45
44
55
Turn a single Claude Code session into a managed team of specialist AI agents that prepare, design, build, and test your code systematically.
66

pact-plugin/hooks/merge_guard_pre.py

Lines changed: 15 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -488,10 +488,10 @@ def _strip_herestring_sq(match: re.Match) -> str:
488488
result,
489489
)
490490

491-
# 7. Strip gh issue/pr CREATION-carrier quoted arguments.
492-
# `gh issue create/edit` and `gh pr create` accept --title/--body
493-
# (and the -t/-b aliases) whose VALUE is prose sent to the GitHub API
494-
# — never executed by a shell. A dangerous-op literal named inside
491+
# 7. Strip gh issue/pr CREATION/COMMENT-carrier quoted arguments.
492+
# `gh issue create/edit/comment` and `gh pr create/comment` accept
493+
# --title/--body (and the -t/-b aliases) whose VALUE is prose sent to the
494+
# GitHub API — never executed by a shell. A dangerous-op literal named inside
495495
# that prose (e.g. `gh issue create --title "...git branch -D x..."`)
496496
# must not trip DANGEROUS_PATTERNS. Strip the quoted value; keep the
497497
# verb + flag tokens visible.
@@ -532,10 +532,18 @@ def _strip_herestring_sq(match: re.Match) -> str:
532532
# nested `*` has no backtracking ambiguity (linear; no ReDoS). The
533533
# double-quoted alternative honors `\"` escapes, matching bash's
534534
# escaped-quote semantics so the regex cannot desync from the shell.
535-
# Verb alternation: issue create|edit, pr create. NOT pr close —
536-
# `close` is absent by construction so a close command never matches.
535+
# Verb alternation: issue create|edit|comment, pr create|comment. NOT pr
536+
# close — `close` is absent by construction so a close command never
537+
# matches. `comment` is a non-executing carrier exactly like create/edit:
538+
# its --body/-b value is API prose, and the SAME doubly-anchored strip
539+
# (carrier verb + value DIRECTLY after --body/-t/-b) + quote-aware span +
540+
# $()/backtick-preserve guard apply, so it inherits the create/edit
541+
# safety — empirically verified: escaped-quote/escaped-dq/metachar bodies
542+
# are handled correctly (op inside a dq/sq body is inert and stripped; an
543+
# op OUTSIDE the body, after an unquoted separator OR a bare escaped quote
544+
# not following a carrier flag, is NEVER stripped and stays caught).
537545
_gh_carrier_span = (
538-
r"gh\s+(?:issue\s+(?:create|edit)|pr\s+create)\b"
546+
r"gh\s+(?:issue\s+(?:create|edit|comment)|pr\s+(?:create|comment))\b"
539547
r"""(?:[^&|;\n"']+|"(?:[^"\\]|\\.)*"|'[^']*')*"""
540548
)
541549

Lines changed: 298 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,298 @@
1+
"""
2+
Location: pact-plugin/tests/test_merge_guard_1037_narrow.py
3+
Summary: Regression canaries for the NARROW gh-comment carrier-strip (#1037) — the
4+
doubly-anchored step-7 `_gh_carrier_span` extension that adds
5+
`gh issue comment` + `gh pr comment` to the carrier verb alternation.
6+
Locks in: gh-comment --title/--body/-t/-b prose that NAMES a dangerous op
7+
now ALLOWs; every executing-op vector still BLOCKs; create/edit unchanged.
8+
The general HYBRID suppressor approach was abandoned (a single-line regex
9+
cannot model bash grammar — it shipped under-blocks); this narrow
10+
carrier-strip is the safe partial fix for the gh-comment over-block.
11+
Used by: pytest (merge-guard suite).
12+
13+
Non-vacuity:
14+
* ALLOW canaries (proof-class P): the comment-verb membership is what flips them.
15+
Proven dynamically by VERB-DISCRIMINATION (a carrier verb ALLOWs an inert
16+
--body; the SAME --body under a non-carrier sibling verb `gh pr edit` BLOCKs)
17+
+ a mechanism assertion (_strip blanks the --body value to STRIPPED, so the
18+
dangerous literal never reaches DANGEROUS_PATTERNS). Build-time source-revert
19+
MEASUREMENT (remove the comment verbs from `_gh_carrier_span`): {5 of 5 ALLOW
20+
flip to BLOCK}. `_gh_carrier_span` is FUNCTION-LOCAL to
21+
_strip_non_executable_content, so this is a measured/documented counter-test
22+
(the discrimination is the runnable equivalent), not an in-test monkeypatch.
23+
* BLOCK canaries (proof-class C): a broaden/neuter flips a preserved-block to
24+
ALLOW, OR (span-stop cases) a discrimination shows the op moves outside the
25+
stripped value — see each test's inline note for the EXACT proven mechanism.
26+
27+
Dangerous literals are authored IN-FILE only (never on a Bash command line — the
28+
installed merge-guard hook false-positives on them). Backslash and single-quote
29+
are built from char vars so the escaped-quote vectors are unambiguous.
30+
"""
31+
import sys
32+
from pathlib import Path
33+
34+
sys.path.insert(0, str(Path(__file__).parent.parent / "hooks"))
35+
36+
import pytest
37+
38+
_BS = "\\" # one literal backslash
39+
_SQ = "'"
40+
41+
42+
class TestNarrowGhCommentCarrierStripAllow:
43+
"""The fix: `gh issue|pr comment --body/-b` prose that NAMES a dangerous op is
44+
now ALLOWED (was a false-positive BLOCK). NEGATIVE assertions — non-vacuity via
45+
the verb-discrimination + mechanism assertion below."""
46+
47+
ALLOW = [
48+
'gh pr comment 5 --body "see gh pr merge 5 for context"',
49+
'gh issue comment 12 --body "do not git push --force origin main"',
50+
'gh pr comment 5 -b "ship after gh pr merge 9"',
51+
"gh issue comment 7 --body 'note: gh pr merge 3 later'", # single-quoted body
52+
]
53+
54+
@pytest.mark.parametrize("command", ALLOW)
55+
def test_comment_body_naming_dangerous_op_now_allows(self, command):
56+
from merge_guard_pre import (
57+
is_dangerous_command,
58+
is_compound_destructive_command,
59+
)
60+
61+
assert not is_dangerous_command(command)
62+
assert not is_compound_destructive_command(command)
63+
64+
def test_carrier_strip_neutralizes_the_body(self):
65+
# mechanism (exclusion-guard, #933): the comment carrier-strip BLANKS the
66+
# --body value to STRIPPED, so the dangerous literal never reaches the scan.
67+
from merge_guard_pre import _strip_non_executable_content
68+
69+
stripped = _strip_non_executable_content('gh pr comment 5 --body "gh pr merge 5"')
70+
assert "gh pr merge" not in stripped
71+
assert "STRIPPED" in stripped
72+
assert stripped.startswith("gh pr comment 5")
73+
74+
@pytest.mark.parametrize(
75+
"carrier,sibling",
76+
[
77+
(
78+
'gh pr comment 5 --body "ref gh pr merge 5"',
79+
'gh pr edit 5 --body "ref gh pr merge 5"',
80+
),
81+
(
82+
'gh pr comment 8 --body "ref git push --force origin main"',
83+
'gh pr edit 8 --body "ref git push --force origin main"',
84+
),
85+
],
86+
)
87+
def test_comment_verb_membership_is_load_bearing(self, carrier, sibling):
88+
# non-vacuity (proof-class P): the comment verb being IN the alternation is
89+
# what flips these. The identical --body under a NON-carrier sibling verb
90+
# (`gh pr edit`, absent from the pr alternation) is NOT stripped and BLOCKs.
91+
# This is the runnable equivalent of reverting the comment verbs from
92+
# `_gh_carrier_span` (build-time measured: {5 of 5 ALLOW flip to BLOCK}).
93+
from merge_guard_pre import is_dangerous_command
94+
95+
assert not is_dangerous_command(carrier) # carrier verb -> stripped -> ALLOW
96+
assert is_dangerous_command(sibling) # non-carrier verb -> survives -> BLOCK
97+
98+
99+
class TestNarrowGhCommentCarrierStripBlock:
100+
"""No under-block: every executing-op vector still BLOCKs. The carrier-strip is
101+
DOUBLY-ANCHORED (carrier verb + value DIRECTLY after --title/--body/-t/-b) and
102+
the span stops at the first UNQUOTED separator, so an op outside the carrier
103+
value always survives into DANGEROUS_PATTERNS (the authority)."""
104+
105+
def test_op_after_body_unquoted_separator_blocks(self):
106+
# non-vacuity (proof-class C, span-stop discrimination): the op INSIDE the
107+
# body ALLOWs; the SAME op after an UNQUOTED `;` BLOCKs (the span stops at
108+
# the separator, the op falls OUTSIDE the stripped value and survives).
109+
from merge_guard_pre import (
110+
is_dangerous_command,
111+
is_compound_destructive_command,
112+
_strip_non_executable_content,
113+
)
114+
115+
inside = 'gh pr comment 5 --body "x gh pr merge 5"'
116+
outside = 'gh pr comment 5 --body "x" ; gh pr merge 5'
117+
assert not is_dangerous_command(inside) # op inside the body -> ALLOW
118+
assert is_compound_destructive_command(outside) # op after `;` -> BLOCK
119+
assert "gh pr merge 5" in _strip_non_executable_content(outside) # op survives
120+
121+
def test_op_after_body_andand_blocks(self):
122+
from merge_guard_pre import is_compound_destructive_command
123+
124+
assert is_compound_destructive_command('gh pr comment 5 --body "x" && gh pr merge 9')
125+
126+
def test_command_substitution_in_body_blocks(self, monkeypatch):
127+
# non-vacuity (proof-class C): the dq inner-strip PRESERVES a $()/backtick
128+
# body via `_has_command_substitution` (it executes inside double quotes).
129+
# Neuter that guard -> the body is blanked -> ALLOW (MEASURED flip), proving
130+
# the preserve guard is the load-bearing reason this stays BLOCK.
131+
from merge_guard_pre import is_dangerous_command
132+
133+
cmd = 'gh pr comment 5 --body "$(gh pr merge 5)"'
134+
assert is_dangerous_command(cmd)
135+
monkeypatch.setattr("merge_guard_pre._has_command_substitution", lambda q: False)
136+
assert not is_dangerous_command(cmd) # flips ALLOW under the neuter
137+
138+
def test_escaped_quote_outside_carrier_flag_blocks(self):
139+
# an escaped quote NOT after a carrier flag does not open a carrier-flag
140+
# value, so the op after the (real, unquoted) separator survives the strip.
141+
# non-vacuity (mechanism): the op literal is present in the _strip output.
142+
from merge_guard_pre import is_dangerous_command, _strip_non_executable_content
143+
144+
cmd = "gh pr comment 5 --foo a" + _BS + _SQ + " ; gh pr merge 5 " + _BS + _SQ + "b"
145+
assert is_dangerous_command(cmd)
146+
assert "gh pr merge 5" in _strip_non_executable_content(cmd)
147+
148+
def test_close_delete_branch_blocks(self):
149+
# `gh pr close` is NOT a carrier verb (absent from the alternation BY
150+
# CONSTRUCTION); `--delete-branch` is the deny trigger -> BLOCK.
151+
from merge_guard_pre import is_dangerous_command
152+
153+
assert is_dangerous_command("gh pr close 5 --delete-branch")
154+
155+
def test_python_dash_c_blocks(self):
156+
# python -c EXECUTES its arg; not a carrier verb -> not stripped -> BLOCK.
157+
# `print('...')` style per the security ratification (an `x='...'` form would
158+
# be removed by the pre-existing var-assignment carrier and is a weaker canary).
159+
from merge_guard_pre import is_dangerous_command
160+
161+
assert is_dangerous_command('python3 -c "print(' + _SQ + "gh pr merge 42" + _SQ + ')"')
162+
163+
def test_general_grep_papercut_still_blocks(self):
164+
# SUPPRESSOR-NEUTRAL regression guard: a bare `grep "...op..."` is a known
165+
# #1037 over-block papercut the NARROW fix deliberately does NOT address
166+
# (out of scope — only gh-comment). The carrier-strip never touches grep (not
167+
# a gh carrier verb), so NO carrier mutation flips this; it is caught by the
168+
# base substring scan. Asserted so a future change cannot silently ALLOW grep
169+
# without the proper fix. (NOTE: `echo "...op..."` is correctly ALLOWED by the
170+
# pre-existing echo carrier-strip — it is NOT a BLOCK canary.)
171+
from merge_guard_pre import is_dangerous_command
172+
173+
assert is_dangerous_command('grep "gh pr merge 5" file')
174+
175+
176+
class TestNarrowGhCommentInertAllow:
177+
"""Ratified INERT-ALLOW: an escaped quote INSIDE a dq --body value is a literal
178+
char in bash, so the entire dq is ONE inert --body value (the op is never a shell
179+
token). ALLOW is CORRECT, NOT an under-block."""
180+
181+
def test_escaped_quote_inside_body_is_inert_allow(self):
182+
# `gh pr comment 5 --body "a\' ; gh pr merge 5 \'b"` — the `\'` is literal
183+
# inside the dq; the whole dq string is the --body API value; the op cannot
184+
# execute. The carrier-strip blanks the whole inert value -> ALLOW. This is
185+
# NOT an under-block: in bash the op never becomes a command.
186+
from merge_guard_pre import is_dangerous_command, _strip_non_executable_content
187+
188+
cmd = (
189+
"gh pr comment 5 --body " + '"' + "a" + _BS + _SQ
190+
+ " ; gh pr merge 5 " + _BS + _SQ + "b" + '"'
191+
)
192+
assert not is_dangerous_command(cmd)
193+
assert "gh pr merge" not in _strip_non_executable_content(cmd)
194+
195+
196+
class TestNarrowGhCarrierCreateEditUnchanged:
197+
"""Regression guard: the narrow change only ADDED comment to the alternation;
198+
create/edit carrier behavior is unchanged."""
199+
200+
CREATE_EDIT = [
201+
'gh issue create --title "regression: gh pr merge 5 in title"',
202+
'gh pr create --body "do not git push --force origin main"',
203+
'gh issue edit 4 --body "ref gh pr merge 2"',
204+
]
205+
206+
@pytest.mark.parametrize("command", CREATE_EDIT)
207+
def test_create_edit_carrier_still_allows(self, command):
208+
from merge_guard_pre import is_dangerous_command
209+
210+
assert not is_dangerous_command(command)
211+
212+
213+
class TestNarrowGhCommentSecurityAnchoringCanaries:
214+
"""Security re-probe (#36 SAFE 42-case differential) anchoring canaries directed
215+
at the COMMENT carrier — the two classes that bit the ABANDONED general
216+
suppressor and that the narrow's FLAG-ANCHORED inner strip (blanks ONLY the
217+
value DIRECTLY after --title/--body/-t/-b) neutralizes. Highest-value guards:
218+
they flip RED if the inner strip were ever loosened from FLAG-ANCHORED to
219+
WHOLE-SEGMENT (blank any quoted region in the span) — the precise mutation that
220+
distinguishes the SAFE narrow carrier-strip from the abandoned general
221+
suppressor (which blanked ALL quoted literals in a segment, so it under-blocked).
222+
223+
Non-vacuity: each canary asserts BLOCK + the op LITERAL SURVIVES the strip
224+
(present in _strip output, so DANGEROUS_PATTERNS catches it — proving the
225+
flag-anchoring did NOT blank it). The distinguishing mutation is MEASURED at
226+
build time: loosening the inner-strip flag-anchor
227+
(`(?:--title|--body|-t|-b)\\s+` -> `\\s*`, i.e. blank ANY quoted region) flips
228+
{4 of 4} flag-anchoring canaries (2 CLASS-1 non-carrier-flag + 2 CLASS-2
229+
`<(...)`) BLOCK->ALLOW = under-block, while the `>(...)` output-procsub stays
230+
BLOCK (defense-in-depth: the whole-command process-sub-to-shell guard skips the
231+
strip entirely). The inner strip is FUNCTION-LOCAL to
232+
_strip_non_executable_content, so the loosen is a documented measurement and
233+
op-survives-the-strip is the runnable equivalent.
234+
"""
235+
236+
# CLASS-1: a dangerous op in a QUOTED value NOT directly after a carrier flag
237+
# (flag-anchored strip leaves it; a whole-segment strip would blank it).
238+
CLASS1_FLAG_ANCHORED = [
239+
'gh pr comment 5 --foo "gh pr merge 5"',
240+
'gh pr comment 5 --body "ok" --foo "gh pr merge 9"',
241+
]
242+
# CLASS-2: process-substitution running its OWN command (incl --admin / the
243+
# #1042 bypass and git push --force); the `<(...)` op survives the strip.
244+
CLASS2_PROCSUB = [
245+
'gh pr comment 5 --body "x" <(bash -c "gh pr merge 5 --admin")',
246+
'gh pr comment 5 --body "x" <(bash -c "git push --force origin main")',
247+
]
248+
249+
@pytest.mark.parametrize("command", CLASS1_FLAG_ANCHORED + CLASS2_PROCSUB)
250+
def test_flag_anchoring_canary_blocks_and_op_survives(self, command):
251+
# BLOCK + the op SURVIVES the strip (flag-anchoring did NOT blank it). The
252+
# whole-segment-loosen mutation flips these {4 of 4} -> ALLOW (measured).
253+
from merge_guard_pre import (
254+
is_dangerous_command,
255+
is_compound_destructive_command,
256+
_strip_non_executable_content,
257+
)
258+
259+
assert is_dangerous_command(command) or is_compound_destructive_command(command)
260+
stripped = _strip_non_executable_content(command)
261+
assert ("gh pr merge" in stripped) or ("git push" in stripped) # op survives
262+
263+
def test_escaped_quote_then_real_op_blocks(self):
264+
# CLASS-1 escaped-quote-then-real-op (span-stop): a `\'` desync ends the sq
265+
# (bash sq has no escapes), so the op after the real `;` is BARE and OUTSIDE
266+
# the carrier span -> survives -> BLOCK. (Flips under a different mutation,
267+
# span-consume-past-separator, not the whole-segment-loosen.)
268+
from merge_guard_pre import (
269+
is_dangerous_command,
270+
is_compound_destructive_command,
271+
_strip_non_executable_content,
272+
)
273+
274+
cmd = "gh pr comment 5 --body " + _SQ + "a" + _BS + _SQ + " ; gh pr merge 5"
275+
assert is_dangerous_command(cmd) or is_compound_destructive_command(cmd)
276+
assert "gh pr merge 5" in _strip_non_executable_content(cmd)
277+
278+
def test_op_after_body_with_admin_blocks(self):
279+
# #1042 bypass shape (--admin) chained after the comment body -> BLOCK.
280+
from merge_guard_pre import (
281+
is_compound_destructive_command,
282+
_strip_non_executable_content,
283+
)
284+
285+
cmd = 'gh pr comment 5 --body "ok" ; gh pr merge 5 --admin'
286+
assert is_compound_destructive_command(cmd)
287+
assert "gh pr merge 5 --admin" in _strip_non_executable_content(cmd)
288+
289+
def test_output_procsub_blocks_defense_in_depth(self):
290+
# `>(bash -c ...)` output process-sub: BLOCK via DEFENSE-IN-DEPTH — the
291+
# whole-command process-sub-to-shell guard skips the carrier-strip entirely
292+
# AND the op is outside any flag-anchored value. Stays BLOCK under the
293+
# whole-segment-loosen alone (a combined procsub-guard-neuter is needed too).
294+
from merge_guard_pre import is_dangerous_command, _strip_non_executable_content
295+
296+
cmd = 'gh pr comment 5 --body "x" >(bash -c "gh pr merge 9")'
297+
assert is_dangerous_command(cmd)
298+
assert "gh pr merge 9" in _strip_non_executable_content(cmd)

0 commit comments

Comments
 (0)