diff --git a/.claude-plugin/marketplace.json b/.claude-plugin/marketplace.json index 08a9d5cd..32a29839 100644 --- a/.claude-plugin/marketplace.json +++ b/.claude-plugin/marketplace.json @@ -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.52", + "version": "4.4.53", "author": { "name": "Synaptic-Labs-AI" }, diff --git a/README.md b/README.md index 65139b6c..24d0dfa4 100644 --- a/README.md +++ b/README.md @@ -605,7 +605,7 @@ When installed as a plugin, PACT lives in your plugin cache: │ └── cache/ │ └── pact-plugin/ │ └── PACT/ -│ └── 4.4.52/ # Plugin version +│ └── 4.4.53/ # Plugin version │ ├── agents/ │ ├── commands/ │ ├── skills/ diff --git a/pact-plugin/.claude-plugin/plugin.json b/pact-plugin/.claude-plugin/plugin.json index ef868dd0..5e40e1e5 100644 --- a/pact-plugin/.claude-plugin/plugin.json +++ b/pact-plugin/.claude-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "PACT", - "version": "4.4.52", + "version": "4.4.53", "description": "Orchestration harness that turns Claude Code into a coordinated team of specialist AI agents", "author": { "name": "Synaptic-Labs-AI", diff --git a/pact-plugin/README.md b/pact-plugin/README.md index 51ffd291..fdd855c9 100644 --- a/pact-plugin/README.md +++ b/pact-plugin/README.md @@ -1,6 +1,6 @@ # PACT — Orchestration Harness for Claude Code -> **Version**: 4.4.52 +> **Version**: 4.4.53 Turn a single Claude Code session into a managed team of specialist AI agents that prepare, design, build, and test your code systematically. diff --git a/pact-plugin/hooks/merge_guard_post.py b/pact-plugin/hooks/merge_guard_post.py index 3778a1bf..eb1feaae 100644 --- a/pact-plugin/hooks/merge_guard_post.py +++ b/pact-plugin/hooks/merge_guard_post.py @@ -682,6 +682,13 @@ def _retire_token_for_command( token_dir = TOKEN_DIR pattern = str(token_dir / f"{TOKEN_PREFIX}*") current_session = get_session_id() + # #1097: target-aware retirement. Bring the match up to the granularity the mint + # records — retire ONLY the token for the SPECIFIC operation (same op AND same + # target), not any same-op token in the session. Uses the SAME _target_value the + # mint/read derive, so retirement, mint, and read agree on target identity by + # construction. (The API-merge form reuses pr_number per #1096, so this covers it + # uniformly with zero extra code.) + cmd_target = _target_value(extract_command_context(command)) for path in glob.glob(pattern): basename = os.path.basename(path) # Skip terminal-rename siblings and per-use markers (mirrors the @@ -705,6 +712,18 @@ def _retire_token_for_command( # subsequent op of the same type would need a fresh token anyway). if ctx.get("operation_type") != op_type: continue + # #1097: target-aware ONLY when the token carries a target (every PRODUCTION- + # minted token does — _collect_pairs mints no pair without a non-None + # _target_value). A target-LESS token (a degenerate/legacy shape) has no target + # to discriminate on, so it falls back to the pre-#1097 op+session match rather + # than becoming un-retirable. SAFE: a target-less token is never the operator's + # PROTECTED token (that one has a target), so the fallback cannot re-open the + # over-block the fix cures — a real protected {op, target:42} token is NOT + # retired by an unrelated {op, target:43} command (token_target=42 non-None, + # 43 != 42 -> continue). + token_target = _target_value(ctx) + if token_target is not None and cmd_target != token_target: + continue # Session scoping (SEC-S1 cycle-2 revised asymmetric predicate). token_session = token_data.get("session_id", "") if current_session: diff --git a/pact-plugin/hooks/shared/merge_guard_common.py b/pact-plugin/hooks/shared/merge_guard_common.py index 0de24808..5d961bf4 100644 --- a/pact-plugin/hooks/shared/merge_guard_common.py +++ b/pact-plugin/hooks/shared/merge_guard_common.py @@ -309,6 +309,94 @@ re.compile(_GIT_PREFIX + r"push\s+-[a-zA-Z]*f"), ) +# leg-boundary substrate (#1087): these close arms' `.*`/lookahead previously ran +# over the WHOLE stripped command, so a `--delete-branch` token in a benign +# continuation leg fired the arm cross-leg. That over-reach ALSO laundered: the +# ambiguous multi-close `gh pr close 42 && gh pr close 43 && echo --delete-branch` +# was is_dangerous=True whole-command, so it minted a close token that AUTHORIZED an +# escalated same-target single `gh pr close 42 --delete-branch`. Semantics now: an +# arm fires iff `gh pr close` and `--delete-branch` co-occur within ONE leg, in ANY +# leg position — so the ambiguous compound is is_dangerous=False per-leg (the mint +# write-gate refuses -> no token -> laundering structurally dead) while a real +# single-leg `gh pr close 42 --delete-branch` still gates. 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. +_CLOSE_LITERAL_ARMS = ( + re.compile(_GH_PREFIX + r"pr\s+close\b(?=.*--delete-branch)"), # forward + re.compile(r"--delete-branch.*" + _GH_PREFIX + r"pr\s+close\b"), # reversed +) + +# leg-boundary substrate (#1086): these 17 API danger arms' `.*` previously ran +# over the WHOLE stripped command, so a mutating method / body-flag token in a +# benign continuation leg (`gh api .../git/refs && echo -X DELETE`) over-blocked +# the benign compound. Matched PER-LEG now: an arm fires iff the API client, the +# mutating method (or implicit-POST body flag), and the target endpoint co-occur +# within ONE leg. The negative-lookahead arms operate WITHIN a leg — a same-leg +# explicit GET correctly excludes, and a body flag in a DIFFERENT leg no longer +# wrongly includes. Bodies are moved VERBATIM from DANGEROUS_PATTERNS (flags — +# re.IGNORECASE — lookaheads, and _GH_API_PREFIX/curl/wget prefixes unchanged); +# the conversion changes WHERE they match, never WHAT they match. No-laundering +# safety is preserved BY CONSTRUCTION, not by denylist-emptiness: an isolated API +# leg is method-less hence detect-negative, so tier 2 abstains → symmetric +# whole-command bind (the safe direction) — see TestEmergentDangerClassIsCloseOnly. +_API_LITERAL_ARMS = ( + # API-based merge bypasses (require mutating HTTP method to avoid blocking reads) + re.compile(_GH_API_PREFIX + r"(?=.*(?:-X|--method)\s+(?:PUT|PATCH|POST)\b).*merge", re.IGNORECASE), + re.compile(r"\bcurl\b(?=.*(?:-X|--request)\s+(?:PUT|PATCH|POST)\b).*api.*merge", re.IGNORECASE), + # API-based branch deletion via DELETE to git/refs endpoint. + # HOST-AGNOSTIC (#1061): the curl arms drop the literal `.*api.*` substring so a + # truly api-free Enterprise/proxy URL (e.g. https://git.example.com/repos/o/r/git/refs/...) + # no longer bypasses — bringing curl to parity with the already-host-agnostic + # gh-api/wget arms. The `api` key was an as-shipped heuristic (#268/#271), not a + # deliberated scope ruling; this WIDENS it. The over-block this widening would introduce + # on a quoted `-d` body mentioning git/refs is closed by carrier-8 (the HTTP-client + # data-body strip in _strip_non_executable_content) — the body value is stripped while + # the path-resident ref survives (PATH-vs-BODY invariant). + re.compile(_GH_API_PREFIX + r"(?=.*(?:-X|--method)\s+DELETE\b).*git/refs", re.IGNORECASE), + re.compile(r"\bcurl\b(?=.*(?:-X|--request)\s+DELETE\b).*git/refs", re.IGNORECASE), + # API-based ref mutation / force push via mutating method to git/refs endpoint + # (any mutating operation on git refs via API is inherently dangerous). Curl arm is + # host-agnostic (#1061) — see the DELETE arm note above. + re.compile(_GH_API_PREFIX + r"(?=.*(?:-X|--method)\s+(?:PATCH|POST|PUT)\b).*git/refs", re.IGNORECASE), + re.compile(r"\bcurl\b(?=.*(?:-X|--request)\s+(?:PATCH|POST|PUT)\b).*git/refs", re.IGNORECASE), + # Branch-protection API mutation (#1063): DELETE|PUT|PATCH on a + # `branches//protection` endpoint WEAKENS protection (remove / replace / + # modify whole config). POST is EXCLUDED — it ENABLES protection sub-features + # (enforce_admins / required_signatures) = the STRENGTHENING direction, so gating it + # would over-block. HOST-AGNOSTIC (the #1061 lesson — no `.*api.*`). Explicit-method + # arms only (the protection endpoint has no implicit-POST danger like git/refs). The + # branch is PATH-resident, so carrier-8 never strips it (no preservation guard needed, + # unlike the body-resident contents arm). No httpie arm: the mint classifier's + # `_is_api_form` is gh-api/curl/wget only, so adding an httpie read arm would create a + # gated-but-unmintable over-block — omitted by design (httpie is WHOLLY out of charter; + # its ref-mutation/merge arms were removed with it). + re.compile(_GH_API_PREFIX + r"(?=.*(?:-X|--method)\s+(?:DELETE|PUT|PATCH)\b).*branches/.*/protection", re.IGNORECASE), + re.compile(r"\bcurl\b(?=.*(?:-X|--request)\s+(?:DELETE|PUT|PATCH)\b).*branches/.*/protection", re.IGNORECASE), + re.compile(r"\bwget\b(?=.*--method=(?:DELETE|PUT|PATCH)\b).*branches/.*/protection", re.IGNORECASE), + # gh api implicit POST: body param flags (-f, -F, --field, --raw-field, --input) + # cause gh api to default to POST. Dangerous when targeting git/refs or merge. + # Negative lookahead excludes explicit GET (which overrides implicit POST). + re.compile(_GH_API_PREFIX + r"(?!.*(?:-X|--method)\s+GET\b)(?=.*(?:-f|-F|--field|--raw-field|--input)\s).*git/refs", re.IGNORECASE), + re.compile(_GH_API_PREFIX + r"(?!.*(?:-X|--method)\s+GET\b)(?=.*(?:-f|-F|--field|--raw-field|--input)\s).*merge", re.IGNORECASE), + # curl implicit POST: --data/-d/--data-raw/--data-binary flags cause curl to + # default to POST. Dangerous when targeting git/refs or merge API endpoints. + # Negative lookahead excludes explicit GET (which overrides implicit POST). + re.compile(r"\bcurl\b(?!.*(?:-X|--request)\s+GET\b)(?=.*(?:--data(?:-(?:raw|binary))?|-d)\s).*git/refs", re.IGNORECASE), + re.compile(r"\bcurl\b(?!.*(?:-X|--request)\s+GET\b)(?=.*(?:--data(?:-(?:raw|binary))?|-d)\s).*api.*merge", re.IGNORECASE), + # Contents API: write operations (PUT/PATCH/POST) to /contents/ endpoint + # targeting main or master branch. Flags any mutating /contents/ call that + # mentions main or master anywhere in the command (acceptable false positive). + re.compile(_GH_API_PREFIX + r"(?=.*(?:-X|--method)\s+(?:PUT|PATCH|POST)\b).*contents/.*(?:main|master)", re.IGNORECASE), + re.compile(r"\bcurl\b(?=.*(?:-X|--request)\s+(?:PUT|PATCH|POST)\b).*api.*contents/.*(?:main|master)", re.IGNORECASE), + # Alternative HTTP clients: wget with --method flag + re.compile(r"\bwget\b(?=.*--method=(?:DELETE|PATCH|POST|PUT)\b).*git/refs", re.IGNORECASE), + re.compile(r"\bwget\b(?=.*--method=(?:DELETE|PATCH|POST|PUT)\b).*merge", re.IGNORECASE), + # Known API detection gaps (defense-in-depth, not a security boundary): + # - GraphQL mutations: gh api graphql -f query='mutation { ... }' bypasses REST-path matching + # - gh alias: aliases can hide API calls (tracked in #270) +) + def detect_command_operation_type(command: str) -> str | None: @@ -427,6 +515,25 @@ def detect_command_operation_type(command: str) -> str | None: if _is_api_form and re.search(r"branches/.*/protection", command): if re.search(r"\b(?:DELETE|PUT|PATCH)\b", command, re.IGNORECASE): return "branch-protection" + # API-based PR merge (#1096, GH-API-ONLY per Option B): gh api with a mutating method + # (PUT/PATCH/POST) on a pulls//merge endpoint is a merge via API. curl/wget were + # DROPPED (sec #71: unsound value-flag denylist over an unbounded flag space) — they + # classify None and stay gated-but-unmintable. Recognized AND extracted by + # the ONE shared per-leg helper _api_merge_leg_endpoint, so recognition<->extractability + # read the SAME surface — a leg classifies merge IFF its ENDPOINT-position PR is + # extractable, which gives no gated-but-unmintable AND binds the URL-positional + # endpoint (never a flag-value / other-leg decoy — the #1096 target-confusion fix). + # ADDITIVE: this classifies inputs that were previously None (a genuine API merge) + # while leaving every EXISTING classification byte-identical. (It is NOT true that + # "every currently-None input stays None" — a genuine api-merge is correctly + # None->merge; that is the whole point of the arm. Only NON-merge inputs are + # unchanged.) Recognition predicate: tolerant IGNORECASE client (_GH_API_PREFIX, so a + # `gh -R o/r api` global-flag spelling also mints) + IGNORECASE PUT/PATCH/POST + + # case-SENSITIVE pulls//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): + 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): @@ -567,6 +674,111 @@ def _extract_api_ref(command: str) -> str | None: return api_match.group(1) if api_match else None +# Value-taking-flag skip set for the gh-api api-merge endpoint walk (#1096; GH-API-ONLY +# per Option B). The api-merge mint arm is gh-api ONLY — curl/wget were DROPPED because +# sec #71 proved a value-flag denylist over curl/wget's UNBOUNDED flag space is +# structurally uncompletable (52 realistic-common decoy leaks), so a curl/wget mint arm +# built on it is unsound. gh api's flags are FINITE + documented + first-positional-endpoint +# = provably sound. Consequence: curl/wget api-merge classifies detect=None (mint=0), +# staying in its PRE-EXISTING gated-but-unmintable state (the read arms still gate it) — +# killing that laundering class BY CONSTRUCTION (can't mint -> can't launder). Tokens are +# the shlex form. Includes the -R/--repo GLOBAL flag, which is decoy-capable (`gh -R +# pulls/5/merge api …` would bind 5 if -R's value is not skipped). Booleans (-i/--include, +# --paginate, --slurp, --silent, --verbose) are DELIBERATELY absent — skipping their next +# token would swallow a positional. +_API_MERGE_GH_VALUE_FLAGS = frozenset({ + "-X", "--method", "-f", "--field", "-F", "--raw-field", "--input", + "-H", "--header", "--hostname", "-q", "--jq", "-t", "--template", + "--cache", "-R", "--repo", +}) + +_PULLS_MERGE_PR_RE = re.compile(r"pulls/(\d+)/merge\b") + + +def _api_merge_leg_endpoint(leg: str) -> str | None: + """Return the ENDPOINT PR of a genuine per-leg API merge, or None (#1096). + + ONE shared helper called by BOTH detect_command_operation_type's merge arm AND + _extract_api_merge_pr, so recognition and extraction read the SAME surface (kills + the reuse-across-two-domains root: detect recognizes a leg IFF its endpoint is + extractable -> no gated-but-unmintable BY CONSTRUCTION, and the bound PR is the URL + POSITIONAL endpoint, never a flag-value / other-leg decoy). + + GH-API-ONLY (#1096 Option B; curl/wget dropped, sec #71). Recognition (gh-api client + + mutating method + pulls//merge path co-occur in this leg): tolerant IGNORECASE + _GH_API_PREFIX client + IGNORECASE PUT/PATCH/POST method + case-SENSITIVE pulls//merge + path. A curl/wget merge classifies None here -> gated-but-unmintable (read arms still gate). + + Extraction binds the pulls//merge that is the URL POSITIONAL: strip body values + (carrier-8, §2.3), shlex-tokenize, then walk skipping flags + the gh-api value-flag + values, and take the first surviving positional. On tokenizer failure NEVER returns + None for a recognized leg (falls back to the stripped/raw first-match) — a mis-parse + must not gate a faithful merge (over-block-safe). + """ + client_gh = re.search(_GH_API_PREFIX, leg, re.IGNORECASE) + if not client_gh: + # GH-API-ONLY (#1096 Option B): curl/wget api-merge legs classify None here + # (mint=0), staying in their PRE-EXISTING gated-but-unmintable state (the + # curl/wget merge READ arms still gate them) — killing the 52 curl/wget + # laundering vectors BY CONSTRUCTION (sec #71: their value-flag denylist over an + # unbounded flag space is uncompletable). No new over-block (status quo ante). + return None + # Recognition uses the EXACT non-capturing literal `pulls/\d+/merge\b` via re.search + # (byte-identical to the prior detect arm), NOT the compiled capturing _PULLS_MERGE_PR_RE + # — so recognition stays byte-identical AND the non-vacuity arm-disable shim (which + # monkeypatches re.search of this exact literal) still surgically disables the arm. + # The capturing _PULLS_MERGE_PR_RE is used only for EXTRACTION (the walk + fallback). + if not re.search(r"pulls/\d+/merge\b", leg): + return None + if not re.search(r"\b(?:PUT|PATCH|POST)\b", leg, re.IGNORECASE): + return None + # Recognized. Extract the ENDPOINT-position PR. + stripped = _strip_non_executable_content(leg) + tokens = _shell_tokenize(stripped) + if tokens is None: + # Tokenizer failure (unbalanced quotes): NEVER None for a recognized leg + # (over-block-safe). Prefer the body-stripped surface (avoids body decoys); + # the raw leg is guaranteed to match (recognition required it). + m = _PULLS_MERGE_PR_RE.search(stripped) or _PULLS_MERGE_PR_RE.search(leg) + return m.group(1) if m else None + value_flags = _API_MERGE_GH_VALUE_FLAGS + i = 0 + while i < len(tokens): + tok = tokens[i] + if tok.startswith("-"): + base = tok.split("=", 1)[0] + if "=" not in tok and base in value_flags: + i += 1 # bare space-separated value-flag: skip its value token too + # attached-value (`--method=PUT`, `-XPUT`) and booleans consume no extra token + i += 1 + continue + # A positional: the endpoint is the FIRST pulls//merge positional (the URL + # argument; for gh api the endpoint is the first positional by REST convention). + mm = _PULLS_MERGE_PR_RE.search(tok) + if mm: + return mm.group(1) + i += 1 + # Recognized (gh-api) but no endpoint positional survived (the only pulls//merge was + # a skipped flag VALUE, i.e. a body/decoy — the leg has no real merge endpoint). NOT a + # tokenizer failure, so this is a genuine "no extractable endpoint" -> None. Correct + # (no wrong-target mint); such an input never faithfully merges a PR. (#1096 is + # gh-api-only per Option B: curl/wget never reach this walk — they classify None at + # the client gate above — so there is no exotic-curl residual to accept.) + return None + + +def _extract_api_merge_pr(command: str) -> str | None: + """Command-level api-merge PR: walk the legs and return the FIRST recognized leg's + ENDPOINT-position PR (#1096). Leg-scoping closes the cross-leg/echo decoy; the + per-leg helper closes the same-leg flag-value decoy. Consumed by extract_command_context + (mint + read whole-fallback + #1097 retirement cmd_target all heal at this one site).""" + for _leg in _split_into_legs(command): + pr = _api_merge_leg_endpoint(_leg) + if pr is not None: + return pr + return None + + def _extract_protection_branch(command: str) -> str | None: """Parse the protected branch from a branch-protection API command's `branches//protection` path (#1063). The branch is PATH-resident (the @@ -1159,6 +1371,8 @@ def extract_command_context(command: str, flag_scan_text: str | None = None) -> ) if op_type in ("merge", "close"): pr_number = _extract_pr_number(command) + if pr_number is None and op_type == "merge": + pr_number = _extract_api_merge_pr(command) # #1096 API pulls//merge if pr_number is not None: context["pr_number"] = pr_number elif op_type == "branch-delete": @@ -1425,9 +1639,11 @@ def cleanup_orphan_tokens( DANGEROUS_PATTERNS = [ # PR merge via gh CLI re.compile(_GH_PREFIX + r"pr\s+merge\b"), -# PR close with --delete-branch via gh CLI (bare close is reversible) -re.compile(_GH_PREFIX + r"pr\s+close\b(?=.*--delete-branch)"), -re.compile(r"--delete-branch.*" + _GH_PREFIX + r"pr\s+close\b"), +# PR close with --delete-branch: the close+--delete-branch danger arms live in +# _CLOSE_LITERAL_ARMS (defined with the classifier patterns above) — matched +# PER-LEG by is_dangerous_command after this list misses (#1087 leg isolation), +# NOT whole-string here (a whole-command match fired cross-leg and laundered an +# ambiguous multi-close into an escalated-single token). Bare close is reversible. # 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. @@ -1435,60 +1651,11 @@ def cleanup_orphan_tokens( 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"), -# API-based merge bypasses (require mutating HTTP method to avoid blocking reads) -re.compile(_GH_API_PREFIX + r"(?=.*(?:-X|--method)\s+(?:PUT|PATCH|POST)\b).*merge", re.IGNORECASE), -re.compile(r"\bcurl\b(?=.*(?:-X|--request)\s+(?:PUT|PATCH|POST)\b).*api.*merge", re.IGNORECASE), -# API-based branch deletion via DELETE to git/refs endpoint. -# HOST-AGNOSTIC (#1061): the curl arms drop the literal `.*api.*` substring so a -# truly api-free Enterprise/proxy URL (e.g. https://git.example.com/repos/o/r/git/refs/...) -# no longer bypasses — bringing curl to parity with the already-host-agnostic -# gh-api/wget arms. The `api` key was an as-shipped heuristic (#268/#271), not a -# deliberated scope ruling; this WIDENS it. The over-block this widening would introduce -# on a quoted `-d` body mentioning git/refs is closed by carrier-8 (the HTTP-client -# data-body strip in _strip_non_executable_content) — the body value is stripped while -# the path-resident ref survives (PATH-vs-BODY invariant). -re.compile(_GH_API_PREFIX + r"(?=.*(?:-X|--method)\s+DELETE\b).*git/refs", re.IGNORECASE), -re.compile(r"\bcurl\b(?=.*(?:-X|--request)\s+DELETE\b).*git/refs", re.IGNORECASE), -# API-based ref mutation / force push via mutating method to git/refs endpoint -# (any mutating operation on git refs via API is inherently dangerous). Curl arm is -# host-agnostic (#1061) — see the DELETE arm note above. -re.compile(_GH_API_PREFIX + r"(?=.*(?:-X|--method)\s+(?:PATCH|POST|PUT)\b).*git/refs", re.IGNORECASE), -re.compile(r"\bcurl\b(?=.*(?:-X|--request)\s+(?:PATCH|POST|PUT)\b).*git/refs", re.IGNORECASE), -# Branch-protection API mutation (#1063): DELETE|PUT|PATCH on a -# `branches//protection` endpoint WEAKENS protection (remove / replace / -# modify whole config). POST is EXCLUDED — it ENABLES protection sub-features -# (enforce_admins / required_signatures) = the STRENGTHENING direction, so gating it -# would over-block. HOST-AGNOSTIC (the #1061 lesson — no `.*api.*`). Explicit-method -# arms only (the protection endpoint has no implicit-POST danger like git/refs). The -# branch is PATH-resident, so carrier-8 never strips it (no preservation guard needed, -# unlike the body-resident contents arm). No httpie arm: the mint classifier's -# `_is_api_form` is gh-api/curl/wget only, so adding an httpie read arm would create a -# gated-but-unmintable over-block — omitted by design (httpie is WHOLLY out of charter; -# its ref-mutation/merge arms were removed with it). -re.compile(_GH_API_PREFIX + r"(?=.*(?:-X|--method)\s+(?:DELETE|PUT|PATCH)\b).*branches/.*/protection", re.IGNORECASE), -re.compile(r"\bcurl\b(?=.*(?:-X|--request)\s+(?:DELETE|PUT|PATCH)\b).*branches/.*/protection", re.IGNORECASE), -re.compile(r"\bwget\b(?=.*--method=(?:DELETE|PUT|PATCH)\b).*branches/.*/protection", re.IGNORECASE), -# gh api implicit POST: body param flags (-f, -F, --field, --raw-field, --input) -# cause gh api to default to POST. Dangerous when targeting git/refs or merge. -# Negative lookahead excludes explicit GET (which overrides implicit POST). -re.compile(_GH_API_PREFIX + r"(?!.*(?:-X|--method)\s+GET\b)(?=.*(?:-f|-F|--field|--raw-field|--input)\s).*git/refs", re.IGNORECASE), -re.compile(_GH_API_PREFIX + r"(?!.*(?:-X|--method)\s+GET\b)(?=.*(?:-f|-F|--field|--raw-field|--input)\s).*merge", re.IGNORECASE), -# curl implicit POST: --data/-d/--data-raw/--data-binary flags cause curl to -# default to POST. Dangerous when targeting git/refs or merge API endpoints. -# Negative lookahead excludes explicit GET (which overrides implicit POST). -re.compile(r"\bcurl\b(?!.*(?:-X|--request)\s+GET\b)(?=.*(?:--data(?:-(?:raw|binary))?|-d)\s).*git/refs", re.IGNORECASE), -re.compile(r"\bcurl\b(?!.*(?:-X|--request)\s+GET\b)(?=.*(?:--data(?:-(?:raw|binary))?|-d)\s).*api.*merge", re.IGNORECASE), -# Contents API: write operations (PUT/PATCH/POST) to /contents/ endpoint -# targeting main or master branch. Flags any mutating /contents/ call that -# mentions main or master anywhere in the command (acceptable false positive). -re.compile(_GH_API_PREFIX + r"(?=.*(?:-X|--method)\s+(?:PUT|PATCH|POST)\b).*contents/.*(?:main|master)", re.IGNORECASE), -re.compile(r"\bcurl\b(?=.*(?:-X|--request)\s+(?:PUT|PATCH|POST)\b).*api.*contents/.*(?:main|master)", re.IGNORECASE), -# Alternative HTTP clients: wget with --method flag -re.compile(r"\bwget\b(?=.*--method=(?:DELETE|PATCH|POST|PUT)\b).*git/refs", re.IGNORECASE), -re.compile(r"\bwget\b(?=.*--method=(?:DELETE|PATCH|POST|PUT)\b).*merge", re.IGNORECASE), -# Known API detection gaps (defense-in-depth, not a security boundary): -# - GraphQL mutations: gh api graphql -f query='mutation { ... }' bypasses REST-path matching -# - gh alias: aliases can hide API calls (tracked in #270) +# 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 +# (#1086 leg isolation), NOT whole-string here (a whole-command match fired cross-leg +# and over-blocked a benign compound carrying a method/body-flag token in a benign leg). # Direct push to default branch (bypasses PR merge) re.compile(_GIT_PREFIX + r"push\s+\S+\s+HEAD:main\b"), re.compile(_GIT_PREFIX + r"push\s+\S+\s+HEAD:master\b"), @@ -1935,7 +2102,12 @@ def _strip_http_body_span(span_match: re.Match) -> str: # contents arm UNCHANGED, so preserve a contents-API span verbatim. Detected # per-span (a compound's `/log` span is still stripped). git/refs and # branches/protection targets are path-resident, so their bodies stay strippable. - if re.search(r"contents/", span): + # IGNORECASE to match the case-insensitive contents READ arm + # (`contents/.*(?:main|master)`, re.IGNORECASE): a `Contents/` (any case) + # span carries its main/master gating signal in the BODY, so it must be + # preserved from stripping in ANY case — else the #1096 §2.3 unquoted body + # strip removes the signal for a capital-case spelling -> under-block. + if re.search(r"contents/", span, re.IGNORECASE): return span def _keep_flag_dq(m: re.Match) -> str: @@ -1961,6 +2133,21 @@ def _keep_flag_dq(m: re.Match) -> str: span = re.sub( r"(" + _field_flag + r"\s+[\w.\-]+=)'[^']*'", r"\1'STRIPPED'", span ) + # (#1096 §2.3) UNQUOTED body/field values — closes the endpoint-decoy the + # quoted-only arms above miss (`-f note=pulls/5/merge`, `-d body`), and the + # #1037 unquoted-body read-floor over-block (carrier-8 feeds is_dangerous). + # Runs AFTER the quoted arms, and the value's first char is a NON-quote + # (`[^\s'"]`), so a quoted value already handled above is never re-touched. + # Anchored on the body-flag(+key=) prefix, so the URL POSITIONAL endpoint + # (never preceded by such a prefix) is never matched -> never stripped + # (over-block-safe). Reuses _keep_flag_dq: a $()/backtick value executes and + # is kept; otherwise the 'STRIPPED' placeholder replaces the bare value. + span = re.sub( + r"(" + _data_flag + r"\s+)[^\s'\"]\S*", _keep_flag_dq, span + ) + span = re.sub( + r"(" + _field_flag + r"\s+[\w.\-]+=)[^\s'\"]\S*", _keep_flag_dq, span + ) return span result = re.sub(_http_client_span, _strip_http_body_span, result) @@ -2340,10 +2527,29 @@ def is_dangerous_command(command: str) -> bool: # provenance to _split_into_legs (normalize + strip, above), so read-floor legs # 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). - for _leg in _slice_stripped_legs(stripped): + # 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). + legs = _slice_stripped_legs(stripped) + for _leg in legs: if any(arm.search(_leg) for arm in _FORCE_PUSH_LITERAL_ARMS): return True + # Literal close arms, matched PER-LEG (#1087): same leg substrate and strip + # provenance as the force-push loop above. An arm fires iff `gh pr close` and + # `--delete-branch` co-occur within ONE leg — so the ambiguous cross-leg + # multi-close is is_dangerous=False here, the mint write-gate refuses (no token), + # and the escalated-single laundering channel is structurally closed. + for _leg in legs: + if any(arm.search(_leg) for arm in _CLOSE_LITERAL_ARMS): + return True + # Literal API danger arms, matched PER-LEG (#1086): same leg substrate and strip + # provenance as the loops above. An arm fires iff the API client, its mutating + # method (or implicit-POST body flag), and the target endpoint co-occur within ONE + # leg — so a method/body-flag token in a benign continuation leg no longer + # over-blocks the compound, while a same-leg dangerous API call still gates. The + # negative-lookahead arms operate within-leg (correct exclusion/inclusion direction). + for _leg in legs: + if any(arm.search(_leg) for arm in _API_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) diff --git a/pact-plugin/tests/runbooks/RUNBOOK_RUN_DATES.md b/pact-plugin/tests/runbooks/RUNBOOK_RUN_DATES.md index 862fb248..99887161 100644 --- a/pact-plugin/tests/runbooks/RUNBOOK_RUN_DATES.md +++ b/pact-plugin/tests/runbooks/RUNBOOK_RUN_DATES.md @@ -189,3 +189,9 @@ the calibration record. | Run date (UTC) | Operator | Plugin version | Verdict | Harness-independence evidence | Notes (corpus; supersede reconciliation; per-set outcomes) | | -------------- | -------- | -------------- | ------- | ----------------------------- | ----------------------------------------------------------- | | 2026-07-02 | michael-wojcik | 4.4.52 installed (contains all v4.4.51 changes; hooks byte-identical to origin/main `60f14796`) | **29/29 PASS, post-side** — all fixed forms ALLOW/mintable, every preserved-block canary DENY, all four laundering channels REFUSE end-to-end, faithful round-trips AUTHORIZE; ZERO installed-vs-source divergence · no HALT | SINGLE-harness POST-INSTALL re-drive (test-engineer; same-engineer as both pre-merge probes — independence stays attributed to the two blind reviews that converged SAFE-TO-MERGE pre-merge). Corpus recovered VERBATIM from both pre-merge harnesses (not re-derived from row prose): the 17-row batch set + the 14-row fix-level set, deduplicated on byte-identical probe forms (the fix-level D1082-cured + N5-superseded rows == batch N5's command) → 29 distinct probes through the INSTALLED v4.4.52 `merge_guard_pre.py`/`merge_guard_post.py` mains as REAL SUBPROCESSES (hooks.json `python3 .py` shape) under macOS system python3 3.9.6 (the runtime floor); per-probe isolated `CLAUDE_CONFIG_DIR` + `HOME` (TOKEN_DIR derives from `$CLAUDE_CONFIG_DIR` at import), zero `~/.claude` contamination; dangerous literals in script files. BYTE-IDENTITY GATE first: full `diff -r` of the installed hooks tree vs the worktree at `60f14796` byte-identical (excl. `__pycache__`) — covers both hook mains + every `shared/` module transitively. Harness-fidelity control: the P1a/P1b mint→authorize pair passed BEFORE any REFUSE was interpreted (rules out envelope-shape infidelity masquerading as a shipped-artifact divergence). | This row DISCHARGES the post-install gate for #1064/#1077/#1078/#1082/#1083 (gated-closure discipline: the two pre-merge rows above ran STAGED hooks and satisfied the pre-merge gates; this is the installed-binary confirmation per the v4.4.45 post-install standard). Expected verdicts = FINAL shipped semantics, reconciled toward the LATER fix-level row where the two sets overlap: N5 (`git push origin feature && rm -f stale.txt`) probed as ALLOW per the D1082-cured supersede — the batch row's N5 DENY→DENY is historical; prior rows untouched (append-only). Per-set outcomes: 17/17 batch-derived (P1a/P1c1 mint exactly 1 token `{push-to-main, target_ref=main, bound_flags=[--force-with-lease]}`; P1b/P1c2 byte-identical exec ALLOW; P2a/P2b httpie + R17 https-alias ALLOW; P3a/P3b/P3c compound-leg ALLOW; N1–N4 DENY; N5 ALLOW; A1/A2 ALLOW) · 2/2 fix-level read-floor non-overlap (permanent `git push && rm -f x.txt` ALLOW; same-leg `cd /repo && git push --force origin main` DENY) · 5/5 laundering-closed (close fwd + close rev + push-lease + merge-admin + force-noverify: mint from the benign member → token written → ESCALATED exec DENY) · 5/5 faithful round-trip (byte-identical member AUTHORIZE, incl. both close-member shapes). **NOT probed**: #1087 (multi-close ambiguity laundering — open, pre-existing, tracked) — per the fix-level row above; #1079 closed won't-fix 2026-07-01. No HALT. | + +## merge-guard-cross-leg-completion-fix-level-live-probe (#1087 close-arm laundering + #1086 API cross-leg over-blocks) + +| Run date (UTC) | Operator | Plugin version | Verdict | Harness-independence evidence | Notes (per-set outcomes; §0 primary gate; identity-slice faithfulness) | +| -------------- | -------- | -------------- | ------- | ----------------------------- | ---------------------------------------------------------------------- | +| 2026-07-02 | michael-wojcik | rides the eventual merge (fix-level pre-merge probe; NO bump decided here) — probe ran hooks: PRE-FIX = branch base `60f14796` (== `33260521^`; close AND API arms still whole-command/cross-leg) vs POST-FIX = worktree HEAD `3e2da931` (per-leg `_CLOSE_LITERAL_ARMS` + `_API_LITERAL_ARMS`) | **14/14 PASS, BOTH SIDES** — #1087 close laundering CLOSED end-to-end (mint 1→0, escalated single ALLOW→DENY); #1086 all six API/close cross-leg over-blocks REMOVED (DENY→ALLOW); every same-leg dangerous form STILL gates incl. the CRITICAL `cd /repo && gh pr close 42 --delete-branch` (DENY/DENY); benign faithful singles + the faithful close round-trip ALLOW both sides; ZERO runtime-vs-unit divergence · no HALT. FIX-LEVEL pre-merge probe; the POST-INSTALL probe rides the eventual merge per the #1085 precedent. | SINGLE-harness empirical before/after (test-engineer run): the SAME crafted hook stdin through the REAL `merge_guard_pre.py`/`merge_guard_post.py` mains as SUBPROCESSES (matching hooks.json `python3 .py`) under macOS system python3 3.9.6 (runtime floor); per-probe isolated `CLAUDE_CONFIG_DIR` + `HOME` (TOKEN_DIR derives from `$CLAUDE_CONFIG_DIR` at import — env-redirectable, no fidelity fallback), zero `~/.claude` contamination; dangerous literals in script files. SAME-ENGINEER probe (also authored the permanent §7a/§7b sweep classes this run certifies) — the two DIRECTIONS self-attribute per row (pre-fix reproduces every over-block + the laundering; post-fix shows the cure), and the blind adversarial review is the independence signal. Identity: pre-fix `git archive 60f14796` verified to LACK the delta symbols (`_CLOSE_LITERAL_ARMS`/`_API_LITERAL_ARMS` absent — grep 0); post-fix = the clean worktree hooks at `3e2da931`. Full suite (plain `python -m pytest`, errors-token scanned — rtk NOT used for the gate): **10476 passed, 11 skipped, 0 failed, 0 ERRORS** (baseline 10436 + 40 new §7a/§7b cases). | LAUNDERING-CLOSED (#1087, end-to-end mint→token→ESCALATED exec): AMBIG member `gh pr close 42 && gh pr close 43 && echo --delete-branch` → pre mints 1 token → escalated single `gh pr close 42 --delete-branch` AUTHORIZES (laundering OPEN); post mints 0 → escalated single DENIES (CLOSED). OVER-BLOCK REMOVED (#1086/#1087, pre DENY→post ALLOW, one per family): close single-member + reversed; API git/refs DELETE, merge PUT, curl git/refs, wget git/refs (all `... && echo ` benign compounds). SAME-LEG STILL GATES (DENY/DENY): the CRITICAL `cd /repo && gh pr close 42 --delete-branch` (True today ONLY via the in-leg per-leg match the fix re-establishes), `gh pr close 42 --delete-branch`, `gh api -X DELETE .../git/refs/heads/x`, `cd /repo && gh api -X DELETE .../git/refs/heads/x`. BENIGN SINGLES run FREE both sides (`gh pr close 42`, `gh api .../git/refs/heads/x`). FAITHFUL ROUND-TRIP (§0 inviolable): `gh pr close 42 --delete-branch` mints 1 → authorizes itself ALLOW both sides (no over-block on a faithful click). **IDENTITY-SLICE FAITHFULNESS** (the coder's flagged reviewer-confirm item, discharged): the permanent §7a/§7b non-vacuity mutates `_slice_stripped_legs`→identity (restores whole-command matching) so each of the 11 cured rows flips D=False→True, AND neutering ONLY that row's family arms (`_CLOSE_LITERAL_ARMS` / `_API_LITERAL_ARMS`) returns it to False — proving each whole-command flip is caused by that family's arm ALONE (no second-family co-match), so the identity-slice mutation is a faithful single-family pre-fix simulation for every row (uniform coupling, no uneven map). CONFIRMED already-green (coder-authored): §7c approval-source canaries (AMBIG mints 0 → escalated + different-target DENY), the OPEN-Q D tripwire (close + API halves), the real post→pre seam laundering-closed canary. No HALT. Satisfies the fix-level pre-merge gate for #1087/#1086; gated closure rides the eventual merge per the no-auto-close discipline. | diff --git a/pact-plugin/tests/test_merge_guard.py b/pact-plugin/tests/test_merge_guard.py index 89ffe479..00e5f554 100644 --- a/pact-plugin/tests/test_merge_guard.py +++ b/pact-plugin/tests/test_merge_guard.py @@ -12985,6 +12985,197 @@ def test_sec_s2_lookup_table_ssot_pin(self): # bidirectional suite in test_merge_guard_auth_symmetry.py; # it exercised the dropped prose classifier extract_context.) + +class TestTargetAwareRetirement: + """#1097 target-aware Layer-1 token retirement — the bidirectional cert. + + Pre-fix, `_retire_token_for_command` matched on op-type + session only, so a + SUCCESSFUL unrelated same-op command (`gh pr close 43`) retired the operator's + approved token for a DIFFERENT target (`close/42`) — an OVER-BLOCK: the + faithful re-execution the operator approved then re-prompted. The fix computes + `cmd_target = _target_value(extract_command_context(command))` once and skips + any token whose own non-None target differs (`token_target is not None and + cmd_target != token_target -> continue`). DEFENSIVE, not strict: a target-LESS + token (degenerate/legacy shape) keeps the pre-fix op+session fallback, so + nothing becomes un-retirable and no under-block opens. + + Priority per the governing principle: the over-block-cured direction + (survival rows) is PRIMARY/inviolable; the still-retires direction is the + secondary no-new-under-block sweep. ATTRIBUTION NOTE for main()-level rows: + these tests call `_retire_token_for_command` DIRECTLY, below Block 3's stdout + gate — an API-merge retiring command at main() level is Block-3-SKIPPED + (its JSON success output does not contain LAYER1_SUCCESS_STDOUT_PATTERNS + ["merge"]) and never reaches this predicate; the target predicate is + load-bearing for CLI merge/close retiring commands, which DO emit the + matching stdout. The live-probe covers the main()-level attribution split.""" + + def _live(self, tmp_path): + return [t for t in tmp_path.glob("merge-authorized-*") + if not t.name.endswith(".consumed") and ".use-" not in t.name] + + # --- PRIMARY (inviolable): unrelated same-op does NOT retire a targeted token --- + + @pytest.mark.parametrize( + "token_ctx,unrelated_cmd,op", + [ + ({"operation_type": "close", "pr_number": "42", + "bound_flags": ["--delete-branch"]}, "gh pr close 43", "close"), + ({"operation_type": "merge", "pr_number": "42", + "bound_flags": []}, "gh pr merge 43", "merge"), + ], + ) + def test_unrelated_same_op_success_does_not_retire_targeted_token( + self, tmp_path, token_ctx, unrelated_cmd, op + ): + """#1097 CURED (the over-block direction): a successful unrelated same-op + command must NOT burn the operator's approved token for a different + target. Pre-fix this retired (op+session match); post-fix the non-None + token target discriminates.""" + from merge_guard_post import write_token, _retire_token_for_command + + write_token(token_ctx, token_dir=tmp_path) + retired = _retire_token_for_command(unrelated_cmd, op, token_dir=tmp_path) + assert retired is False, ( + f"OVER-BLOCK regressed: unrelated {unrelated_cmd!r} retired the " + f"{token_ctx['operation_type']}/{token_ctx['pr_number']} token" + ) + assert len(self._live(tmp_path)) == 1, "the protected token must survive" + + def test_no_target_command_does_not_retire_targeted_token(self, tmp_path): + """Safe-direction edge: a retiring command with NO extractable target + (bare `gh pr merge`) retires nothing against a targeted token — + cmd_target None != token_target '42'.""" + from merge_guard_post import write_token, _retire_token_for_command + + write_token({"operation_type": "merge", "pr_number": "42", + "bound_flags": []}, token_dir=tmp_path) + retired = _retire_token_for_command("gh pr merge", "merge", token_dir=tmp_path) + assert retired is False + assert len(self._live(tmp_path)) == 1 + + def test_api_merge_target_composes_with_retirement(self, tmp_path): + """#1096 composition at the predicate level: the API-merge form's + pr_number (path-resident, `pulls//merge`) flows through the SAME + `_target_value` the retirement predicate uses — an unrelated API merge + (43) does not retire the 42 token; the same-target API form does. + (At main() level an API-merge retiring command is Block-3-skipped + entirely — see the class docstring; this row certifies the predicate's + target arithmetic for the API spelling, the belt-and-suspenders layer.)""" + from merge_guard_post import write_token, _retire_token_for_command + + write_token({"operation_type": "merge", "pr_number": "42", + "bound_flags": []}, token_dir=tmp_path) + unrelated = _retire_token_for_command( + "gh api -X PUT /repos/o/r/pulls/43/merge", "merge", token_dir=tmp_path) + assert unrelated is False + assert len(self._live(tmp_path)) == 1 + same = _retire_token_for_command( + "gh api -X PUT /repos/o/r/pulls/42/merge", "merge", token_dir=tmp_path) + assert same is True + assert len(self._live(tmp_path)) == 0 + + # --- SECONDARY (no-new-under-block): genuine self-consume STILL retires --- + + def test_self_consume_still_retires_own_token(self, tmp_path): + """A successful run of the SAME approved operation retires its own token + (target matches) — the one-approval-one-operation discipline is intact.""" + from merge_guard_post import write_token, _retire_token_for_command + + write_token({"operation_type": "close", "pr_number": "42", + "bound_flags": ["--delete-branch"]}, token_dir=tmp_path) + retired = _retire_token_for_command( + "gh pr close 42 --delete-branch", "close", token_dir=tmp_path) + assert retired is True, "NEW UNDER-BLOCK: self-consume stopped retiring" + assert len(self._live(tmp_path)) == 0 + second = _retire_token_for_command( + "gh pr close 42 --delete-branch", "close", token_dir=tmp_path) + assert second is False, "second retire must find no live candidate" + + def test_target_less_token_keeps_op_session_fallback(self, tmp_path): + """The DEFENSIVE half: a target-LESS token (degenerate/legacy shape — + no production mint writes one, see the target-completeness test below) + keeps the pre-fix op+session retirement, so nothing becomes un-retirable. + The ~5 pre-existing target-less retirement tests in + TestTokenLifecycleExtensions pin the same fallback end-to-end.""" + from merge_guard_post import write_token, _retire_token_for_command + + write_token({"operation_type": "merge"}, token_dir=tmp_path) + retired = _retire_token_for_command("gh pr merge 99", "merge", token_dir=tmp_path) + assert retired is True, ( + "defensive fallback broken: target-less token no longer retired by " + "an op+session match — the predicate was implemented STRICT (bug)" + ) + + # --- target-completeness (the by-construction claim, verified) --- + + def test_every_production_mint_writes_a_targeted_token(self, tmp_path): + """The defensive fallback's coarse-retirement residual is provably EMPTY + in production: every op-class's canonical approval mints a context whose + `_target_value` is non-None. Source audit (the completeness half): + `write_token` has exactly ONE production call site (merge_guard_post + main's mint path), fed only by `_mint_context_from_bundle`, whose + `_collect_pairs` admits a pair ONLY when `op_type is not None and + target is not None` — so no mint path can write a target-less token. + This sweep is the per-op-class empirical corroboration.""" + from merge_guard_post import _mint_context_from_bundle, _target_value + + approvals = [ + "gh pr merge 42", + "gh pr close 42 --delete-branch", + "git push --force origin main", + "git push origin main", + "git branch -D victim", + "git push origin :feature", + "git push --prune origin", + "gh api -X DELETE repos/o/r/branches/main/protection", + "gh api -X PUT /repos/o/r/pulls/42/merge", + ] + for cmd in approvals: + question = { + "question": "Proceed?", + "options": [{"label": "Yes, do it", + "description": f"Run `{cmd}` now"}], + "multiSelect": False, + } + ctx, refusal = _mint_context_from_bundle( + [question], {"Proceed?": "Yes, do it"}) + assert ctx is not None, f"canonical approval failed to mint: {cmd!r} ({refusal})" + assert _target_value(ctx) is not None, ( + f"TARGET-LESS PRODUCTION MINT for {cmd!r} — the defensive " + f"fallback's coarse retirement is REACHABLE in production " + f"(separate pre-existing over-block; STOP and flag)" + ) + + # --- non-vacuity: the survival rows are coupled to the target predicate --- + + def test_target_predicate_non_vacuous_under_target_value_neuter( + self, tmp_path, monkeypatch + ): + """Two-direction counter-mutation: with `_target_value` neutered to + always-None (the pre-fix surface — no target ever discriminates, every + token falls to the op+session fallback), the unrelated same-op command + RETIRES the operator's 42 token again — proving the survival rows above + are coupled to the #1097 target predicate, not vacuously green. + In-memory (monkeypatch) by design — no git mutation in the shared + worktree.""" + import merge_guard_post as mgp + + # direction 1 — fix present: the unrelated command does not retire + mgp.write_token({"operation_type": "close", "pr_number": "42", + "bound_flags": ["--delete-branch"]}, token_dir=tmp_path) + assert mgp._retire_token_for_command( + "gh pr close 43", "close", token_dir=tmp_path) is False + assert len(self._live(tmp_path)) == 1 + # direction 2 — pre-fix surface restored: the coarse retirement returns + monkeypatch.setattr(mgp, "_target_value", lambda ctx: None) + assert mgp._retire_token_for_command( + "gh pr close 43", "close", token_dir=tmp_path) is True, ( + "target-value neuter did not restore the pre-fix coarse retirement " + "— the survival assertions above would be vacuous" + ) + assert len(self._live(tmp_path)) == 0 + + class TestD1MatcherQuoteNormalization: """D1-matcher (#933): _token_matches_command normalizes surrounding quotes on the captured branch name at compare time, so a token branch `feat/x` diff --git a/pact-plugin/tests/test_merge_guard_auth_symmetry.py b/pact-plugin/tests/test_merge_guard_auth_symmetry.py index bccfc263..9a821426 100644 --- a/pact-plugin/tests/test_merge_guard_auth_symmetry.py +++ b/pact-plugin/tests/test_merge_guard_auth_symmetry.py @@ -924,7 +924,18 @@ class TestLegBoundedMintWindow: op's own leg BY CONSTRUCTION (the symmetry pin below guards the shared-helper wiring). Every test drives the REAL mint (post.main) and the REAL read seam; the counter-mutations prove the canaries are coupled to each half of the - single-commit fix.""" + single-commit fix. + + #1087 update: the CLOSE channel is now closed more fundamentally at the READ + FLOOR — the close danger arms match PER-LEG (_CLOSE_LITERAL_ARMS), so the + ambiguous cross-leg close member is is_dangerous=False and mints NOTHING (the + two-tier bind is no longer REACHED by a dangerous close). The close-member + pins below therefore assert mint-nothing / run-free rather than a []-bind. The + two-tier bind is RETAINED for the push/merge/force channels (which still + produce real tokens) and as defense-in-depth for a future isolable op — the + tier helpers stay exercised by test_two_tier_selection_precedence, and the + invariant that justifies retention is pinned by the OPEN-Q D tripwire + (TestEmergentDangerClassIsCloseOnly).""" # --- cured members: byte-identical re-approval AUTHORIZES (pre-fix RED) --- @@ -987,7 +998,7 @@ def test_escalation_channel_closed(self, member, escalated, tmp_path): assert _token_bound_flags(tmp_path) == [] assert _authorize(escalated, tmp_path) is not None - # --- close op-class: the emergent-danger members, BOTH shapes cured --- + # --- close op-class: the emergent members are now not-dangerous per-leg --- @pytest.mark.parametrize( "member", @@ -996,18 +1007,22 @@ def test_escalation_channel_closed(self, member, escalated, tmp_path): "echo --delete-branch && gh pr close 42", # reversed close arm ], ) - def test_close_emergent_member_round_trips_both_shapes(self, member, tmp_path): - """The emergent-danger close members (zero individually-dangerous legs; - whole-command danger from the close arms' cross-leg lookaheads): the read - seam's tier-2 fallback binds [] from the unique DETECTABLE close leg, and - the mint window's identical two-tier selection binds [] from the same leg - — byte-identical re-approval AUTHORIZES. The reversed shape is the row - that a positional leg[0] window failed (leg[0] is the echo leg there): - the two-tier selection is what makes BOTH shapes cure.""" + def test_close_emergent_member_mints_nothing_and_runs_free(self, member, tmp_path): + """Post-#1087 the close danger arms match PER-LEG at the read floor, so + neither shape has `gh pr close` and `--delete-branch` together in ONE leg + — is_dangerous=False. The member is no longer an emergent-danger compound: + the approval mints NOTHING (write-gate: not_dangerous) and the + byte-identical re-execution RUNS FREE (a reversible bare close plus an + echo). Both shapes cure identically because the per-leg floor is + position-independent — the reversed shape (leg[0] is the echo) is no + longer a special case, it too has no in-leg close+flag. This is the #1087 + over-block removal; the two-tier bind that formerly bound [] here is no + longer reached by a dangerous close (retained as defense-in-depth per + OPEN-Q A).""" q = "Close it?" opts = [_opt("Yes, close", f"On approval run: `{member}`"), _opt("Cancel", "Abort")] assert _invoke_post([_q(q, opts)], {q: "Yes, close"}, tmp_path) == 0 - assert _token_bound_flags(tmp_path) == [] + assert len(_minted_tokens(tmp_path)) == 0 assert _authorize(member, tmp_path) is None def test_close_laundering_channel_closed(self, tmp_path): @@ -1016,11 +1031,17 @@ def test_close_laundering_channel_closed(self, tmp_path): as a reversible bare close plus an echo — minted bound_flags=['--delete-branch'] from the echo literal, and the ESCALATED `gh pr close 42 --delete-branch` (a REAL, irreversible branch-deleting - close) AUTHORIZED against it. Post-fix the token binds [] → REFUSE.""" + close) AUTHORIZED against it. Post-#1087 the mechanism is more + fundamental: the member is is_dangerous=False per-leg, so the approval + mints NOTHING (not a []-token via the two-tier bind) and the escalated + single REFUSES for lack of any token. The explicit zero-mint assertion + makes this non-vacuous — a regression that re-minted the member would flip + it RED at the count, not silently pass on the DENY.""" member = "gh pr close 42 && echo --delete-branch" q = "Close it?" opts = [_opt("Yes, close", f"On approval run: `{member}`"), _opt("Cancel", "Abort")] assert _invoke_post([_q(q, opts)], {q: "Yes, close"}, tmp_path) == 0 + assert len(_minted_tokens(tmp_path)) == 0 assert _authorize("gh pr close 42 --delete-branch", tmp_path) is not None def test_close_flag_on_op_single_leg_round_trips(self, tmp_path): @@ -1043,7 +1064,7 @@ def test_bare_close_boundary_stays_ungated(self, tmp_path): cmd = "gh pr close 42" q = "Close it?" opts = [_opt("Yes, close", f"On approval run: `{cmd}`"), _opt("Cancel", "Abort")] - _invoke_post([_q(q, opts)], {q: "Yes, close"}, tmp_path) + assert _invoke_post([_q(q, opts)], {q: "Yes, close"}, tmp_path) == 0 assert len(_minted_tokens(tmp_path)) == 0 assert _authorize(cmd, tmp_path) is None @@ -1069,20 +1090,25 @@ def test_op_leg_flag_binds_wherever_the_op_leg_sits(self, cmd, expected_flags, t assert _token_bound_flags(tmp_path) == expected_flags assert _authorize(cmd, tmp_path) is None - # --- ambiguity fail-safe + destructive-path precedence --- - - def test_ambiguous_execution_denies_against_clean_member_token(self, tmp_path): - """Ambiguity fail-safe: a >=2-detectable-leg execution (no unique op leg) - makes the read seam fall back to the conservative WHOLE-command scan, - which binds the echo literal — set-mismatch vs the clean member token's - [] → DENY. Ambiguity can only collapse WIDER, never narrower.""" - member = "gh pr close 42 && echo --delete-branch" + # --- ambiguity fail-safe: the #1087 laundering-closed proof (real seam) --- + + def test_ambiguous_approval_source_mints_nothing_and_escalated_single_denies(self, tmp_path): + """#1087 laundering closed, end-to-end over the REAL mint + read seams: + pre-fix, approving the ambiguous multi-close + `gh pr close 42 && gh pr close 43 && echo --delete-branch` (which reads as + two reversible bare closes plus an echo) minted a token whose set + AUTHORIZED the escalated single `gh pr close 42 --delete-branch` (a real + branch delete). Post-fix the ambiguous source is is_dangerous=False + per-leg → the approval mints NOTHING, and the escalated single-command + REFUSES for lack of any token. This is the approval-SOURCE direction the + #1083 close pins never exercised (they drove the member as an execution + target); it is the direct laundering-closed proof.""" + ambiguous = "gh pr close 42 && gh pr close 43 && echo --delete-branch" q = "Close it?" - opts = [_opt("Yes, close", f"On approval run: `{member}`"), _opt("Cancel", "Abort")] + opts = [_opt("Yes, close", f"On approval run: `{ambiguous}`"), _opt("Cancel", "Abort")] assert _invoke_post([_q(q, opts)], {q: "Yes, close"}, tmp_path) == 0 - assert _token_bound_flags(tmp_path) == [] - ambiguous = "gh pr close 42 && gh pr close 43 && echo --delete-branch" - assert _authorize(ambiguous, tmp_path) is not None + assert len(_minted_tokens(tmp_path)) == 0 + assert _authorize("gh pr close 42 --delete-branch", tmp_path) is not None def test_two_tier_selection_precedence(self): """Destructive-path precedence, at the tier helpers directly: a dangerous @@ -1180,7 +1206,7 @@ def test_compound_refuse_upstream_of_bind(self, tmp_path): member = "gh pr merge 5 && git branch -Df victim" q = "Proceed?" opts = [_opt("Yes", f"On approval run: `{member}`"), _opt("Cancel", "Abort")] - _invoke_post([_q(q, opts)], {q: "Yes"}, tmp_path) + assert _invoke_post([_q(q, opts)], {q: "Yes"}, tmp_path) == 0 assert len(_minted_tokens(tmp_path)) == 0 # --- non-vacuity: in-memory counter-mutations, both halves, both directions --- @@ -1213,26 +1239,45 @@ def test_window_is_non_vacuous_under_full_text_mutation(self, tmp_path, monkeypa "full-text mutation did not re-open the laundering channel" ) - def test_read_fallback_is_non_vacuous_under_tier2_neutering(self, tmp_path, monkeypatch): - """READ-half counter-mutation: neuter tier 2 (_single_detectable_leg → - None, the binding merge_guard_pre resolves at call time) and assert the - emergent close member's byte-identical re-approval flips BACK to DENY - (read falls back to the whole-command scan and binds the echo literal - against the [] token). Proves the both-shapes-cure canaries are coupled - to the read fallback, not just the window.""" - member = "gh pr close 42 && echo --delete-branch" + def test_close_laundering_closed_is_non_vacuous_under_whole_command_match( + self, tmp_path, monkeypatch): + """READ-FLOOR non-vacuity for the #1087 close per-leg conversion — replaces + the tier-2-neutering non-vacuity, which is void now that is_dangerous is + per-leg and independent of tier 2. Direction 1 — fix present: the + ambiguous multi-close is is_dangerous=False, mints nothing, and the + escalated single denies. Direction 2 — restore the PRE-FIX whole-command + close match by neutering the leg substrate to a single whole-command + "leg" (identity slice), so the per-leg close arms fire over the WHOLE + stripped command again: the ambiguous source becomes is_dangerous=True, + the approval MINTS, and the escalated single AUTHORIZES — the laundering + channel RE-OPENS. Proves the close-channel canaries are coupled to the + per-leg conversion, not vacuously green.""" + from shared.merge_guard_common import is_dangerous_command + ambiguous = "gh pr close 42 && gh pr close 43 && echo --delete-branch" + escalated = "gh pr close 42 --delete-branch" q = "Close it?" - opts = [_opt("Yes, close", f"On approval run: `{member}`"), _opt("Cancel", "Abort")] - # direction 1 — fix present: byte-identical authorizes + opts = [_opt("Yes, close", 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, close"}, tmp_path) == 0 - assert _token_bound_flags(tmp_path) == [] - assert _authorize(member, tmp_path) is None - # direction 2 — tier 2 neutered: the emergent over-block returns - import merge_guard_pre as pre_mod - monkeypatch.setattr(pre_mod, "_single_detectable_leg", lambda c: None) - assert _authorize(member, tmp_path) is not None, ( - "tier-2 neutering did not restore the emergent-danger over-block — " - "the both-shapes-cure canaries would be vacuous" + assert len(_minted_tokens(tmp_path)) == 0 + assert _authorize(escalated, tmp_path) is not None + # direction 2 — pre-fix whole-command close 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 close match — " + "the close-channel canaries would be vacuous" + ) + tmp2 = tmp_path / "prefix-sim" + tmp2.mkdir() + assert _invoke_post([_q(q, opts)], {q: "Yes, close"}, tmp2) == 0 + assert len(_minted_tokens(tmp2)) == 1, ( + "whole-command close match did not restore the pre-fix mint — " + "the close laundering-closed canary would be vacuous" + ) + assert _authorize(escalated, tmp2) is None, ( + "whole-command close match did not re-open the laundering channel" ) diff --git a/pact-plugin/tests/test_merge_guard_op_recognition_completeness.py b/pact-plugin/tests/test_merge_guard_op_recognition_completeness.py index 1810b743..fd361d81 100644 --- a/pact-plugin/tests/test_merge_guard_op_recognition_completeness.py +++ b/pact-plugin/tests/test_merge_guard_op_recognition_completeness.py @@ -47,9 +47,11 @@ extract_command_context as CTX, ) from merge_guard_pre import _token_matches_command as MATCH # noqa: E402 +from merge_guard_pre import check_merge_authorization # noqa: E402 from merge_guard_post import ( # noqa: E402 _mint_context_from_bundle, _target_value, + write_token, ) IMPLICIT = "\x00implicit" # the implicit-remote marker used by remote-mass-delete @@ -1161,6 +1163,727 @@ def test_first_leg_anchoring_is_non_vacuous_under_whole_command_mutation(self, m ) +class TestCloseLiteralArmCrossLegSweep: + """#1087 CLOSE cross-leg completion — the permanent bidirectional sweep for + the per-leg `_CLOSE_LITERAL_ARMS` conversion, modeled on the #1082 force-push + pair (`test_literal_arm_cross_leg_span_cured` / `_same_leg_still_gates`). + + The close danger arms (`gh pr close` + `--delete-branch`) previously ran their + `.*`/lookahead over the WHOLE stripped command, so a `--delete-branch` token in + a benign continuation leg fired the arm cross-leg — an OVER-BLOCK, and the + substrate of the #1087 laundering (the ambiguous multi-close minted a close + token that authorized an escalated same-target single). The conversion matches + per-leg: an arm fires iff `gh pr close` and `--delete-branch` co-occur within + ONE leg. Per §0, the over-block-REMOVED direction is the PRIMARY/INVIOLABLE + gate proven first; the same-leg-STILL-gates direction is the secondary + no-new-under-block sweep.""" + + # --- PRIMARY (§0-inviolable): over-block REMOVED — benign compounds run FREE --- + @pytest.mark.parametrize( + "cmd", + [ + "gh pr close 42 && gh pr close 43 && echo --delete-branch", # AMBIG (the #1087 attack) + "gh pr close 42 && echo --delete-branch", # single-member + "echo --delete-branch && gh pr close 42", # reversed + ], + ) + def test_close_arm_cross_leg_span_cured(self, cmd): + """#1087 CURED: a bare `gh pr close` chained with a `--delete-branch` token + in a SEPARATE leg no longer gates (the token and the close verb never + co-occur in one leg). This is the over-block removal AND the laundering + substrate removal — the ambiguous form is now is_dangerous=False, so the + mint write-gate refuses and no token can be minted to launder.""" + assert D(cmd) is False, f"CLOSE cross-leg over-block regressed: {cmd!r}" + + # --- SECONDARY (no-new-under-block): same-leg close+flag STILL gates --- + @pytest.mark.parametrize( + "cmd", + [ + "gh pr close 42 --delete-branch", # single leg + "cd /repo && gh pr close 42 --delete-branch", # CRITICAL must-not-regress (co-occur in leg[1]) + 'git commit -m "a && b" && gh pr close 42 --delete-branch', # quoted && is substrate, real close leg gates + "bash -c 'gh pr close 42 --delete-branch'", # quoted single leg + "gh --repo o/r pr close 42 --delete-branch", # global-flag prefix + "gh pr close 42 -d", # first-leg flag-condition union arm + "gh pr close -d 42", # flag before positional + "gh pr close 42 -cd", # clustered short flag + "gh pr close 5 -d && echo done", # first-leg -d + benign continuation + ], + ) + def test_close_arm_same_leg_still_gates(self, cmd): + """The no-new-under-block set: `gh pr close` + delete flag co-occurring + within ONE leg still gates in ANY leg position — including the CRITICAL + `cd /repo && gh pr close 42 --delete-branch` (True today ONLY via the + in-leg per-leg match the #1087 conversion re-establishes; it was True + pre-fix via the cross-leg lookahead the fix removes). The `-d`/`-cd` + short-flag forms gate via the first-leg flag-condition union arm.""" + assert D(cmd) is True, f"NEW UNDER-BLOCK: same-leg dangerous close stopped gating: {cmd!r}" + + def test_close_arm_same_leg_control_classifies_close(self): + """Non-vacuity control for the same-leg set: the preserved forms classify + as `close`, so the D=True rows discriminate a real gated close from a + blanket 'compound is always safe'.""" + assert OP("cd /repo && gh pr close 42 --delete-branch") == "close" + assert OP("gh pr close 42 --delete-branch") == "close" + + @pytest.mark.parametrize( + "cmd", + [ + "gh pr close 42 && gh pr close 43 && echo --delete-branch", + "gh pr close 42 && echo --delete-branch", + "echo --delete-branch && gh pr close 42", + ], + ) + def test_close_cured_rows_non_vacuous_and_single_family(self, cmd, monkeypatch): + """Row-by-row non-vacuity AND identity-slice faithfulness for every cured + close form. Two-stage counter-mutation over the module-global bindings + `is_dangerous_command` resolves at call time: + + direction 1 (fix present): the benign compound runs free (D is False). + direction 2 (`_slice_stripped_legs` -> identity, the pre-fix whole-command + surface): the over-block RETURNS (D is True) — coupling the assertion + to the per-leg partition. + faithfulness (identity-slice + `_CLOSE_LITERAL_ARMS` neutered): D returns + to False — proving the whole-command flip is caused by the CLOSE family + arm ALONE, with no second-family (force-push/API/flag-condition) + co-match. This is why the identity-slice mutation is a FAITHFUL pre-fix + simulation for this row (the coder flagged this for reviewer + confirmation; it is confirmed here per-row, not assumed single-family).""" + assert mgc.is_dangerous_command(cmd) is False + monkeypatch.setattr(mgc, "_slice_stripped_legs", lambda s: [s]) + assert mgc.is_dangerous_command(cmd) is True, ( + f"whole-command mutation did not restore the pre-fix close over-block " + f"— the cured-row assertion would be vacuous: {cmd!r}" + ) + monkeypatch.setattr(mgc, "_CLOSE_LITERAL_ARMS", ()) + assert mgc.is_dangerous_command(cmd) is False, ( + f"whole-command flip survived close-arm neutering — a SECOND family " + f"co-matches, so the identity-slice mutation is NOT a faithful " + f"single-family pre-fix simulation for this row: {cmd!r}" + ) + + +class TestApiLiteralArmCrossLegSweep: + """#1086 API cross-leg completion — the permanent bidirectional sweep for the + per-leg `_API_LITERAL_ARMS` conversion (17 arms), modeled on the #1082 + force-push pair. The API danger arms previously ran their `.*` over the WHOLE + stripped command, so a mutating method / body-flag token in a benign + continuation leg (`gh api .../git/refs && echo -X DELETE`) over-blocked the + benign compound. Per-leg now: an arm fires iff the API client, the mutating + method (or implicit-POST body flag), and the target endpoint co-occur within + ONE leg. Unlike close, the API emergent compounds are PURE over-blocks (an + isolated bare API leg is method-less hence detect-negative, so tier-2 abstains + symmetrically — no laundering asymmetry; pinned by the OPEN-Q D tripwire in + test_merge_guard_over_block_batch.py). §0 priority: over-block-removed first.""" + + # --- PRIMARY (§0-inviolable): over-block REMOVED — one row per family --- + @pytest.mark.parametrize( + "cmd", + [ + "gh api repos/o/r/git/refs/heads/x && echo -X DELETE", # git/refs DELETE + "gh api repos/o/r/git/refs/heads/x && echo -X PATCH", # git/refs mutate + "gh api repos/o/r/branches/main/protection && echo -X DELETE", # protection + "gh api repos/o/r/pulls/5/merge && echo -X PUT", # merge + "gh api repos/o/r/contents/f && echo -X PUT main", # contents + "gh api repos/o/r/git/refs/heads/x && echo -f sha=abc", # implicit-POST body flag + "curl https://api.github.com/repos/o/r/git/refs/heads/x && echo -X DELETE", # curl git/refs + "wget https://api.github.com/repos/o/r/git/refs/heads/x && echo --method=DELETE", # wget git/refs + ], + ) + def test_api_arm_cross_leg_span_cured(self, cmd): + """#1086 CURED: an API read in one leg with a mutating method / body-flag + token in a SEPARATE (benign `echo`) leg no longer gates — the method and + endpoint never co-occur in one leg. One representative row per API danger + family (git/refs DELETE + mutate, protection, merge, contents, + implicit-POST, curl, wget).""" + assert D(cmd) is False, f"API cross-leg over-block regressed: {cmd!r}" + + # --- SECONDARY (no-new-under-block): same-leg dangerous API STILL gates --- + @pytest.mark.parametrize( + "cmd", + [ + "gh api -X DELETE repos/o/r/git/refs/heads/x", # single leg + "cd /repo && gh api -X DELETE repos/o/r/git/refs/heads/x", # non-first leg (method+path co-occur) + "gh api repos/o/r/pulls/5/merge -X PUT", # merge, method after path + "curl -X DELETE https://api.github.com/repos/o/r/git/refs/heads/x", # curl same-leg + "wget --method=DELETE https://api.github.com/repos/o/r/git/refs/heads/x", # wget same-leg + "gh api -X DELETE repos/o/r/branches/main/protection", # protection (first-leg) + "gh api -f sha=abc repos/o/r/git/refs/heads/x", # implicit-POST body flag same-leg + ], + ) + def test_api_arm_same_leg_still_gates(self, cmd): + """The no-new-under-block set: the API client + mutating method (or + implicit-POST body flag) + endpoint co-occurring within ONE leg still gates + in ANY leg position. (The non-first-leg protection form + `cd /repo && gh api -X DELETE .../branches/main/protection` is separately + pinned at `test_literal_arm_contrast_still_gates_non_first_leg`; the + git/refs non-first-leg row here is its sibling, not a duplicate.)""" + assert D(cmd) is True, f"NEW UNDER-BLOCK: same-leg dangerous API stopped gating: {cmd!r}" + + def test_api_arm_same_leg_control_detects_op(self): + """Non-vacuity control: the preserved same-leg API forms classify to a + real destructive op (detect untouched by the read-floor conversion), so + the D=True rows discriminate a real gated API call.""" + assert OP("gh api -X DELETE repos/o/r/git/refs/heads/x") == "branch-delete" + assert OP("gh api -X DELETE repos/o/r/branches/main/protection") == "branch-protection" + + @pytest.mark.parametrize( + "cmd", + [ + "gh api repos/o/r/git/refs/heads/x && echo -X DELETE", + "gh api repos/o/r/git/refs/heads/x && echo -X PATCH", + "gh api repos/o/r/branches/main/protection && echo -X DELETE", + "gh api repos/o/r/pulls/5/merge && echo -X PUT", + "gh api repos/o/r/contents/f && echo -X PUT main", + "gh api repos/o/r/git/refs/heads/x && echo -f sha=abc", + "curl https://api.github.com/repos/o/r/git/refs/heads/x && echo -X DELETE", + "wget https://api.github.com/repos/o/r/git/refs/heads/x && echo --method=DELETE", + ], + ) + def test_api_cured_rows_non_vacuous_and_single_family(self, cmd, monkeypatch): + """Row-by-row non-vacuity AND identity-slice faithfulness for every cured + API form (same two-stage counter-mutation as the close sweep): + + direction 1 (fix present): benign compound runs free (D is False). + direction 2 (`_slice_stripped_legs` -> identity): the over-block RETURNS + (D is True) — coupling the assertion to the per-leg partition. + faithfulness (identity-slice + `_API_LITERAL_ARMS` neutered): D returns to + False — proving the whole-command flip is caused by the API family arm + ALONE (no force-push/close/flag-condition co-match), so the + identity-slice mutation is a faithful single-family pre-fix simulation + for this row.""" + assert mgc.is_dangerous_command(cmd) is False + monkeypatch.setattr(mgc, "_slice_stripped_legs", lambda s: [s]) + assert mgc.is_dangerous_command(cmd) is True, ( + f"whole-command mutation did not restore the pre-fix API over-block " + f"— the cured-row assertion would be vacuous: {cmd!r}" + ) + monkeypatch.setattr(mgc, "_API_LITERAL_ARMS", ()) + assert mgc.is_dangerous_command(cmd) is False, ( + f"whole-command flip survived API-arm neutering — a SECOND family " + f"co-matches, so the identity-slice mutation is NOT a faithful " + f"single-family pre-fix simulation for this row: {cmd!r}" + ) + + +class _ApiMergeDetectArmDisabledRe: + """A drop-in for merge_guard_common's `re` module that forces + re.search(r'pulls/\\d+/merge\\b', ...) to return None — surgically disabling + ONLY the API-merge detect arm's endpoint condition (the per-leg + `re.search(r"pulls/\\d+/merge\\b", _leg)` inside detect_command_operation_type) + so the pre-fix detect surface (API merge unclassified) can be measured through + the REAL machinery. `_extract_api_merge_pr` uses the DIFFERENT literal + `pulls/(\\d+)/merge\\b` (capturing group) and is unaffected; every compiled + DANGEROUS_PATTERNS object calls its own .search, not mgc.re. Mirrors the + _ContentsGuardDisabledRe seam above.""" + + def search(self, pattern, string, *args, **kwargs): # noqa: A003 + if pattern == r"pulls/\d+/merge\b": + return None + return _real_re.search(pattern, string, *args, **kwargs) + + def __getattr__(self, name): + return getattr(_real_re, name) + + +class TestApiMergeMintParity: + """#1096 API-merge mint parity — the bidirectional cert for the additive + per-leg detect arm (GH-API-ONLY per Option B + mutating PUT/PATCH/POST + a + pulls//merge endpoint in ONE leg → "merge") plus the path-based + `_extract_api_merge_pr` wired into `extract_command_context` as the + pr_number fallback. + + Pre-fix, a mutating API PR-merge was the gated-but-unmintable class: the + read floor gated it (DANGEROUS_PATTERNS API-merge arms) but detect returned + None, so the mint write-gate refused → a faithful API-merge click could + NEVER mint — a PERMANENT over-block. The cure raises recognition in the ONE + detect SSOT (mint + read + retirement observer all served from one site; + never a mint-only recognition fork). + + Priority per the governing principle: over-block-CURED (mints + authorizes) + is the PRIMARY/inviolable direction; additive purity is co-inviolable (an + existing classification change could re-block a faithful click elsewhere); + the no-new-under-block rows are the secondary sweep. GH-API-ONLY (Option B): + curl/wget api-merge mints NOTHING (detect=None → mint=0) — dropped because a + value-flag denylist over curl/wget's unbounded flag space is unsound (sec #71). + Curl/wget merges stay READ-gated (is_dangerous=True) = the pre-existing + gated-but-unmintable state; that structural fact is asserted in + TestApiMergeEndpointResidualBoundary::test_curl_wget_api_merge_mints_nothing_by_construction.""" + + def _authorize(self, cmd, tmp_path): + """Mint `cmd` via the real bundle path, write the token, and run the + real read-side authorization. Returns (ctx, verdict).""" + ctx, refusal = mint(cmd) + if ctx is None: + return None, f"NO-MINT({refusal})" + write_token(ctx, token_dir=tmp_path) + err = check_merge_authorization(cmd, token_dir=tmp_path) + return ctx, ("ALLOW" if err is None else "DENY") + + # --- PRIMARY (inviolable): every faithful spelling classifies + mints --- + + @pytest.mark.parametrize( + "cmd,expected_flags", + [ + ("gh api -X PUT /repos/o/r/pulls/42/merge", []), + ("gh api -X PATCH /repos/o/r/pulls/42/merge", []), + ("gh api -X POST /repos/o/r/pulls/42/merge", []), + ("gh -R o/r api /repos/o/r/pulls/42/merge -X PUT", ["--repo=o/r"]), + ], + ) + def test_api_merge_spelling_classifies_and_mints(self, cmd, expected_flags): + """#1096 CURED: every faithful API-merge spelling detects as `merge` and + mints a pr-targeted token. The gh-global-flag row is the Ruling-B cure + (tolerant _GH_API_PREFIX client match) and binds its REAL -R flag as + [--repo=o/r] (ruled correct: a real denylist flag, spelling-appropriate); + every path-resident-only spelling binds [].""" + assert OP(cmd) == "merge", f"API-merge spelling unclassified: {cmd!r}" + ctx, refusal = mint(cmd) + assert ctx is not None, f"OVER-BLOCK regressed (no mint): {cmd!r} ({refusal})" + assert ctx["pr_number"] == "42" + assert ctx["bound_flags"] == expected_flags + + @pytest.mark.parametrize( + "cmd", + [ + "gh api -X PUT /repos/o/r/pulls/42/merge", + "gh -R o/r api /repos/o/r/pulls/42/merge -X PUT", + ], + ) + def test_api_merge_faithful_round_trip_authorizes(self, cmd, tmp_path): + """The full faithful click: approve → mint → byte-identical re-execution + AUTHORIZES through the real read path (the §0 inviolable round-trip).""" + _ctx, verdict = self._authorize(cmd, tmp_path) + assert verdict == "ALLOW", ( + f"faithful API-merge round-trip blocked ({verdict}): {cmd!r}" + ) + + def test_global_flag_repo_bind_refuses_cross_repo(self, tmp_path): + """The [--repo] round-trip's safe edge: the repo-scoped token REFUSES an + exec against a DIFFERENT repo (the escalation-discriminator direction).""" + ctx, refusal = mint("gh -R o/r api /repos/o/r/pulls/42/merge -X PUT") + assert ctx is not None and ctx["bound_flags"] == ["--repo=o/r"] + write_token(ctx, token_dir=tmp_path) + err = check_merge_authorization( + "gh -R other/x api /repos/other/x/pulls/42/merge -X PUT", + token_dir=tmp_path) + assert err is not None, "cross-repo exec must REFUSE against a repo-bound token" + + # --- cross-spelling authorization (correct same-op + non-laundering) --- + + def test_cli_approval_authorizes_api_exec_same_flags(self, tmp_path): + """Cross-spelling is CORRECT same-operation authorization: a plain CLI + approval (bound []) authorizes the plain API exec of the SAME pr (both + derive {merge, 42, []} from the one extract_command_context).""" + ctx, _ = mint("gh pr merge 42") + assert ctx is not None and ctx["bound_flags"] == [] + write_token(ctx, token_dir=tmp_path) + assert check_merge_authorization( + "gh api -X PUT /repos/o/r/pulls/42/merge", token_dir=tmp_path) is None + + def test_api_approval_authorizes_cli_exec_same_flags(self, tmp_path): + ctx, _ = mint("gh api -X PUT /repos/o/r/pulls/42/merge") + assert ctx is not None and ctx["bound_flags"] == [] + write_token(ctx, token_dir=tmp_path) + assert check_merge_authorization("gh pr merge 42", token_dir=tmp_path) is None + + def test_privileged_cli_approval_refuses_plain_api_exec(self, tmp_path): + """Non-laundering: a PRIVILEGED CLI approval (bound [--admin]) does NOT + authorize the plain API exec (flag-set mismatch) — the privileged + approval cannot be laundered into a differently-shaped execution.""" + ctx, _ = mint("gh pr merge 42 --admin") + assert ctx is not None and ctx["bound_flags"] == ["--admin"] + write_token(ctx, token_dir=tmp_path) + assert check_merge_authorization( + "gh api -X PUT /repos/o/r/pulls/42/merge", token_dir=tmp_path) is not None + + def test_api_approval_refuses_different_target(self, tmp_path): + ctx, _ = mint("gh api -X PUT /repos/o/r/pulls/42/merge") + assert ctx is not None + write_token(ctx, token_dir=tmp_path) + assert check_merge_authorization( + "gh api -X PUT /repos/o/r/pulls/43/merge", token_dir=tmp_path) is not None + + # --- additive purity + no-new-under-block: negatives stay unmintable --- + + @pytest.mark.parametrize( + "cmd", + [ + "gh api /repos/o/r/pulls/42/merge", # bare GET (merge-status read) + "gh api -X GET /repos/o/r/pulls/42/merge", # explicit GET + "gh api -X DELETE /repos/o/r/pulls/42/merge", # DELETE excluded + "gh api -X PUT /repos/o/r/merges/xyz", # non-pulls path (read-gated residual, unmintable) + "gh api -f commit_title=t /repos/o/r/pulls/42/merge", # implicit-POST (deliberate residual) + "gh api repos/o/r/pulls/5/merge && echo -X PUT", # per-leg canary + "gh -R o/r api repos/o/r/pulls/5/merge && echo -X PUT", # per-leg canary, tolerant matcher + ], + ) + def test_non_faithful_api_merge_stays_unclassified_and_unmintable(self, cmd): + """No-new-under-block + additive purity: every non-faithful form stays + detect-None and unmintable. The per-leg canaries are the rows that would + FAIL under a whole-command arm (a method keyword in a benign continuation + leg must not classify the compound) — they hold under the tolerant client + matcher too (Ruling-B widens only the in-leg client span, not the leg + isolation). The non-pulls and implicit-POST rows are read-floor-gated + (D=True) but deliberately unmintable — PRE-EXISTING residuals (read-floor + breadth / implicit-POST parity with git/refs), not #1096 regressions.""" + assert OP(cmd) is None, f"non-faithful API-merge form classified: {cmd!r}" + ctx, _refusal = mint(cmd) + assert ctx is None, f"non-faithful API-merge form MINTED: {cmd!r}" + + @pytest.mark.parametrize( + "cmd,expected_op", + [ + ("gh api -X DELETE repos/o/r/git/refs/heads/x", "branch-delete"), + ("gh api -X PATCH repos/o/r/git/refs/heads/x", "force-push"), + ("gh api -X DELETE repos/o/r/branches/main/protection", "branch-protection"), + ("gh pr merge 42", "merge"), + ("gh pr close 42 --delete-branch", "close"), + ("git push --force origin main", "force-push"), + ("git branch -D victim", "branch-delete"), + ("gh api /repos/o/r/git/refs/heads/x", None), + ("gh api /repos/o/r/contents/file.txt", None), + ("echo PUT pulls and merge", None), + ], + ) + def test_additive_purity_existing_classifications_unchanged(self, cmd, expected_op): + """Additive purity (inviolable): the pre-existing detect corpus is + untouched by the new arm — every existing op-class still classifies + identically and every previously-None input stays None. The ONLY input + class the arm changes is the formerly-None mutating pulls//merge.""" + assert OP(cmd) == expected_op + + # --- non-vacuity: two independent in-memory couplings --- + + def test_mint_rows_non_vacuous_under_detect_arm_disable(self, tmp_path): + """Direction-2 counter-mutation #1: surgically disable ONLY the API-merge + detect arm (its exact endpoint literal, via the delegating re-module + shim) — the pre-fix surface returns: detect None → the mint write-gate + refuses → the faithful API click is blocked again. The CLI-merge control + still mints, so the flip is attributable to the NEW arm alone.""" + cmd = "gh api -X PUT /repos/o/r/pulls/42/merge" + # direction 1 — fix present + assert OP(cmd) == "merge" + ctx, _ = mint(cmd) + assert ctx is not None + # direction 2 — arm disabled: pre-fix gated-but-unmintable returns + saved = mgc.re + mgc.re = _ApiMergeDetectArmDisabledRe() + try: + assert OP(cmd) is None, ( + "arm-disable shim did not restore the pre-fix detect surface — " + "the cured-spelling assertions would be vacuous" + ) + ctx2, _ = mint(cmd) + assert ctx2 is None, "mint survived the detect-arm disable" + ctl, _ = mint("gh pr merge 42") + assert ctl is not None, "CLI-merge control must be unaffected by the shim" + finally: + mgc.re = saved + + def test_mint_rows_non_vacuous_under_extractor_neuter(self, monkeypatch): + """Direction-2 counter-mutation #2: neuter `_extract_api_merge_pr` to + always-None — detect still says merge, but the context loses its + pr_number, `_collect_pairs` admits no (op, target) pair, and the mint + REFUSES — proving the mint rows are coupled to the extractor wiring + (recognition<->extractability), not just the detect arm.""" + cmd = "gh api -X PUT /repos/o/r/pulls/42/merge" + ctx, _ = mint(cmd) + assert ctx is not None + monkeypatch.setattr(mgc, "_extract_api_merge_pr", lambda c: None) + assert OP(cmd) == "merge" # detect unaffected — isolates the extractor + ctx2, _ = mint(cmd) + assert ctx2 is None, ( + "extractor neuter did not break the mint — the pr-target wiring " + "assertions would be vacuous" + ) + ctl, _ = mint("gh pr merge 42") + assert ctl is not None, "CLI-merge control must be unaffected" + + +# The endpoint (`.../repos/o/r/pulls/N/merge`) is the target PR; a decoy +# `pulls/M/merge` in a flag VALUE or another leg must never be the bound target. +# These helpers build the mint→authorize seam once for the endpoint-position suite. +def _mint_pr(cmd): + """Mint `cmd` and return the bound pr_number (or None if it did not mint).""" + ctx, _reason = mint(cmd) + return ctx.get("pr_number") if ctx is not None else None + + +def _authorize_standalone(approve_cmd, standalone_cmd, tmp_path): + """Mint the approval, write the token, then run the read-side authorization + for a DIFFERENT standalone command. Returns 'ALLOW' | 'DENY' | 'NO-MINT'.""" + ctx, _reason = mint(approve_cmd) + if ctx is None: + return "NO-MINT" + write_token(ctx, token_dir=tmp_path) + err = check_merge_authorization(standalone_cmd, token_dir=tmp_path) + return "ALLOW" if err is None else "DENY" + + +class TestApiMergeEndpointPositionLaunderingClosed: + """#1096 endpoint-position fix (commit that added `_api_merge_leg_endpoint`) — + the REMEDIATION cert for the BLOCKING target-confusion laundering the prior + (flat first-match) extractor allowed. + + ROOT: `_extract_api_merge_pr` did `re.search(r"pulls/(\\d+)/merge", command)` — + a FLAT whole-string first-match that bound the FIRST `pulls//merge` + substring, which need not be the endpoint. A decoy PR in a flag body + (`-f note=pulls/5/merge`), a header, the `-R` value, or an earlier leg + (`echo pulls/5/merge && …`) bound a token `{merge, 5}`; a standalone + `gh api -X PUT .../pulls/5/merge` (NEVER approved) then AUTHORIZED — a consent + breach (approve merge-6 → authorize merge-5). + + FIX: bind the ENDPOINT-position PR (the URL positional the command actually + merges) via the shared per-leg helper + shlex-view positional walk. Every row + below approves endpoint-6 and asserts (a) the token binds 6, and (b) the + standalone decoy-5 merge DENIES for lack of a matching token — the laundering + is CLOSED. Priority: this is the laundering-closed (security) direction; the + no-over-block (§0 inviolable) direction is the sibling class below.""" + + # (name, approval command [endpoint 6, decoy 5 embedded], standalone decoy-5) + _DECOY = "gh api -X PUT repos/o/r/pulls/5/merge" + + @pytest.mark.parametrize( + "approve,standalone", + [ + ("echo pulls/5/merge && gh api -X PUT repos/o/r/pulls/6/merge", _DECOY), + ("gh api -X PUT -f note=pulls/5/merge repos/o/r/pulls/6/merge", _DECOY), + ('gh api -X PUT -f "note=pulls/5/merge" repos/o/r/pulls/6/merge', _DECOY), + ("gh api -X PUT -f note='pulls/5/merge' repos/o/r/pulls/6/merge", _DECOY), + ('gh api -H "X-Ref:pulls/5/merge" -X PUT repos/o/r/pulls/6/merge', _DECOY), + ("gh -R pulls/5/merge api repos/o/r/pulls/6/merge -X PUT", _DECOY), + ("gh api -X PUT -f x=pulls/5/merge repos/o/r/pulls/6/merge", _DECOY), + ], + ) + def test_decoy_binds_endpoint_and_standalone_decoy_denies( + self, approve, standalone, tmp_path + ): + """Every confirmed exploit: the approval binds the ENDPOINT (6), and the + never-approved standalone decoy-5 merge DENIES. Pre-fix the flat + first-match bound 5 and this standalone AUTHORIZED (the laundering).""" + assert _mint_pr(approve) == "6", ( + f"endpoint-position bind regressed (bound a decoy): {approve!r}" + ) + assert _authorize_standalone(approve, standalone, tmp_path) == "DENY", ( + f"LAUNDERING OPEN: standalone decoy-5 authorized off the {approve!r} token" + ) + + def test_base_no_token_denies_standalone_decoy(self, tmp_path): + """Load-bearing base check: with NO token minted, the standalone decoy-5 + merge DENIES — so the pre-fix ALLOW was caused by the mis-bound token, + not an ambient allow. This isolates the mis-bind as the laundering cause.""" + err = check_merge_authorization(self._DECOY, token_dir=tmp_path) + assert err is not None, "standalone api-merge must DENY with no token" + + def test_laundering_closed_non_vacuous_under_flat_extractor(self, monkeypatch): + """NON-VACUITY (in-memory): restore the pre-fix FLAT first-match extractor + (`re.search(pulls/(\\d+)/merge)` over the whole command — the shape at + commit-parent that bound a decoy) on the command-level `_extract_api_merge_pr` + that `extract_command_context` calls, and assert the `-f` body decoy + RE-BINDS the decoy 5 — proving the endpoint-position assertions above are + coupled to the walk, not vacuously green. The module-global binding + resolves at call time.""" + approve = "gh api -X PUT -f note=pulls/5/merge repos/o/r/pulls/6/merge" + # direction 1 — fix present: binds the endpoint + assert _mint_pr(approve) == "6" + # direction 2 — flat first-match restored: binds the decoy again + def flat(command): + m = _real_re.search(r"pulls/(\d+)/merge\b", command) + return m.group(1) if m else None + monkeypatch.setattr(mgc, "_extract_api_merge_pr", flat) + assert _mint_pr(approve) == "5", ( + "flat-extractor mutation did not re-bind the decoy — the " + "laundering-closed assertions would be vacuous" + ) + + +class TestApiMergeEndpointNoOverBlock: + """#1096 endpoint-position fix — the §0 INVIOLABLE no-over-block direction: + every faithful GH-API api-merge binds its CORRECT endpoint and round-trips, and + the fix never returns None for a recognized merge (no gated-but-unmintable). + GH-API-ONLY (Option B): curl/wget api-merge is intentionally unmintable — see + TestApiMergeEndpointResidualBoundary::test_curl_wget_api_merge_mints_nothing_by_construction.""" + + @pytest.mark.parametrize( + "cmd", + [ + "gh api -X PUT repos/o/r/pulls/6/merge", + "gh api --method PATCH repos/o/r/pulls/6/merge", + "gh api repos/o/r/pulls/6/merge -X POST", # method-after-path + "gh -R o/r api repos/o/r/pulls/6/merge -X PUT", # global-flag + "gh api -X PUT -f note=pulls/5/merge repos/o/r/pulls/6/merge", # body-COINCIDENCE faithful + ], + ) + def test_faithful_spelling_binds_correct_endpoint(self, cmd): + """Every faithful spelling mints its CORRECT endpoint (6), including the + body-coincidence merge whose `-f note` happens to mention pulls/5 — the + endpoint positional (6) is bound, never the body decoy, and never None.""" + assert _mint_pr(cmd) == "6", f"faithful api-merge bound wrong/None: {cmd!r}" + + @pytest.mark.parametrize( + "cmd", + [ + "gh api -X PUT repos/o/r/pulls/6/merge", + "gh api -X PUT -f note=pulls/5/merge repos/o/r/pulls/6/merge", + ], + ) + def test_faithful_round_trip_authorizes(self, cmd, tmp_path): + """The faithful click round-trips: approve → mint → byte-identical + re-execution AUTHORIZES (the §0 inviolable guarantee).""" + assert _authorize_standalone(cmd, cmd, tmp_path) == "ALLOW", ( + f"faithful api-merge round-trip over-blocked: {cmd!r}" + ) + + def test_tokenizer_failure_still_binds_endpoint(self): + """Over-block-safe fallback: an unbalanced-quote faithful merge (shlex + tokenizer returns None) still binds the endpoint from the stripped/raw + first-match — NEVER None for a recognized leg (a mis-parse must not gate + a faithful merge).""" + cmd = 'gh api -X PUT -f note="unbalanced repos/o/r/pulls/6/merge' + assert OP(cmd) == "merge" + assert _mint_pr(cmd) == "6", "tokenizer-failure fallback returned None (over-block)" + + def test_gh_api_attached_method_is_1079_underblock_not_overblock(self): + """DOCUMENTED BOUNDARY (not an over-block): the gh-api ATTACHED method + spelling `gh api --method=PUT .../merge` is is_dangerous=False (the read + floor's `(?:-X|--method)\\s+` requires whitespace) yet OP=merge — the + pre-existing #1079 method-delimiter under-block (attached/`=` spellings + bypass the read floor; closed won't-fix). It is READ==MINT SYMMETRIC: + not gated → correctly NOT mintable, so it is NOT a gated-but-unmintable + over-block, and it is unchanged by the endpoint-position fix. The SPACED + gh-api form and wget's native `--method=` form DO gate + mint (covered + above). This row pins the symmetry so a future 'harden' that mints the + attached form without also gating it (asymmetry = over-block-shaped + surprise) turns red.""" + cmd = "gh api --method=PUT repos/o/r/pulls/6/merge" + assert D(cmd) is False, "read floor unexpectedly gates the attached form" + assert _mint_pr(cmd) is None, ( + "attached-method form minted while the read floor does NOT gate it — " + "read/mint asymmetry (see #1079); this is the symmetry tripwire" + ) + + +class TestApiMergeEndpointCarrierInteraction: + """#1096 §7 carrier-interaction (b409be63 L8 / #1074): the PATH-resident + endpoint survives EVERY value-stripping carrier (carrier-4 var-assign strip + + carrier-8 HTTP-body strip), a body decoy is removed exactly once, and the + §2.3 unquoted carrier-8 extension does not disturb the endpoint. Two levels: + (a) the mechanism (is_dangerous/extract), (b) the end-to-end mint→authorize + security proof.""" + + def test_mechanism_endpoint_survives_both_carriers_decoy_removed(self): + """MECHANISM: a `-f branch=pulls/5/merge` body decoy alongside the + endpoint pulls/6/merge — the endpoint (path-resident) survives the + carrier strip and the extractor binds 6; the body decoy is not bound. + The command still gates (is_dangerous=True — the endpoint is a real + mutating merge) with its target intact.""" + cmd = "gh api -X PUT -f branch=pulls/5/merge repos/o/r/pulls/6/merge" + assert D(cmd) is True, "endpoint merge stopped gating after carrier strip" + assert mgc._api_merge_leg_endpoint(cmd) == "6", "carrier strip disturbed the endpoint" + + def test_seam_body_decoy_does_not_leak_to_mintable_token(self, tmp_path): + """SECURITY: end-to-end, the body decoy does not leak through to a + mintable token — approving the endpoint-6 merge binds 6, and a standalone + decoy-5 merge DENIES (the decoy never became an authorizing target).""" + approve = "gh api -X PUT -f branch=pulls/5/merge repos/o/r/pulls/6/merge" + assert _mint_pr(approve) == "6" + assert _authorize_standalone( + approve, "gh api -X PUT repos/o/r/pulls/5/merge", tmp_path) == "DENY" + + def test_contents_capital_c_write_to_main_still_gates(self): + """CONTENTS case-insensitivity under-block (independent of the coder's own + test_gh_api_put_contents_main_case_insensitive): the §2.3 unquoted body + strip exposed a latent under-block — carrier-8's `contents/` preservation + guard was case-SENSITIVE while its read arm is case-insensitive, so a + capital-`Contents/` write-to-main had its main/master gating body stripped + → is_dangerous=False → a dangerous contents-write ran ungated. The IGNORECASE + fix restores gating. This asserts the under-block is CLOSED.""" + cmd = "gh api -X PUT repos/o/r/Contents/README.md -f branch=Main" + assert D(cmd) is True, ( + "capital-C Contents write-to-main is UNGATED — the case-sensitivity " + "under-block reopened (the §2.3 fix regressed)" + ) + + def test_contents_case_fix_introduces_no_over_block(self): + """The other direction of the IGNORECASE change: a lowercase faithful + contents write-to-main still gates + classifies (the fix only PRESERVES + more spans from stripping, so it cannot introduce an over-block — this + pins that it did not accidentally change the lowercase behavior).""" + cmd = "gh api -X PUT repos/o/r/contents/README.md -f branch=main" + assert D(cmd) is True + + +class TestApiMergeEndpointResidualBoundary: + """#1096 GH-API-ONLY narrowing (Option B) TRIPWIRE. The prior option-1 exotic-curl + ACCEPTED-RESIDUAL is SUPERSEDED: curl/wget api-merge legs now classify detect=None + (mint=0), so there is NO laundering residual to accept — the whole curl/wget decoy + class dies BY CONSTRUCTION (can't mint -> can't launder; sec #71 proved a value-flag + denylist over curl/wget's unbounded flag space is uncompletable). This class pins + (a) the gh-api no-widening guard (the kept, provably-sound client) and (b) the + STRUCTURAL assertion that curl/wget mint NOTHING regardless of flags — deliberately + NOT re-enumerating the 52 curl vectors as a denylist (that would re-import the + open-endedness Option B exits).""" + + # (a) gh-api no-widening: an ENUMERATED gh-api value-flag carrying a decoy URL BEFORE + # the endpoint is skipped, so mint binds the ENDPOINT (6) and the standalone decoy-5 + # DENIES. RED if a common gh-api flag drops from _API_MERGE_GH_VALUE_FLAGS (widening). + @pytest.mark.parametrize( + "approve", + [ + 'gh api -H "X-Ref: https://x/repos/o/r/pulls/5/merge" -X PUT repos/o/r/pulls/6/merge', + "gh api -f note=https://x/repos/o/r/pulls/5/merge -X PUT repos/o/r/pulls/6/merge", + "gh api -F note=https://x/repos/o/r/pulls/5/merge -X PUT repos/o/r/pulls/6/merge", + "gh api -t https://x/repos/o/r/pulls/5/merge -X PUT repos/o/r/pulls/6/merge", + ], + ) + def test_gh_api_enumerated_value_flag_decoy_binds_endpoint(self, approve, tmp_path): + """gh-api no-widening: an enumerated gh-api value-flag decoy binds the ENDPOINT + (6), and the standalone decoy-5 DENIES. RED if a common gh-api flag drops from + the skip set (silent widening).""" + assert _mint_pr(approve) == "6", ( + f"a gh-api enumerated value-flag decoy bound the decoy — residual widened: {approve!r}" + ) + assert _authorize_standalone( + approve, "gh api -X PUT repos/o/r/pulls/5/merge", tmp_path) == "DENY" + + @pytest.mark.parametrize( + "cmd", + [ + # canonical curl/wget merge forms + "curl -X PUT https://api.github.com/repos/o/r/pulls/6/merge", + "curl --request PUT https://api.github.com/repos/o/r/pulls/6/merge", + "wget --method=PUT https://api.github.com/repos/o/r/pulls/6/merge", + "curl --url https://api.github.com/repos/o/r/pulls/6/merge -X PUT", + # non-vacuity sample of the sec-#71 decoy forms — also None (NOT re-enumerating 52) + 'curl -H "X: https://x/pulls/5/merge" -X PUT https://api.github.com/repos/o/r/pulls/6/merge', + "curl --request https://api.evil/pulls/5/merge -X PUT https://api.github.com/repos/o/r/pulls/6/merge", + "wget -O /tmp/pulls/5/merge --method=PUT https://api.github.com/repos/o/r/pulls/6/merge", + ], + ) + def test_curl_wget_api_merge_mints_nothing_by_construction(self, cmd): + """(b) THE STRUCTURAL narrowing assertion (Option B): a curl/wget api-merge leg + classifies detect=None -> mint=0, regardless of flags — so the ENTIRE curl/wget + decoy class (the 52 sec-#71 vectors) is DEAD by construction (can't mint -> can't + launder). Still READ-gated (is_dangerous True) = the pre-existing + gated-but-unmintable state, NOT a new over-block/under-block. Replaces the old + 'curl decoy closed' pins, whose real reason is now 'curl can't mint at all'.""" + assert OP(cmd) is None, f"curl/wget api-merge classified (narrowing breached): {cmd!r}" + assert _mint_pr(cmd) is None, f"curl/wget api-merge minted (narrowing breached): {cmd!r}" + assert D(cmd) is True, f"curl/wget api-merge no longer read-gated (under-block): {cmd!r}" + + def test_gh_api_unknown_flag_non_url_value_no_over_block(self): + """No-over-block / never-None on the KEPT client (gh-api): a faithful gh-api merge + with an UNKNOWN flag carrying a NON-URL benign value still binds the endpoint (6), + never None. RED if a future 'harden' fail-closes on multi-token/exotic forms + (reintroducing a cardinal-sin over-block for gh-api).""" + cmd = "gh api -X PUT repos/o/r/pulls/6/merge --some-unknown-flag benign" + assert _mint_pr(cmd) == "6", ( + "faithful gh-api merge with an unknown benign flag returned wrong/None — a " + "harden fail-closed into an over-block" + ) + + class TestAcceptedRecognitionLimitationPins: """FORWARD-PROTECTION pins for the ACCEPTED conservative-recognition limitation (the review-cycle-1 SECURITY-HALT disposition). These forms run UNGATED BY diff --git a/pact-plugin/tests/test_merge_guard_over_block_batch.py b/pact-plugin/tests/test_merge_guard_over_block_batch.py index 9a8c5b9c..3c6d5d2e 100644 --- a/pact-plugin/tests/test_merge_guard_over_block_batch.py +++ b/pact-plugin/tests/test_merge_guard_over_block_batch.py @@ -327,15 +327,17 @@ def test_idiomatic_clients_still_gate_contrast(self, cmd): # =========================================================================== -# Leg-isolation completion re-cert (#1082 Fix B + #1083 Fix A two-tier window) -# — TEST-phase EXTENSION of the coder's TestLegBoundedMintWindow canaries. -# Everything below verifies GREEN focus areas only. The close-ambiguity -# laundering residual (approve `close N && close M && echo --delete-branch` → -# a [--delete-branch] token that authorizes a real branch-delete) is a -# rigorously-attributed PRE-EXISTING channel (the close literal arm's -# cross-leg `(?=.*--delete-branch)` lookahead, the #1082 root Fix B fixed for -# force-push arms but not for close) — it is issue-tracked / dispositioned -# separately, NOT pinned here. +# Leg-isolation completion re-cert (#1082 Fix B + #1083 Fix A two-tier window +# + #1087 close per-leg conversion). TEST-phase EXTENSION of the coder's +# TestLegBoundedMintWindow canaries. The close-ambiguity laundering residual +# (approve `close N && close M && echo --delete-branch` → a [--delete-branch] +# token that authorizes a real branch-delete) — the close literal arm's +# cross-leg `(?=.*--delete-branch)` lookahead, which the #1082 root Fix B fixed +# for force-push arms but not for close — is now FIXED by the #1087 per-leg +# close conversion (_CLOSE_LITERAL_ARMS matched per-leg at the read floor: the +# ambiguous form is is_dangerous=False → mints nothing → cannot launder). It is +# no longer issue-deferred; it IS pinned here now, in +# TestAmbiguousApprovalByteIdenticalIsSafeDirection. # =========================================================================== def _mint_ctx(desc: str, tmp_path, question="Proceed?", label="Yes, run it"): @@ -459,14 +461,24 @@ def test_single_approval_rides_benign_prefix_or_continuation( class TestEmergentDangerClassIsCloseOnly: - """#1083 §12.9 structural fact: the two-tier read fallback's tier 2 - (_single_detectable_leg) only matters for EMERGENT-danger ops — an op that is - detect-POSITIVE but NOT individually dangerous, so whole-command danger comes - from a cross-leg lookahead. `close` is the ONLY such op; every other - privileged op is bare-dangerous (tier-1 handles it), which is why the - emergent BIND class reduces to close. If a future op becomes - detect-positive-but-not-dangerous, this pin flips and that op joins the - emergent class (the re-open trigger named in §12.9).""" + """#1083 §12.9 structural fact: an op that is detect-POSITIVE but NOT + individually dangerous is the ONLY kind that can form an emergent-danger + compound (whole-command danger arising from a cross-leg match). `close` is the + ONLY such op; every other privileged op is bare-dangerous (tier-1 handles it). + + #1087 update: the close danger arms are now matched PER-LEG, so `close` no + longer produces an emergent-*dangerous* compound — a dangerous close is a + dangerous LEG (tier 1), and a bare multi-close is is_dangerous=False (no bind + at all). `close` KEEPS the detect-positive-but-not-dangerous PROPERTY, though + (bare `gh pr close` classifies as `close` yet is not dangerous), and THAT is + what this pin guards: it is the CLOSE HALF of the tier-2-retention tripwire + (OPEN-Q A/D). Tier 2 (_single_detectable_leg) is retained as defense-in-depth + for a FUTURE op that becomes detect-positive-but-not-dangerous; if one does, + this pin (and the API half, test_bare_api_form_is_detect_negative + below) flips RED, catching the re-populated emergent class before it can + launder. The bare-form assertions below are UNCHANGED by the per-leg + conversion — they test bare close / bare-dangerous ops / a bare API GET leg, + none of which the conversion touches.""" @pytest.mark.parametrize( "cmd,dangerous", @@ -486,38 +498,84 @@ def test_only_close_is_detect_positive_but_not_dangerous(self, cmd, dangerous): f"(§12.9 re-open trigger)" ) - def test_api_git_refs_get_leg_is_detect_negative(self): - """The API arms are cross-leg matchers too, but an isolated GET leg is - detect-NEGATIVE (unlike bare close) → tier 2 abstains → both surfaces stay - on the whole-command scan → SYMMETRIC bind → no laundering asymmetry - (§12.9 follow-up). This is why the API emergent members are pure - over-blocks, not laundering channels.""" - assert OP("gh api /repos/o/r/git/refs") is None + @pytest.mark.parametrize( + "cmd", + [ + # gh api bare (method-less) forms — one per API family the arms target + "gh api /repos/o/r/git/refs", + "gh api /repos/o/r/git/refs/heads/x", + "gh api /repos/o/r/branches/main/protection", + "gh api /repos/o/r/contents/file.txt", + "gh api /repos/o/r/pulls/5/merge", + # curl / wget bare-GET equivalents for git/refs and protection + "curl https://api.github.com/repos/o/r/git/refs/heads/x", + "curl https://api.github.com/repos/o/r/branches/main/protection", + "wget https://api.github.com/repos/o/r/git/refs/heads/x", + ], + ) + def test_bare_api_form_is_detect_negative(self, cmd): + """OPEN-Q D tripwire — the API HALF of the tier-2-retention invariant + (pairs with the close-half assertion above). Every bare (method-less) API + form, across every family the #1086 danger arms target, is detect-NEGATIVE: + an isolated bare API leg classifies to NO op (unlike bare `gh pr close`, + which is detect-positive-but-not-dangerous). That is why API is NOT in the + emergent-danger class — tier 2 abstains → both surfaces stay on the + whole-command scan → SYMMETRIC bind → no laundering asymmetry (the API + emergent members are pure over-blocks, now removed by the per-leg + conversion, not laundering channels). If a future edit makes any bare API + form detect-POSITIVE (classifiable without a mutating method, i.e. isolable + like close), that op joins the emergent class and gains the mint/read + isolation asymmetry #1083 fixed for close — a laundering channel, and this + assertion FAILS FIRST. Coverage is ENUMERATIVE, not universal: it pins the + bare forms of the API families the #1086 arms target, so it catches a + REGRESSION that makes one of THEM isolable — but a genuinely-new op class + OUTSIDE this enumeration (a new endpoint family, or a non-API op) could + still re-populate the emergent class WITHOUT tripping this pin. It is a + strong regression tripwire for the known surface, not a universal + guarantee; together with the close half it is the (enumerated) evidence + that justifies retaining tier 2 (OPEN-Q A), and it MUST remain a standing + merge gate.""" + assert OP(cmd) is None, ( + f"bare API form became detect-positive: {cmd!r} — it would join the " + f"close emergent-danger class and gain a laundering asymmetry (re-open)" + ) class TestAmbiguousApprovalByteIdenticalIsSafeDirection: - """#1083 §12.9 ambiguity fallback (coder-disclosed, deliberately UNPINNED as - a residual): a ≥2-detectable / 0-dangerous approval falls back to the - whole-command scan on BOTH surfaces, so its BYTE-IDENTICAL re-approval - authorizes symmetrically. This documents ONLY the safe-direction property the - dispatch asked to verify — a DIFFERENT (non-byte-identical) execution that is - a different op/target still REFUSES. It does NOT assert the residual's - over-binding as a contract (that laundering corner is issue-tracked, not - pinned here).""" + """#1087 laundering closed at the source. Pre-fix, the ambiguous multi-close + `gh pr close 42 && gh pr close 43 && echo --delete-branch` was is_dangerous= + True (the close arm's cross-leg `(?=.*--delete-branch)` lookahead), so + approving it MINTED a token — and as an APPROVAL SOURCE that token authorized + an escalated same-target single `gh pr close 42 --delete-branch` (a real + branch delete). Post-fix the close arms match PER-LEG: no leg has `gh pr + close` and `--delete-branch` together, so the ambiguous form is + is_dangerous=False → mints NOTHING → cannot launder in any direction. This + class pins the approval-SOURCE direction the #1083 residual left unpinned (it + previously tested the form only as an execution target); it is now a fixed + channel, not a deferred residual.""" AMBIG = "gh pr close 42 && gh pr close 43 && echo --delete-branch" - def test_byte_identical_reapproval_is_symmetric(self, tmp_path): - """The symmetric-authorize consequence: mint and read both fall back to - the whole command, so the byte-identical re-execution AUTHORIZES (the - pre-fix mint-[] → always-DENY asymmetry is gone).""" + def test_ambiguous_approval_mints_no_token(self, tmp_path): + """The ambiguous multi-close is not-dangerous per-leg, so the approval + mints NOTHING (n == 0 — was n == 1 pre-fix), and the byte-identical + re-execution RUNS FREE (not-dangerous → no token consulted).""" n, _ctx = _mint_ctx(f"On approval run: `{self.AMBIG}`", tmp_path, question="Close?", label="Yes") - assert n == 1 + assert n == 0 assert _authorize(self.AMBIG, tmp_path) is None - def test_different_target_execution_still_refuses(self, tmp_path): - """Safe-direction guard: an execution against a DIFFERENT pr target does - NOT authorize against the ambiguous token (target axis still enforced).""" + def test_ambiguous_approval_source_does_not_authorize_escalated_single(self, tmp_path): + """THE laundering-closed proof (closes the #1087 O9 gap): approving the + ambiguous multi-close mints no token, so the ESCALATED single + `gh pr close 42 --delete-branch` (a real, irreversible branch delete) + REFUSES for lack of any authorization. Pre-fix this AUTHORIZED (T1).""" n, _ctx = _mint_ctx(f"On approval run: `{self.AMBIG}`", tmp_path, question="Close?", label="Yes") - assert n == 1 + assert n == 0 + assert _authorize("gh pr close 42 --delete-branch", tmp_path) is not None + + def test_ambiguous_approval_source_does_not_authorize_different_target(self, tmp_path): + """Target-axis guard, now trivial: with no token minted, an execution + against a DIFFERENT pr target also REFUSES.""" + n, _ctx = _mint_ctx(f"On approval run: `{self.AMBIG}`", tmp_path, question="Close?", label="Yes") + assert n == 0 assert _authorize("gh pr close 99 --delete-branch", tmp_path) is not None diff --git a/pact-plugin/tests/test_merge_guard_seam_integration.py b/pact-plugin/tests/test_merge_guard_seam_integration.py index cd94ca97..e98586a0 100644 --- a/pact-plugin/tests/test_merge_guard_seam_integration.py +++ b/pact-plugin/tests/test_merge_guard_seam_integration.py @@ -271,6 +271,31 @@ def test_main_to_main_non_conforming_blocks(self, tmp_path): assert code == 2 assert '"permissionDecision": "deny"' in out + def test_main_to_main_ambiguous_close_laundering_closed(self, tmp_path): + """#1087 laundering closed through the REAL post→pre seam: approving the + ambiguous multi-close `gh pr close 42 && gh pr close 43 && echo + --delete-branch` mints NO token (is_dangerous=False per-leg → the mint + write-gate refuses), so the escalated single `gh pr close 42 + --delete-branch` (a real, irreversible branch delete) is DENIED at the + pre hook (exit 2, deny). Pre-fix the approval minted a token that + AUTHORIZED the escalated single (exit 0) — this is the end-to-end + laundering-closed proof. + NON-VACUITY: the escalated single IS a gated command (a faithful in-leg + approval of it mints + authorizes), so the deny here is the MISSING token, + not an ungated command.""" + ambiguous = "gh pr close 42 && gh pr close 43 && echo --delete-branch" + question = "Close these PRs?" + options = [{"label": "Yes, close", "description": f"On approval run: `{ambiguous}`"}] + assert self._run_post( + [{"question": question, "options": options, "multiSelect": False}], + {question: "Yes, close"}, tmp_path, + ) == 0 + assert list(tmp_path.glob("merge-authorized-*")) == [] + + code, out = self._run_pre("gh pr close 42 --delete-branch", tmp_path) + assert code == 2 + assert '"permissionDecision": "deny"' in out + # ════════════════════════════════════════════════════════════════════════════ # #1052 — self-teaching OBSERVER-STYLE no-mint advisory. When an AskUserQuestion