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/marketplace.json
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@
"name": "PACT",
"source": "./pact-plugin",
"description": "Orchestration harness that turns Claude Code into a coordinated team of specialist AI agents",
"version": "4.4.53",
"version": "4.4.54",
"author": {
"name": "Synaptic-Labs-AI"
},
Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -605,7 +605,7 @@ When installed as a plugin, PACT lives in your plugin cache:
│ └── cache/
│ └── pact-plugin/
│ └── PACT/
│ └── 4.4.53/ # Plugin version
│ └── 4.4.54/ # Plugin version
│ ├── agents/
│ ├── commands/
│ ├── skills/
Expand Down
2 changes: 1 addition & 1 deletion pact-plugin/.claude-plugin/plugin.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "PACT",
"version": "4.4.53",
"version": "4.4.54",
"description": "Orchestration harness that turns Claude Code into a coordinated team of specialist AI agents",
"author": {
"name": "Synaptic-Labs-AI",
Expand Down
2 changes: 1 addition & 1 deletion pact-plugin/README.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# PACT — Orchestration Harness for Claude Code

> **Version**: 4.4.53
> **Version**: 4.4.54

Turn a single Claude Code session into a managed team of specialist AI agents that prepare, design, build, and test your code systematically.

Expand Down
81 changes: 62 additions & 19 deletions pact-plugin/hooks/shared/merge_guard_common.py
Original file line number Diff line number Diff line change
Expand Up @@ -103,15 +103,16 @@
- `cd /repo && git push origin --delete main`
- `git fetch && git push --mirror origin`
- `NOTE=x ; git push origin :main`
- `cd /repo && git branch -Df temp` (cluster force-delete; idiomatic `-D` still caught match-anywhere)
- `cd /repo && gh pr close 5 -d` (short `-d` close; spelled `--delete-branch` still caught match-anywhere)
- `cd /repo && git branch -Df temp` (cluster force-delete; idiomatic `-D` still caught per-leg, in any leg)
- `cd /repo && gh pr close 5 -d` (short `-d` close; spelled `--delete-branch` still caught per-leg, in any leg)
These are NOT bugs — do NOT "fix" them (the fix re-blocks faithful clicks). httpie
(`http` / `https` CLI) is likewise WHOLLY ungated by design — ref-mutation, merge, AND
protection-mutation — because the MINT classifier covers gh-api / curl / wget only; ANY
httpie read-floor arm re-creates a gated-but-unmintable over-block. Ungated keeps read == mint.
NB this first-leg anchoring is SPECIFIC to those parse-dependent forms: the LITERAL
DANGEROUS_PATTERNS arms (force-push, branch -D, gh pr merge/close, push-to-main, the API
ref/protection arms) match-anywhere and STILL gate in a non-first leg
danger arms (the DANGEROUS_PATTERNS bank + the per-leg literal-arm tuples: force-push,
branch-delete, close, API ref/protection) match in ANY leg position (per-leg for the
tuple arms) and STILL gate in a non-first leg
(`cd /repo && git push --force origin main` is caught). When an over-block of a faithful
click is found, the fix WIDENS the mint, never narrows detection into a new under-block.
=============================================================================
Expand Down Expand Up @@ -397,6 +398,28 @@
# - gh alias: aliases can hide API calls (tracked in #270)
)

# leg-boundary substrate (#1094): these three branch-delete arms' `.*` previously
# ran over the WHOLE stripped command, so an idiomatic `-D` / `--delete --force`
# token in a benign continuation leg (`git branch new-feature && echo -D`) gated
# the benign compound — and, because the whole-command classifier ALSO returned
# branch-delete for it, the ambiguous compound was MINTABLE from a click whose
# branch leg was benign (the close-twin laundering shape). Matched PER-LEG now:
# an arm fires iff `git branch` and the force-delete flag co-occur within ONE
# leg, in ANY leg position (`cd /repo && git branch -D temp` still gates —
# match-anywhere purpose preserved, per leg). Quoted separators are handled by
# the substrate (a quoted `&&` is not a leg boundary). Bodies are moved VERBATIM
# from DANGEROUS_PATTERNS — the conversion changes WHERE they match (per-leg vs
# whole-command), never WHAT they match. Per-leg scoping also bounds the arms'
# `\s+` runs: whole-command they DID span a raw newline — the one whitespace-class
# leg separator — so `git branch --force<newline>--delete x` (two non-destructive
# legs) previously gated; bounding `\s+` is the same over-block-cure direction. Clustered /
# split spellings (`-Df` / `-fD` / `--delete -f`) are NOT these arms' job: they
# are the union arm's (first-leg-anchored, by design).
_BRANCH_DELETE_LITERAL_ARMS = (
re.compile(_GIT_PREFIX + r"branch\s+.*-D\b"),
re.compile(_GIT_PREFIX + r"branch\s+.*--delete\s+--force\b"),
re.compile(_GIT_PREFIX + r"branch\s+--force\s+--delete\b"),
)


