merge-guard: raise Layer-1 retirement granularity to op+target+flags (v4.4.55)#1110
Merged
michael-wojcik merged 4 commits intoJul 5, 2026
Conversation
…lags (Synaptic-Labs-AI#1100) _retire_token_for_command now discriminates on bound_flags in addition to op+target+session: a successful bare same-op/same-target command no longer retires an operator's approved escalated (flag-carrying) token — the Synaptic-Labs-AI#1100 over-retirement friction. Target and the flag set are derived from ONE leg-isolated extract_command_context call, mirroring the read arm, so mint, read, and retirement agree on (op, target, bound_flags) identity by construction (same _target_value + same extract_privileged_flags SSOT). The discriminator is conjunctive and monotonic (retires fewer tokens, never more), so the only residual is a tolerated under-retire backstopped by TTL + MAX_USES; as a PostToolUse observer it can never over-block a faithful click. Tests: new TestFlagAwareRetirement (self-consume incl. flagless, friction-cured survival both directions, flag-normalization equivalence, seam-independent and SSOT-neuter non-vacuity, both-modes session matrix) plus a post->pre seam test. Update the pre-existing target-axis non-vacuity probe to a flagless token so it isolates the target predicate under the new flag axis.
…retirement docstring Both the independent security review and an adversarial break-sweep flagged that the prior "conjunctive and MONOTONIC — retires fewer, never more" wording understated the change. It composes two independent, both-safe moves: a purely subtractive flag-inequality skip, plus a leg-isolated cmd_target derivation that purifies the compare toward the executed op's own identity (mirroring the read arm) and can retire a self-consume a prior whole-command mis-scan would have missed — the safe direction, since over-retire only consumes a token, never authorizes one. Docstring-only; behavior unchanged.
…retirement flag-granularity)
There was a problem hiding this comment.
Pull request overview
This PR refines merge-guard’s Layer-1 retirement behavior so that a successful command only retires an authorization token when (op_type, target, bound_flags, session) match—preventing a bare same-op/same-target command from burning a previously approved flag-escalated token for that same target.
Changes:
- Updated
merge_guard_post._retire_token_for_commandto derive command context via leg-isolatedextract_command_contextand to require set-equality onbound_flagsbefore retiring a token. - Added/expanded tests validating self-consume retirement, survival on flag mismatch, normalization equivalence, and an end-to-end post→pre seam scenario.
- Bumped documented/plugin version strings from 4.4.54 → 4.4.55.
Reviewed changes
Copilot reviewed 7 out of 7 changed files in this pull request and generated 1 comment.
Show a summary per file
| File | Description |
|---|---|
pact-plugin/hooks/merge_guard_post.py |
Tightens token retirement matching to include privileged-flag identity and uses leg-isolated context extraction. |
pact-plugin/tests/test_merge_guard.py |
Adds comprehensive flag-aware retirement test matrix and adjusts a target-axis non-vacuity test to remain isolated under the new flag axis. |
pact-plugin/tests/test_merge_guard_seam_integration.py |
Adds an end-to-end disk-seam test ensuring escalated tokens survive bare retirement and still authorize. |
README.md |
Updates cache-path version example to 4.4.55. |
pact-plugin/README.md |
Updates displayed plugin version to 4.4.55. |
pact-plugin/.claude-plugin/plugin.json |
Bumps plugin version to 4.4.55. |
.claude-plugin/marketplace.json |
Bumps marketplace plugin version to 4.4.55. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
Comment on lines
+772
to
+773
| if set(ctx.get("bound_flags", [])) != cmd_flags: | ||
| continue |
…nd_flags (Copilot Synaptic-Labs-AI#1110 review) The Synaptic-Labs-AI#1100 flag-axis read `set(ctx.get("bound_flags", []))` could crash the PostToolUse retirement observer on a malformed/legacy token: a JSON-null bound_flags yields None (dict.get returns the present key's value, not the default) so set(None) raises TypeError; a list containing a non-string/unhashable element makes set() raise on the unhashable. An unguarded crash aborts the whole scan, skipping retirement for the rest of the run. Add an isinstance-list + all-string-element guard mirroring the function's existing malformed-ctx skip: a malformed token is skipped gracefully (the safe under-retire direction) and never poisons the loop — a valid token in the same run still retires. Skip, not delete (deletion is the read arm's reaper job). Valid-token semantics unchanged; empty [] still passes. Tests: TestMalformedTokenRetirementRobustness — malformed bound_flags (null/int/float/bool/string and list-with-unhashable-element) never raises and is skipped; a malformed token forced first does not poison a valid retirement.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Raises merge-guard Layer-1 token retirement granularity from
op+targettoop+target+bound_flags(#1100), curing a bounded re-approval friction: a successful bare same-op/same-target command (e.g.gh pr close 5) no longer retires an operator's approved escalated flag-carrying token (e.g.{close, 5, [--delete-branch]}). Follow-on to #1097 (target-aware retirement).What changed
Confined to
_retire_token_for_commandinpact-plugin/hooks/merge_guard_post.py— a PostToolUse observer (fires after a successful execution; never gates a command). Two independent, both-safe moves compose:if set(ctx.bound_flags) != cmd_flags: continue) — fewer tokens on the flag axis; byte-mirrors the read arm's existing security:--adminand other privileged flags ride past merge-guard auth binding #1042 set-equality (merge_guard_pre.py:559).cmd_targetderivation switches to leg-isolatedextract_command_context(mirroring the read arm atmerge_guard_pre.py:536-540), so target + flags are derived from one SSOT call — mint, read, and retirement agree on(op, target, bound_flags)identity by construction.Reuse-only: the mint already stores
bound_flags(SSOTmerge_guard_common.py:1400); the read arm already set-compares it. No mint / read / shared-extractor edits.Safety — dual-independent certification
Certified SAFE-TO-MERGE by two independent adversarial streams — a
pact-security-engineerreview (8-item verification table) and a 3-lens adversarial break-sweep — which converged on the same conclusion and the same single (now-fixed) doc-precision nit:_token_matches_command/check_merge_authorization) is untouched; it binds(op, target, bound_flags)exactly and consumes on match. Any surviving under-retired token can replay only as its identical approved command —MAX_USES(2)+TTL(300s)bounded — never a different destructive command.sys.exit(0); it never emits apermissionDecision. The cardinal-sin direction is out of reach by construction.[] == []case); extraction-symmetric (derives flags via theextract_privileged_flagsSSOT, never_normalized_flags); target-less narrowing is inert (a target-less token fails the read arm's positive per-op-class target match → authorizes nothing, and no production mint ever creates one).This aligns with the merge-guard design principle: a faithful single-command click always mints and merges; over-block is wrong by definition. Retirement is an observer that only ever removes tokens — it never grants authorization — so this change is laundering-safe by construction.
Tests
New
TestFlagAwareRetirement: matching-flag self-consume (incl. flagless), friction-cured survival (both directions), flag-normalization equivalence (-d==--delete-branch,--repo=/-R/--repo, reorder, dup, value-mismatch), a seam-independent black-box flip + an SSOT-neuter non-vacuity proof, and a both-modes session matrix. Plus apost→preseam integration test, and an intent-preserving fix to the pre-existing #1097 target-axis non-vacuity probe (token made flagless so it isolates the target predicate under the new flag axis; target non-vacuity still proven).Full suite: 10620 passed, 11 skipped, 0 failed, 0 errors.
Version
PATCH bump 4.4.54 → 4.4.55 — a bounded friction refinement; no user-visible behavioral surface change.
Completes ACs of #1100; closure pending post-install live-probe.