Skip to content

Commit b01639d

Browse files
fix(merge-guard): bind privileged flags (--admin/-R/--no-verify) to the approval (#1042) (#1048)
* fix(merge-guard): add privileged-flag scanner + denylist to shared SSOT (#1042) Add PRIVILEGED_FLAGS (op-class-scoped MUST-BIND denylist) and a linear extract_privileged_flags scanner, surfaced as a bound_flags key on extract_command_context (computed once; both hook arms inherit). Behaviour-neutral foundation for never-escalate flag binding. * fix(merge-guard): enforce never-escalate flag set-equality in read gate (#1042) _token_matches_command refuses when executed bound_flags differ from approved (added privilege or dropped constraint), checked after op-type and before target. Closes the --admin/-R/--no-verify ride-past bypass. Repairs 10 pre-existing tests that incidentally used now-bound flags; axis-under-test preserved. * fix(merge-guard): scan full approval surface for bound flags at mint (#1042) _mint_context_from_bundle widens the privileged-flag scan to the full selected-option text so a flag after a quoted argument is not lost to region truncation; op/target stay region-anchored. Restores read/mint scan symmetry. * test(merge-guard): RED bypass matrix + non-vacuity for privileged-flag binding (#1042) Add the privileged-flag bypass RED matrix (read-arm REFUSE for --admin / -R / --no-verify / git-abbreviation / combined-short clusters; positive AUTHORIZE form-invariance; scanner canonical-form pins; multiplicity-attribute), A1 mint-symmetry through the real mint seam, an extract_privileged_flags linearity witness, and restore non-vacuity to 5 PR-mismatch siblings. Per-mechanism non-vacuity measured by source-revert (C2 gate -> 22 RED, C3 mint -> 1 RED, is_git_surface mutation -> 9 RED). Full suite 9756 passed, 0 errors. * chore(release): 4.4.44 — merge-guard privileged-flag binding (#1042) * test(merge-guard): cover -R=value short =-joined + a CLOSE real-mint witness (#1042) Review-cycle coverage hardening from the coverage-matrix pass: add the previously-uncovered -R=value short =-joined scanner branch (read-arm bypass + merge/close scanner pins + positive form-invariance) and a governed CLOSE -R real-mint witness (round-trip AUTHORIZE + redirect-add REFUSE), removing the op-agnostic-transfer dependency for the value-flag class on a second op-class. * fix(merge-guard): bind --match-head-commit to close the dropped-constraint case (#1042) Add --match-head-commit (value-taking) to the merge denylist. An approval carrying --match-head-commit <sha> that is then executed without it now REFUSES via set-equality on the bound flag, closing the silent head-SHA-constraint drop. * test(merge-guard): dropped-constraint REFUSE coverage for --match-head-commit (#1042) Add TestMatchHeadCommitDroppedConstraint: approve-with/execute-without REFUSE, added-direction REFUSE, value-mismatch REFUSE (proves the value binds), identical AUTHORIZE, plus spaced + =-joined scanner pins. Non-vacuity measured: a source-only revert of the denylist entry reds exactly 5 of these. The approval token is scanner-derived (not hand-built) so the refusal is provably coupled to the binding.
1 parent 639e3d2 commit b01639d

11 files changed

Lines changed: 1073 additions & 26 deletions

.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.43",
15+
"version": "4.4.44",
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.43/ # Plugin version
608+
│ └── 4.4.44/ # 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.43",
3+
"version": "4.4.44",
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.43
3+
> **Version**: 4.4.44
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_post.py

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -379,7 +379,16 @@ def _mint_context_from_bundle(questions: list, answers: dict) -> dict | None:
379379
return None
380380

381381
# ── Step 5: extract the single distinct command's context to mint. ──
382-
return extract_command_context(the_command)
382+
# Op/target are derived from `the_command` (region-anchored — preserves the
383+
# anti-distractor multiplicity gate above). The privileged-flag scan (#1042)
384+
# is widened to the FULL selected-option text so a flag positioned after a
385+
# quoted argument (e.g. `gh pr merge 5 --subject "msg" --admin`, written bare)
386+
# is not lost to the bare-command region truncation — the read arm scans the
387+
# full raw command, so the mint must scan a full surface too for symmetry.
388+
return extract_command_context(
389+
the_command,
390+
flag_scan_text=" ".join(selected_option_texts),
391+
)
383392

384393

385394
def write_token(context: dict, token_dir: Path | None = None) -> str | None:

pact-plugin/hooks/merge_guard_pre.py

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1054,6 +1054,18 @@ def _token_matches_command(token: dict, command: str) -> bool:
10541054
if token_op is None or cmd_op is None or token_op != cmd_op:
10551055
return False
10561056

1057+
# (a2) NEVER-ESCALATE FLAG AXIS (#1042) — the executed command's binding-
1058+
# relevant flags must EXACTLY equal the approved set. ANY difference (an added
1059+
# privilege like --admin/-R, OR a dropped constraint) REFUSES. Both sides are
1060+
# computed by the shared extract_command_context SSOT, so they cannot drift.
1061+
# Checked AFTER op-type identity and BEFORE the per-op target returns so it
1062+
# applies uniformly to all four op-classes. A pre-fix token without the key
1063+
# defaults to the empty set, so any privileged execution mismatches -> REFUSE
1064+
# (over-block-safe; tokens expire in 5 min, so no backward-compat is needed).
1065+
# bound_flags is an attribute checked here, never part of pair identity.
1066+
if set(context.get("bound_flags", [])) != set(cmd.get("bound_flags", [])):
1067+
return False
1068+
10571069
# (b) Target axis, per op-class — require a POSITIVE, command-anchored target
10581070
# match. Unextractable or mismatched target -> REFUSE (over-block is the safe
10591071
# #1031 direction; the read side never under-blocks #1032).

pact-plugin/hooks/shared/merge_guard_common.py

Lines changed: 200 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -443,7 +443,190 @@ def _extract_force_push_target_ref(command: str) -> str | None:
443443
return refspec or None
444444

445445

446-
def extract_command_context(command: str) -> dict:
446+
# -----------------------------------------------------------------------------
447+
# Privileged-flag binding (#1042). The (operation_type, target) binding above
448+
# DROPS every dash-flag, so an approved `gh pr merge 5` and an executed
449+
# `gh pr merge 5 --admin` (branch-protection bypass) reduce to the SAME context
450+
# and authorize — the flag rides past the checkpoint undetected. The fix adds
451+
# ONE more binding dimension — `bound_flags` — computed by the SINGLE scanner
452+
# below, called from the SINGLE site in extract_command_context, so BOTH hook
453+
# arms inherit it and can never classify a command's flags differently (the same
454+
# anti-drift property that the shared (op,target) SSOT already guarantees).
455+
#
456+
# PRIVILEGED_FLAGS is the op-class-scoped denylist: { op_type -> { canonical_long
457+
# -> (aliases, value_taking) } }. Membership is PURE DATA — adding or removing a
458+
# flag is a one-line edit with ZERO scanner/predicate changes, so the security
459+
# review owns membership without touching logic. A flag's PRESENCE binds it; the
460+
# read-side set-equality gate then enforces never-escalate.
461+
#
462+
# EXCLUDES op-trigger flags that already change op_type (and are therefore
463+
# already bound through it): --force/-f (force-push), -D (branch-delete), and
464+
# gh pr close's --delete-branch (the close-danger trigger). Listing them here
465+
# would double-bind and needlessly over-block. NB the asymmetry: --delete-branch
466+
# /-d on gh pr MERGE is a post-merge SIDE-EFFECT (deletes the source branch), not
467+
# a merge op-trigger, so it IS bound on the `merge` class — and -d (merge
468+
# delete-branch) is a DIFFERENT op from -D (branch force-delete); op-class scoping
469+
# keeps them from being conflated.
470+
PRIVILEGED_FLAGS: dict[str, dict[str, tuple[tuple[str, ...], bool]]] = {
471+
"merge": {
472+
"--admin": (("--admin",), False), # bypass branch protection
473+
"--delete-branch": (("-d", "--delete-branch"), False), # side-effect: deletes source branch
474+
"--repo": (("-R", "--repo"), True), # cross-repo redirect (value-carrying target)
475+
# value-carrying SAFETY constraint (pins the merge to a head SHA); binding
476+
# it closes the dropped-constraint case — approve with --match-head-commit,
477+
# execute without it -> set-equality REFUSES (#1042).
478+
"--match-head-commit": (("--match-head-commit",), True),
479+
},
480+
"close": {
481+
"--repo": (("-R", "--repo"), True), # cross-repo redirect (value-carrying target)
482+
# --delete-branch is the close-danger op-trigger (bound via op_type) — NOT listed.
483+
},
484+
"force-push": {
485+
"--no-verify": (("--no-verify",), False), # bypass pre-push hook
486+
},
487+
"branch-delete": {
488+
# No bound flags today: branch-delete's privileged effect is its op-trigger
489+
# (-D / --delete --force), already bound via op_type. Kept as an explicit
490+
# extension point so a future bound flag is a one-line data edit here.
491+
},
492+
}
493+
494+
495+
def extract_privileged_flags(command: str, op_type: str | None) -> list[str]:
496+
"""Scan a command for the privileged dash-flags bound on its op-class (#1042).
497+
498+
Returns a SORTED list of canonical flag tokens (boolean flags as their
499+
canonical long form, e.g. ``--admin``; value-taking flags as
500+
``--repo=<value>``). The read side compares these as SETS for exact equality,
501+
so any added privilege OR dropped constraint mismatches and REFUSES.
502+
503+
The scan is a SINGLE linear ``str.split()`` token-walk against the op-class
504+
denylist (``PRIVILEGED_FLAGS``) — constant per-token work, NO regex, no
505+
backtracking — so it preserves the bounded/linear extraction invariant
506+
(INV-D2) the rest of this module is careful about.
507+
508+
Normalizes every CLI form to one canonical token: exact long (``--admin``),
509+
short alias (``-R`` -> ``--repo``), ``=``-joined (``--repo=x`` / ``-R=x``),
510+
attached short value (``-Rx`` -> ``--repo=x``), and combined-short clusters
511+
via a general per-character walk (``-sd`` -> ``--delete-branch``;
512+
``-dR owner/repo`` -> ``--delete-branch`` + ``--repo=owner/repo``) so NO bound
513+
short is ever dropped regardless of cluster ordering. On the GIT surface
514+
ONLY, an unambiguous long-prefix abbreviation is EXPANDED to its canonical
515+
flag (``--no-verif`` -> ``--no-verify``) — this is SECURITY-LOAD-BEARING:
516+
git's parser accepts abbreviation, so a missed match would be a silent
517+
UNDER-block; gh rejects abbreviation, so its surface needs no expansion.
518+
519+
Args:
520+
command: The command (read arm) or full approval surface (mint arm) to
521+
scan. The caller decides which; the scanner treats it as one string.
522+
op_type: The classified operation type, or None. Selects the denylist;
523+
an op_type with no denylist entry (incl. None and the API/un-flagged
524+
classes) yields ``[]``.
525+
526+
Returns:
527+
Sorted list of canonical bound-flag tokens; ``[]`` when none are present.
528+
"""
529+
denylist = PRIVILEGED_FLAGS.get(op_type) if op_type is not None else None
530+
if not denylist:
531+
# op_type is None, unknown, or carries no bound flags (e.g. branch-delete
532+
# today). An empty result binds the empty set — over-block-safe and the
533+
# correct outcome for the API/un-flagged classes.
534+
return []
535+
536+
# Derive the lookup tables from the denylist ONCE per call. All small,
537+
# constant-size structures (the denylist has <=3 entries per op-class), so
538+
# the per-token work below stays O(1).
539+
alias_to_canonical: dict[str, str] = {}
540+
value_taking: set[str] = set()
541+
canonical_long_names: list[str] = []
542+
for canonical, (aliases, takes_value) in denylist.items():
543+
canonical_long_names.append(canonical)
544+
if takes_value:
545+
value_taking.add(canonical)
546+
for alias in aliases:
547+
alias_to_canonical[alias] = canonical
548+
# git's parse-options expands unambiguous long-prefix abbreviations; gh's
549+
# pflag rejects them. Only the git surface needs abbreviation expansion.
550+
is_git_surface = op_type in ("force-push", "branch-delete")
551+
552+
tokens = command.split()
553+
found: set[str] = set()
554+
i = 0
555+
n = len(tokens)
556+
while i < n:
557+
token = tokens[i]
558+
# Non-flag tokens, the bare `-` (stdin) and `--` (end-of-options) marker
559+
# never bind. Skipping `--` is load-bearing: it must NOT prefix-match a
560+
# sole long flag in the abbreviation branch below.
561+
if not token.startswith("-") or token in ("-", "--"):
562+
i += 1
563+
continue
564+
565+
if token.startswith("--"):
566+
# Long flag: exact denylist hit, or — git surface only — an
567+
# unambiguous prefix abbreviation. An inline `=value` is split off.
568+
flag_part, has_eq, inline_value = token.partition("=")
569+
canonical = alias_to_canonical.get(flag_part)
570+
if canonical is None and is_git_surface:
571+
prefix_matches = [
572+
name for name in canonical_long_names if name.startswith(flag_part)
573+
]
574+
# Exactly one match = unambiguous; >1 is ambiguous (git itself
575+
# rejects it, so the command never runs) and binds nothing.
576+
if len(prefix_matches) == 1:
577+
canonical = prefix_matches[0]
578+
if canonical is None:
579+
i += 1
580+
continue
581+
if canonical in value_taking:
582+
if has_eq: # --repo=value
583+
found.add(f"{canonical}={inline_value}")
584+
i += 1
585+
elif i + 1 < n: # --repo value
586+
found.add(f"{canonical}={tokens[i + 1]}")
587+
i += 2
588+
else: # --repo (value missing; degenerate)
589+
found.add(canonical)
590+
i += 1
591+
else:
592+
found.add(canonical) # boolean (ignore any spurious =value)
593+
i += 1
594+
continue
595+
596+
# Short cluster (single dash): a general per-character walk that subsumes
597+
# the lone short (`-R`), the combined boolean cluster (`-sd`), the
598+
# attached short value (`-Rx`), and any mixed ordering (`-dR`, `-Rd`). A
599+
# value-taking short consumes the REST of the cluster (or the next token)
600+
# as its value and stops the walk — pflag semantics — so no bound short
601+
# is ever dropped from a cluster.
602+
cluster = token[1:]
603+
consumed_next = False
604+
j = 0
605+
while j < len(cluster):
606+
canonical = alias_to_canonical.get("-" + cluster[j])
607+
if canonical is None:
608+
j += 1
609+
continue
610+
if canonical in value_taking:
611+
remainder = cluster[j + 1:]
612+
if remainder.startswith("="): # `-R=value`
613+
remainder = remainder[1:]
614+
if remainder: # `-Rvalue`
615+
found.add(f"{canonical}={remainder}")
616+
elif i + 1 < n: # `-R value`
617+
found.add(f"{canonical}={tokens[i + 1]}")
618+
consumed_next = True
619+
else: # `-R` (value missing; degenerate)
620+
found.add(canonical)
621+
break
622+
found.add(canonical) # boolean short; keep walking
623+
j += 1
624+
i += 2 if consumed_next else 1
625+
626+
return sorted(found)
627+
628+
629+
def extract_command_context(command: str, flag_scan_text: str | None = None) -> dict:
447630
"""Extract operation context FROM A COMMAND STRING (never prose).
448631
449632
The shared SSOT both merge-guard hooks call. A key is PRESENT only when
@@ -453,12 +636,28 @@ def extract_command_context(command: str) -> dict:
453636
pr_number: str (merge / close)
454637
branch: str (branch-delete)
455638
target_ref: str (force-push, KD-6)
639+
bound_flags: list[str] (#1042) — sorted normalized privileged flags;
640+
ALWAYS present when operation_type is (empty list when none).
641+
642+
`flag_scan_text` (#1042) widens ONLY the privileged-flag scan to a fuller
643+
surface than `command` — the mint arm passes the full selected-option text so
644+
a flag positioned after a quoted argument is not lost to region truncation
645+
(the read arm passes nothing, scanning the raw command). Op/target are ALWAYS
646+
derived from `command` (region-anchored — preserves the anti-distractor
647+
multiplicity gate); only the flag scan honors `flag_scan_text`.
456648
"""
457649
context: dict = {}
458650
op_type = detect_command_operation_type(command)
459651
if op_type is None:
460652
return context
461653
context["operation_type"] = op_type
654+
# bound_flags is computed HERE (the single call site) so both arms inherit it
655+
# un-driftably. It is an ATTRIBUTE of the (op,target) pair, never part of pair
656+
# identity (_target_value / _collect_pairs ignore it), so flag variation can
657+
# never inflate the distinct-pair count and trip the multiplicity refusal.
658+
context["bound_flags"] = extract_privileged_flags(
659+
flag_scan_text if flag_scan_text is not None else command, op_type
660+
)
462661
if op_type in ("merge", "close"):
463662
pr_number = _extract_pr_number(command)
464663
if pr_number is not None:

0 commit comments

Comments
 (0)