def detect_command_operation_type(command: str) -> str | None:
Expand Down Expand Up @@ -448,14 +471,18 @@ def detect_command_operation_type(command: str) -> str | None:
# write-side classifier. Branch-delete-via-pr-close is folded into
# the close class on both sides for symmetric authorization.
return "close"
# Legs are computed ONCE here and shared by the three per-leg loops below
# (force-push, api-merge, branch-delete) — the same single-substrate hoist
# idiom as the read floor's `legs` in is_dangerous_command.
legs = _split_into_legs(command)
# force-push: git push ... --force (excludes --force-with-lease — carved out
# of the force-push arms ONLY; the push-to-main arm still gates lease pushes
# to a default branch). Matched PER-LEG over the shared _FORCE_PUSH_LITERAL_ARMS
# SSOT (#1082) — the same arm list the read floor consumes, over the same leg
# substrate (_split_into_legs), so a force-class flag in a benign continuation
# leg no longer classifies the first-leg push as force-push and read==mint
# holds by construction on this class.
for _leg in _split_into_legs(command):
for _leg in legs:
if any(arm.search(_leg) for arm in _FORCE_PUSH_LITERAL_ARMS):
return "force-push"
# Direct push to a default branch (main/master) — plain OR --force-with-lease —
Expand Down Expand Up @@ -531,17 +558,21 @@ def detect_command_operation_type(command: str) -> str | None:
# `gh -R o/r api` global-flag spelling also mints) + IGNORECASE PUT/PATCH/POST +
# case-SENSITIVE pulls/<N>/merge path. DELETE excluded; the implicit-POST (-f/--data,
# no method keyword) spelling is a deliberate residual, per the git/refs detect arm.
for _leg in _split_into_legs(command):
for _leg in legs:
if _api_merge_leg_endpoint(_leg) is not None:
return "merge"
# branch-delete: git branch -D, git branch --delete --force,
# or git branch --force --delete (matches DANGEROUS_PATTERNS).
if re.search(_GIT_PREFIX + r"branch\s+.*-D\b", command):
return "branch-delete"
if re.search(_GIT_PREFIX + r"branch\s+.*--delete\s+--force\b", command):
return "branch-delete"
if re.search(_GIT_PREFIX + r"branch\s+--force\s+--delete\b", command):
return "branch-delete"
# branch-delete: git branch -D / --delete --force / --force --delete. Matched
# PER-LEG over the shared _BRANCH_DELETE_LITERAL_ARMS SSOT (#1094) — the same
# arm tuple the read floor consumes, over the same leg substrate
# (_split_into_legs), so a stray force-delete token in a benign continuation
# leg no longer classifies the compound as branch-delete, and read==mint
# holds by construction on this class (a command is gated via these arms iff
# some stripped leg matches iff detect classifies branch-delete here).
# Clustered spellings (-Df / -fD / --delete -f) fall through to the union-arm
# fallback below, same as the read floor.
for _leg in legs:
if any(arm.search(_leg) for arm in _BRANCH_DELETE_LITERAL_ARMS):
return "branch-delete"
# Quote-aware normalized-flag FALLBACK (ADDITIVE, INV-AU): catches the
# clustered/split flag spellings the literal regexes above miss — chiefly
# `git branch -Df`/`-fD`/`--delete -f` (force-delete), which `-D\b` and the
Expand Down Expand Up @@ -1647,10 +1678,10 @@ def cleanup_orphan_tokens(
# Force push arms live in _FORCE_PUSH_LITERAL_ARMS (defined with the classifier
# patterns above) — matched PER-LEG by is_dangerous_command after this list
# misses (#1082 leg isolation), NOT whole-string here.
# Force branch deletion
re.compile(_GIT_PREFIX + r"branch\s+.*-D\b"),
re.compile(_GIT_PREFIX + r"branch\s+.*--delete\s+--force\b"),
re.compile(_GIT_PREFIX + r"branch\s+--force\s+--delete\b"),
# Force-branch-delete arms live in _BRANCH_DELETE_LITERAL_ARMS (defined with the
# classifier patterns above) — matched PER-LEG by is_dangerous_command after this
# list misses (#1094 leg isolation), NOT whole-string here (a whole-command match
# fired cross-leg on a stray -D / --delete --force token in a benign leg).
# API danger arms (merge / git-refs / branch-protection / contents / implicit-POST,
# across gh api / curl / wget) live in _API_LITERAL_ARMS (defined with the classifier
# patterns above) — matched PER-LEG by is_dangerous_command after this list misses
Expand Down Expand Up @@ -2528,7 +2559,8 @@ def is_dangerous_command(command: str) -> bool:
# and substrate legs can never diverge, and the strip is not recomputed. An arm
# fires iff push and the force-class flag co-occur within ONE leg, in ANY leg
# position (the match-anywhere purpose, per leg). The legs are computed ONCE
# here and shared by all three literal-arm loops below (force-push, close, API).
# here and shared by all four literal-arm loops below (force-push, close, API,
# branch-delete).
legs = _slice_stripped_legs(stripped)
for _leg in legs:
if any(arm.search(_leg) for arm in _FORCE_PUSH_LITERAL_ARMS):
Expand All @@ -2550,6 +2582,17 @@ def is_dangerous_command(command: str) -> bool:
for _leg in legs:
if any(arm.search(_leg) for arm in _API_LITERAL_ARMS):
return True
# Literal branch-delete arms, matched PER-LEG (#1094): same leg substrate and
# strip provenance as the loops above. An arm fires iff `git branch` and the
# force-delete flag co-occur within ONE leg — a stray `-D` / `--delete --force`
# token in a benign continuation leg no longer gates the compound (and the
# formerly-mintable ambiguous compound is is_dangerous=False, so the mint
# write-gate refuses → no token → the laundering substrate is structurally
# closed), while a same-leg force-delete still gates in ANY leg position.
# Clustered/split flag spellings remain the union arm's job (below).
for _leg in legs:
if any(arm.search(_leg) for arm in _BRANCH_DELETE_LITERAL_ARMS):
return True
# ADDITIVE union arm (INV-AU): a quote-aware normalized-flag danger CONDITION across
# every flag spelling the literal floor misses — `-d`/`-cd` close delete, `-Df`/`-fD`/
# `--delete -f` branch force-delete. Runs on the STRIPPED surface (same as the floor)
Expand Down
44 changes: 44 additions & 0 deletions pact-plugin/tests/test_merge_guard_auth_symmetry.py
Original file line number Diff line number Diff line change
Expand Up @@ -1280,6 +1280,50 @@ def test_close_laundering_closed_is_non_vacuous_under_whole_command_match(
"whole-command close match did not re-open the laundering channel"
)

def test_branch_delete_laundering_closed_is_non_vacuous_under_whole_command_match(
self, tmp_path, monkeypatch):
"""READ-FLOOR laundering closure for the #1094 branch-delete per-leg
conversion — the close-twin shape, over the REAL mint + read seams.
Pre-fix, the ambiguous compound `git branch new-feature && echo -D`
(a benign branch CREATE plus an echo) was is_dangerous=True
whole-command AND classified branch-delete with an extractable target,
so approving it MINTED a branch-delete token that authorized the
escalated same-target single `git branch -D new-feature`. Direction 1 —
fix present: the ambiguous source is is_dangerous=False per-leg, the
approval mints NOTHING (post-hook exit==0, mint==0 — the write-gate
refusal), and the escalated single DENIES for lack of any token.
Direction 2 — identity-slice restores the pre-fix whole-command match:
the source gates again, the approval MINTS, and the escalated single
AUTHORIZES — the laundering channel RE-OPENS, proving the closure is
coupled to the per-leg conversion, not vacuously green."""
from shared.merge_guard_common import is_dangerous_command
ambiguous = "git branch new-feature && echo -D"
escalated = "git branch -D new-feature"
q = "Create the branch?"
opts = [_opt("Yes, create", f"On approval run: `{ambiguous}`"), _opt("Cancel", "Abort")]
# direction 1 — fix present: not-dangerous, mints nothing, escalation denies
assert is_dangerous_command(ambiguous) is False
assert _invoke_post([_q(q, opts)], {q: "Yes, create"}, tmp_path) == 0
assert len(_minted_tokens(tmp_path)) == 0
assert _authorize(escalated, tmp_path) is not None
# direction 2 — pre-fix whole-command match restored: laundering re-opens
import shared.merge_guard_common as common_mod
monkeypatch.setattr(common_mod, "_slice_stripped_legs", lambda s: [s])
assert is_dangerous_command(ambiguous) is True, (
"identity-slice did not restore the whole-command branch-delete match — "
"the branch-delete laundering-closed canary would be vacuous"
)
tmp2 = tmp_path / "prefix-sim"
tmp2.mkdir()
assert _invoke_post([_q(q, opts)], {q: "Yes, create"}, tmp2) == 0
assert len(_minted_tokens(tmp2)) == 1, (
"whole-command branch-delete match did not restore the pre-fix mint — "
"the branch-delete laundering-closed canary would be vacuous"
)
assert _authorize(escalated, tmp2) is None, (
"whole-command branch-delete match did not re-open the laundering channel"
)


class TestPrivilegedFlagReadFloorSeam:
"""The read floor over the REAL on-disk token seam: a forged/seeded approval
Expand Down
Loading