fix(source-control): close the merge wrapper's =value spelling bypass#1539
Conversation
`bin/source-control-babysit-merge`'s --allow-unpinned-head guard is a prefix test against the raw argument, so `--allow-unpinned-head=true` sailed past it: the =value tail is not itself a prefix of `--allow-unpinned-head`. The flag still refused today, but only because argparse's store_true rejects an explicit value -- the wrapper was depending on the interpreter behind it for exactly the case its own header says it must not. Strip a `--flag=value` tail before the prefix comparison, and add engine.test.sh rows plus two guard_contract.py refusal rows (regenerating reference/guard-contract.md) asserting the refusal is the wrapper's own text, not an argparse error, with no-over-refusal rows for sibling --allow* flags. Closes #1522 *This was generated by AI during work-loop execution.* Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
|
Claude finished @kyle-sexton's task in 2m 32s —— View job PR Review:
|
|
Claude finished @kyle-sexton's task in 2m 21s —— View job Security Review
FindingsNo security vulnerabilities found. This PR closes a guard bypass rather than introducing one. Detailed analysis of each changed surface below.
|
…ygiene CI's comment-hygiene gate flags "PR #<number>" references in source comments (tracker-ref:pr-reference) as transient/stale-prone; the convention elsewhere in this codebase is a bare "(#1234)" issue reference, never "PR #1234". Reworded the one offending engine.test.sh comment to explain the no-network-call rationale without naming a PR. *This was generated by AI during work-loop execution.* Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
|
Claude finished @kyle-sexton's task in 2m 33s —— View job PR Review:
|
| Argument | Stem | Refused? | Expected |
|---|---|---|---|
--allow-unpinned-head=true |
--allow-unpinned-head |
✓ | REFUSED |
--allow-unpinned=1 |
--allow-unpinned |
✓ | REFUSED |
--allow-unpinned-hea=1 |
--allow-unpinned-hea |
✓ | REFUSED |
--allow-unpinned-head (no =) |
unchanged | ✓ | REFUSED |
--allow-dependency=val |
--allow-dependency |
✓ | passes through |
--allow-unprotected |
unchanged | ✓ | passes through |
--allowed-owners=someone |
--allowed-owners |
✓ | passes through |
Error message correctly echoes $arg (the original argument), not $stem. Users see exactly what they typed.
Test methodology — engine.test.sh lines 66–78
stderr="$(bash "$MERGE_WRAPPER" "$@" 2>&1 >/dev/null)"
got=$?
if [[ "$got" == "2" && "$stderr" == *"is not permitted through the wrapper"* ]]; thenAsserting on the wrapper's own refusal text rather than only the exit code is the right call — as the PR description explains, both the wrapper refusal and argparse's own store_true rejection produce exit 2, so an exit-code-only assertion would have "passed" on both sides of the fix for different reasons. The helper directly prevents that trap.
Redirect order 2>&1 >/dev/null is correct. The sequence: 2>&1 redirects stderr to where stdout currently points (the $() capture pipe), then >/dev/null discards stdout. Net effect: command substitution captures stderr; stdout is discarded. This is intentional and works correctly.
got=$? is correct. stderr="$(...)" (without local) is a plain assignment, which in bash does propagate the subshell's exit code to $?. local stderr got is on a separate line at the top of the function, so the assignment's $? is clean.
The no-over-refusal assertions using exit 3 (owner-scope refusal from the fail-closed CLI) are a clean approach — verifying sibling --allow* flags reach the CLI without any network call.
Contract consistency — guard_contract.py lines 1108–1117
stem = flag.split("=", 1)[0]
return any(denied.startswith(stem) for denied in WRAPPER_DENIED_FLAGS[wrapper])split("=", 1)[0] is semantically identical to bash %%=* (both split on the first = and take the left side). The denied.startswith(stem) check mirrors "--allow-unpinned-head" == "$stem"*. The two implementations match exactly — no divergence that could produce a split result between the bash filter and the Python consistency check.
The two new Refusal rows are well-formed: distinct IDs, accurate claim prose explaining the mechanism, correct refused_by=BASH_WRAPPER attribution, and error_contains matching what the error message echoes (the original $arg form).
Second commit — comment hygiene
The follow-up commit (3652c73) rewrites a comment that referenced "PR #1382" to use a bare description instead, matching the (#1234) convention used elsewhere. Correct and appropriate.
Minor observation (non-blocking)
The two new Refusal rows aren't listed in the backed_by tuple for the merge entry-point (line ~916). The existing rows adequately back the entry-point claim and backed_by appears to list representative rather than exhaustive rows, so this is a completeness note rather than a defect.
Changelog / versioning
The 0.32.0 → 0.32.1 patch bump is appropriate for a bug fix with no API change. The CHANGELOG entry is accurate and covers the test methodology rationale as well as the fix itself.
|
Claude finished @kyle-sexton's task in 2m 6s —— View job Security Review
No security vulnerabilities found. Detailed analysis of each changed surface below.
|
Summary
plugins/source-control/bin/source-control-babysit-mergeexists to add exactly one refusal ontop of
babysit_merge.py: it rejects--allow-unpinned-headso that no unattended,allow-rule-covered invocation can merge an unvetted head. The guard's own comment says it "must not
depend on the interpreter behind it" — but an
=valuespelling of the flag did exactly that.Reproduced on
origin/mainbefore touching anything:Both exit 2, but for different reasons. The guard is
[[ "$arg" == --a* && "--allow-unpinned-head" == "$arg"* ]]— a test that$argis a prefix of the flag.--allow-unpinned-head=trueis not aprefix of
--allow-unpinned-head(the=truetail breaks the comparison), so the wrapper's ownfilter never fires; the CLI happens to reject it today only because the flag is
argparse store_true, which refuses an explicit value. The refusal is real, but incidental — the moment theguarded flag (or an equivalent guarded flag) accepts a value, this same test stops refusing
anything while looking identical, and nothing fails loudly to say so.
Fix
Strip a
--flag=valuetail (stem="${arg%%=*}") before the prefix comparison, so the guard teststhe option name rather than the raw argument. No exact spelling that previously refused changed
behavior — the stem of an already-refused argument is unchanged, and a stemmed sibling flag
(
--allow-dependency,--allow-unprotected,--allowed-owners=<value>) still isn't a prefix of--allow-unpinned-head, so it still reaches the CLI unmolested.Test plan
check_wrapper_refusalrows inengine.test.shand the two newguard_contract.pyrows against unmodifiedorigin/mainand confirmed they fail: 3 bash rowsFAIL (
--allow-unpinned-head=true,--allow-unpinned=1,--allow-unpinned-hea=1all reachargparse instead of the wrapper) and 2 Python
test_every_refusal_rowsubtests FAIL the sameway.
between the wrapper's refusal and argparse's own usage/rejection errors on this path, so an
exit-code-only assertion would have passed before and after this fix for different reasons
(exactly the trap
--allow-unpinned-head=trueis).engine.test.shgained acheck_wrapper_refusalhelper that greps stderr for the wrapper's refusal text;guard_contract.py's existing framework already asserts no JSON envelope was emitted (proofPython never ran) for
bash-wrapper-attributed rows.--allow-unpinned-head=true,--allow-unpinned=1,--allow-unpinned-hea=1(all refused by the wrapper) plus no-over-refusal rows for--allow-dependency,--allow-unprotected, and--allowed-owners=owner(all still reach thefail-closed CLI, verified via an out-of-scope-owner exit 3 so no network call is needed).
merge.equals-value-unpinned-head-refused-by-wrapperandmerge.equals-value-abbreviated-unpinned-head-refused-by-wrapper, each citing fix(source-control): babysit-merge wrapper's --allow-unpinned-head guard misses the =value spelling #1522. Alsoupdated
wrapper_denies()(used by the doc/parser cross-checktest_every_wrapper_refusal_row_reaches_the_denial_table) to strip the same=valuetailbefore its own prefix check, so the two new rows don't fail that self-consistency test for an
unrelated reason. Regenerated
reference/guard-contract.mdviapython tests/guard_contract.py --emit.engine.test.shwrapper rows PASS; fullunittest discoversuite (442 tests, includes both new rows and the existing 5 unpinned-head-family rows) OK;
ruff checkclean;shellcheck -xon the wrapper clean.scripts/check-changed-skills.sh origin/main(triggerphrases preserved,
engine.test.shpasses). Note: this machine's defaultpython3/pythononPATH resolve to a broken local interpreter install unrelated to this change (a
dataclassesimport failure); the gate was run with a working Python 3.11+ interpreter on PATH, and the
identical failure reproduces on unmodified
origin/maintoo, confirming it's pre-existing andenvironmental, not a regression.
scripts/check-changelog-parity.sh --checkand--check-bump origin/main✔;scripts/validate-plugins.sh✔;markdownlint-cli2on the touched docs clean.0.32.0→0.32.1with the matching CHANGELOG entry.Related
Closes #1522. Same failure family as #1371 (fixed by #1354, which made the guard prefix-aware) —
this closes the one spelling that fix's prefix comparison still missed. Found during independent
verification of #1405, per #1522's own description; not introduced by that PR.
This was generated by AI during work-loop execution.
🤖 Generated with Claude Code