Skip to content

fix(source-control): close the flag-abbreviation gap in both babysit guards - #1354

Merged
kyle-sexton merged 4 commits into
mainfrom
fix/babysit-argparse-abbrev
Jul 25, 2026
Merged

fix(source-control): close the flag-abbreviation gap in both babysit guards#1354
kyle-sexton merged 4 commits into
mainfrom
fix/babysit-argparse-abbrev

Conversation

@kyle-sexton

Copy link
Copy Markdown
Contributor

Summary

Both guarded babysit scripts built their parsers with bare argparse.ArgumentParser(description=__doc__), leaving allow_abbrev at its default True. Replayed empirically: --i resolves to --include-human and --mer to --merge, while the command TEXT contains neither. Every permission condition stated as the literal presence or absence of a flag — the standard melodic-software/dotfiles#315's auto-mode allow entries are held to — was therefore defeasible by prefix spelling: a lane could resolve a human-participating thread, or land a merge with no grant anywhere, while every stated condition read as satisfied.

Changes:

  • allow_abbrev=False on both parsers (babysit_resolve_thread.py, babysit_merge.py), with the constraint documented at each site.
  • The merge wrapper's --allow-unpinned-head refusal is now prefix-aware instead of exact-equality — --allow-unpinned-hea previously sailed past the wrapper and argparse accepted it. The wrapper is the allow-rule boundary and must not depend on the interpreter behind it.
  • The autonomous resolver now machine-enforces the severity half of its covering grant's never-conditions: any thread whose fetched comments carry a structured severity marker (shared babysit_classify vocabulary — CRITICAL/IMPORTANT surviving negation redaction, shields P0–P3 badge, bracketed [P0]–[P3]) or the word "security" is refused in --autonomous mode, failing closed when the comment page is truncated. Interactive modes are unchanged; severity judgment there stays with the evaluating agent. New skipped-severity-marked action and skippedSeverityMarked summary counter.

Tests

  • Abbreviation rejection for both parsers (SystemExit 2 before any network call).
  • Severity guard: skip in autonomous, eligible interactively, fail-closed on missing signal and on truncated comment pages; projection matches for badge/bracket/CRITICAL/security and non-matches for prose (AP1000).
  • Full suite: 60 tests OK; ruff and shellcheck clean.

Related

No linked issue. Cross-repo counterpart of melodic-software/dotfiles#315 (verifier finding F1 and the two live Codex P1 review threads there); the dotfiles allow entries' flag-literal conditions become sound once this lands in the installed plugin cache.

🤖 Generated with Claude Code

…guards

Both guarded parsers left argparse abbreviation on, so `--i` resolved to
--include-human and `--mer` to --merge while the command text contained
neither — defeating every permission condition stated as the literal
presence or absence of a flag. Set allow_abbrev=False on both, and make
the merge wrapper refuse --allow-unpinned-head by prefix family instead
of exact equality, so the wrapper boundary no longer depends on the
interpreter behind it.

The autonomous resolver additionally refuses severity/security-marked
threads (shared structured-severity vocabulary plus the word "security",
fail-closed on comment truncation), putting code behind the "never a
security or P1 thread" condition the covering permission grants state.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@claude

claude Bot commented Jul 25, 2026

Copy link
Copy Markdown

Claude finished @kyle-sexton's task in 2m 50s —— View job


Code Review

  • Gather context and understand the PR
  • Review changed files
  • Analyze correctness, security, and test coverage
  • Post findings

The PR closes a real security gap: argparse's default allow_abbrev=True let unattended workers pass --mer for --merge or --i for --include-human while the literal flag was absent from the command text that the permission grants inspect. The three changes work together correctly. A few items worth noting:

source-control-babysit-merge — wrapper prefix check

The new condition is:

if [[ "$arg" == --a* && "--allow-unpinned-head" == "$arg"* ]]; then

The second clause uses "$arg"* as a bash glob pattern on the right side of [[ == ]]. If $arg contains glob metacharacters ([, ?, *), bash may evaluate an unintended pattern. For example, $arg = "--allow-[u" produces the pattern --allow-[u*, which bash may treat as a character class or a literal depending on the shell version, and could match unexpectedly.

In practice an attacker-controlled $arg with embedded brackets is contrived and the allow_abbrev=False guard in Python is the real fix; the wrapper is defense-in-depth. But to make the wrapper robust and intent-clear, quoting the pattern expansion could eliminate the ambiguity entirely:

if [[ "$arg" == --a* && "${arg:0:${#arg}}" == "${arg}" && "--allow-unpinned-head" == "${arg}"* ]]; then

or more simply, using a startswith approach that avoids glob side effects:

if [[ "$arg" == --a* ]] && [[ "--allow-unpinned-head" == "${arg}"* ]]; then

The glob issue is only present in the RHS of [[ ]]; the current code already quotes "$arg" on the LHS of the first clause, which is correct. This is a minor concern — the path where it matters is narrow and the Python-level guard closes it independently.

Permalink

babysit_resolve_thread.py — severity guard ordering in classify()

The skipped-severity-marked check fires after skipped-not-outdated:

if (autonomous or only_outdated) and not thread["isOutdated"]:
    return "skipped-not-outdated"
if autonomous and thread.get("severityFlagged", True):
    return "skipped-severity-marked"

A severity-marked thread that is also not-outdated will return skipped-not-outdated, not skipped-severity-marked. The skippedSeverityMarked counter therefore undercounts in the case where a severity-flagged thread is also not-outdated. More importantly, a caller reading the output to understand why a thread was skipped would see skipped-not-outdated and miss the severity signal. For a guard whose stated purpose is "never a security or P1 thread", making the reason invisible when it coincides with not-outdated is a diagnostics gap.

The fix is to check severity before the not-outdated gate:

if (autonomous or only_outdated) and not thread["isOutdated"]:
    return "skipped-not-outdated"

if autonomous and thread.get("severityFlagged", True):
    return "skipped-severity-marked"
if (autonomous or only_outdated) and not thread["isOutdated"]:
    return "skipped-not-outdated"

The security contract is still fully enforced either way (both not-outdated and severity-marked are non-resolving skips), so this is a diagnostics issue rather than a correctness bug. But the undercounting of skippedSeverityMarked could mislead an operator reviewing the output.

Permalink

Fix this →

Test coverage gap — severity + not-outdated interaction

No test covers the case of a thread that is both severity_flagged=True and is_outdated=False in --autonomous mode. Under the current ordering that thread surfaces as skipped-not-outdated; after a fix it would surface as skipped-severity-marked. A test asserting the correct label would lock in the intended behavior either way.

Minor: _has_severity_marker duplication with approval_reports_blocking

babysit_merge.py's approval_reports_blocking() combines the same three predicates (has_blocking_severity, SEVERITY_BADGE_RE, SEVERITY_PLAIN_RE) without SECURITY_TEXT_RE. The resolver's _has_severity_marker adds the security-word case. The intentional divergence is documented in the new comment, so this is noted rather than flagged — but exporting a shared helper from babysit_classify would eliminate the duplication if this pattern grows.

Everything else looks correct

  • allow_abbrev=False on both parsers: correct fix, comment explains the "why" precisely.
  • Fail-closed on truncated comment pages (severity_flagged = truncated or any(...)): correct.
  • Default True in thread.get("severityFlagged", True): correctly fails closed when the projection key is absent.
  • skippedSeverityMarked counter in JSON output: consistent with the existing counter conventions.
  • Test for --mer abbreviation rejection and --i abbreviation rejection: both verify SystemExit(2) before any network call, which is the right assertion level.
  • NoAbbreviatedFlags.test_merge_abbreviation_is_rejected cleanly isolates the argparse layer by not requiring any network stub.

Summary: Two items worth addressing before merge — the classify() severity-check ordering (diagnostics gap, skippedSeverityMarked undercounting) and the missing test for the severity+not-outdated combination. The bash glob edge case in the wrapper is minor and the security contract holds regardless. The core allow_abbrev=False fix is correct and well-tested.

@claude

claude Bot commented Jul 25, 2026

Copy link
Copy Markdown

Claude finished @kyle-sexton's task in 4m 7s —— View job


Security Review

  • Gather context and read changed files
  • Analyze source-control-babysit-merge shell wrapper
  • Analyze babysit_merge.py
  • Analyze babysit_resolve_thread.py (severity guard + abbrev fix)
  • Analyze test files for coverage gaps
  • Post findings

Scope: 5 changed files — source-control-babysit-merge, babysit_merge.py, babysit_resolve_thread.py, and their test files. Reviewed only the diff.


IMPORTANT — --allow-unpinned-head=VALUE bypasses the wrapper refusal check

Confidence: PLAUSIBLE (bypass exists in the wrapper; not currently exploitable due to argparse store_true semantics, but violates the design principle this PR explicitly establishes)

The new prefix check in the shell wrapper (line 22):

if [[ "$arg" == --a* && "--allow-unpinned-head" == "$arg"* ]]; then

The second condition ("--allow-unpinned-head" == "$arg"*) asks: does the literal string --allow-unpinned-head match the pattern "$arg"*? — i.e., does --allow-unpinned-head start with $arg? For a prefix like --allow-unpinned-hea, it does. But for --allow-unpinned-head=VALUE, it doesn't — the pattern stem --allow-unpinned-head=VALUE is longer than the literal being tested, so the condition is false, and the argument passes through the wrapper unchallenged.

arg = "--allow-unpinned-head=foo"
check 1: "--allow-unpinned-head=foo" == --a*          → TRUE  (starts with --a)
check 2: "--allow-unpinned-head" == "--allow-unpinned-head=foo"*  → FALSE (literal is shorter than stem)
→ wrapper does not intercept; arg is forwarded to babysit_merge.py

In practice, babysit_merge.py declares --allow-unpinned-head as action="store_true", and Python's argparse rejects any --flag=VALUE form for store_true flags with exit 2. So this particular path is not currently exploitable.

The concern is that this PR's own design goal, stated in the wrapper comment, is "this wrapper is the allow-rule boundary and must not depend on the interpreter behind it." The =VALUE form is a surface the wrapper silently defers to the interpreter for — the exact dependency the PR set out to eliminate. If --allow-unpinned-head were ever changed to an optional-value argument, or if another refused flag were added with a non-store_true action, the gap would become load-bearing.

The fix is a third condition or an extended guard pattern:

if [[ "$arg" == --a* && "--allow-unpinned-head" == "$arg"* ]] \
    || [[ "$arg" == "--allow-unpinned-head="* ]]; then

Or, more robustly, match the full flag name with an optional trailing =:

if [[ "$arg" == "--allow-unpinned-head" || "$arg" == "--allow-unpinned-head="* \
      || ( "$arg" == --a* && "--allow-unpinned-head" == "$arg"* ) ]]; then

No other security issues found

The remainder of the changes are clean:

  • allow_abbrev=False on both parsers correctly closes the prefix-abbreviation bypass that motivated this PR. Tested.
  • Severity guard (_has_severity_marker) is fail-closed on truncated comment pages and on a missing severityFlagged field (default True). Tested.
  • GraphQL mutation in resolve_thread passes the thread ID as a typed variable (-f id={thread_id}), not interpolated into the query string — no injection surface.
  • printf error message uses '%s\n' as the format string and "$arg" as its argument — $arg cannot reach the format specifier position.
  • Severity guard ordering in classify is sound: the not-outdated check precedes severity-marked, so a non-outdated severity thread is refused at the earlier guard (still refuse, not double-allow).
  • Thread ID comparison (thread["id"] != args.thread_id) is exact string equality — no injection or ambiguity.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 6d18fc79c4

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread plugins/source-control/skills/babysit-prs/scripts/babysit_resolve_thread.py Outdated
…idden class

The first cut reused the shared P0-P3 vocabulary, which flagged the
advisory P2/P3 threads the worker is documented to resolve once outdated
and would have self-blocked the merge gate on its own advisory threads.
The guard now matches only what the covering grants actually forbid
unattended: a P0/P1 badge or bracket, the word CRITICAL, or the word
"security" -- still fail-closed on truncation.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@claude

claude Bot commented Jul 25, 2026

Copy link
Copy Markdown

Claude encountered an error after 0s —— View job


I'll analyze this and get back to you.

@claude

claude Bot commented Jul 25, 2026

Copy link
Copy Markdown

Claude encountered an error after 0s —— View job


I'll analyze this and get back to you.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: d8b49c0c71

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread plugins/source-control/skills/babysit-prs/scripts/babysit_resolve_thread.py Outdated
One word-bounded token now covers badge, bracket, and the bare prose
spellings bots also emit ("P1: blocking regression", "P1 must fix"),
which the badge/bracket-only pair missed. The boundary keeps P2/P3
markers and embedded strings like AP1000 out; a thread merely mentioning
P1 flags to interactive judgment, the safe direction.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@claude

claude Bot commented Jul 25, 2026

Copy link
Copy Markdown

Claude finished @kyle-sexton's task in 3m 42s —— View job


Code Review

  • Gather context from PR and prior review rounds
  • Read changed files (full diff from origin/main)
  • Analyze latest commit (bare P0/P1 prose form fix)
  • Check open items from prior rounds (ordering, wrapper gap, test coverage)
  • Post findings

Latest commit (60e19d1) — correct and complete

The Codex finding was valid. d8b49c0 introduced two narrow regexes:

SEVERITY_BLOCK_BADGE_RE = re.compile(r"/badge/P[01]-")   # only shields badge
SEVERITY_BLOCK_PLAIN_RE = re.compile(r"\[P[01]\]")        # only bracketed marker

Neither matched bare prose forms like P1: blocking regression or P1 must fix. The fix in 60e19d1 is the right approach — replacing both with a single word-boundary pattern:

SEVERITY_BLOCK_P01_RE = re.compile(r"\bP[01]\b")

\bP[01]\b covers all three spelling families in one pattern. Verification:

  • /badge/P1-orangeP1 is word-bounded (/ and - are non-word chars) → matches ✓
  • [P1][ and ] are non-word chars → matches ✓
  • P1: blocking regression: is a non-word char → matches ✓
  • AP1000 → preceded by word char A\bP does not match → correctly excluded ✓
  • P2-yellowP2 not in P[01] → correctly excluded ✓

The test_bare_p1_prose_forms_flag test locks in the new behavior. This commit is correct.


Two items from prior review rounds remain open

Both were flagged in the first code review (round 1, on 6d18fc7) and have not been addressed across any of the three commits.

1. classify() severity-check ordering — diagnostics gap

babysit_resolve_thread.py line 247

if (autonomous or only_outdated) and not thread["isOutdated"]:
    return "skipped-not-outdated"
if autonomous and thread.get("severityFlagged", True):
    return "skipped-severity-marked"

A thread that is both severityFlagged=True and isOutdated=False in --autonomous mode returns skipped-not-outdated, never reaching the severity check. The security contract holds (both paths skip), but:

  • skippedSeverityMarked undercounts — an operator auditing the summary sees zero severity skips even when severity-flagged threads were skipped.
  • The reason emitted per-thread is wrong — a caller reading thread-level output to understand why something was skipped misses the severity signal entirely.

Fix: move the severity check above the not-outdated gate, so a severity-flagged thread always surfaces its actual reason:

if autonomous and thread.get("severityFlagged", True):
    return "skipped-severity-marked"
if (autonomous or only_outdated) and not thread["isOutdated"]:
    return "skipped-not-outdated"

Fix this →

Missing test — severity + not-outdated interaction

No test covers autonomous=True, severity_flagged=True, is_outdated=False. Under the current ordering that thread reports skipped-not-outdated; after the fix it would report skipped-severity-marked. A test asserting the correct label is needed to lock in the intended behavior.


2. Wrapper --allow-unpinned-head=VALUE bypass — design-principle gap

source-control-babysit-merge line 22

if [[ "$arg" == --a* && "--allow-unpinned-head" == "$arg"* ]]; then

The second clause asks "does the literal --allow-unpinned-head start with $arg?" For --allow-unpinned-head=foo, the pattern stem is longer than the literal, so the test is false — the argument passes through:

arg = "--allow-unpinned-head=foo"
"--allow-unpinned-head" == "--allow-unpinned-head=foo"*  →  FALSE

Currently not exploitable: babysit_merge.py declares --allow-unpinned-head as action="store_true", and argparse rejects any --flag=VALUE form for store_true with exit 2. But the PR comment at line 15–19 explicitly states "this wrapper is the allow-rule boundary and must not depend on the interpreter behind it" — the =VALUE path is a case where the wrapper silently defers to the interpreter, which is the exact dependency it was written to eliminate.

A third clause closes the gap:

if [[ "$arg" == --a* && "--allow-unpinned-head" == "$arg"* ]] \
    || [[ "$arg" == "--allow-unpinned-head="* ]]; then

Everything else is correct

  • \bP[01]\b — uniform pattern, correct word-boundary semantics, covers all three P0/P1 spellings, correctly excludes AP1000 and P2/P3 markers.
  • allow_abbrev=False on both parsers — correct fix, tested, comments explain the why.
  • Fail-closed on truncated comment pages (severity_flagged = truncated or any(...)) — correct.
  • Default True in thread.get("severityFlagged", True) — correctly fails closed when the projection key is absent.
  • P2/P3 advisory threads not flagged (d8b49c0) — correct; verified by test_advisory_p2_marker_does_not_flag.
  • GraphQL mutation passes thread ID as a typed variable, not interpolated — no injection surface.
  • skippedSeverityMarked counter shape is consistent with existing counter conventions.

Summary: 60e19d1 correctly addresses the Codex finding — the unified \bP[01]\b regex is the right fix and is now tested. Two items from prior rounds remain: the classify() severity-check ordering (diagnostics gap, skippedSeverityMarked undercounts, missing test for the severity+not-outdated combination) and the wrapper =VALUE bypass (currently non-exploitable but violates the stated design principle).

@claude

claude Bot commented Jul 25, 2026

Copy link
Copy Markdown

Claude finished @kyle-sexton's task in 3m 7s —— View job


Security Review

  • Gather context
  • Fetch PR diff (3 commits: 6d18fc7, d8b49c0, 60e19d1)
  • Read changed files
  • Analyze for security issues
  • Post findings

Scope: 5 changed files — source-control-babysit-merge, babysit_merge.py, babysit_resolve_thread.py, and their test files. Reviewed all three commits at HEAD (60e19d1).


SUGGESTION — --allow-unpinned-head=VALUE still bypasses the wrapper prefix guard

Confidence: CONFIRMED (the bypass path exists; not currently exploitable due to argparse store_true semantics)

Previously flagged in the round-1 security review; still present at HEAD. The wrapper check:

if [[ "$arg" == --a* && "--allow-unpinned-head" == "$arg"* ]]; then

The second clause asks: does the literal --allow-unpinned-head match the pattern "$arg"*? That is, does --allow-unpinned-head START WITH $arg? For $arg = "--allow-unpinned-head=anything", the stem is longer than the literal being tested, so the condition is false, and the argument passes through the wrapper unchallenged:

arg = "--allow-unpinned-head=1"
clause 1: "--allow-unpinned-head=1" == --a*                  → TRUE
clause 2: "--allow-unpinned-head" == "--allow-unpinned-head=1"* → FALSE  (literal shorter than stem)
→ not intercepted; forwarded to babysit_merge.py

Argparse closes this gap in practice — store_true exits 2 on any --flag=value form — but the wrapper's own comment states the design principle that "this wrapper is the allow-rule boundary and must not depend on the interpreter behind it." The =VALUE form is exactly the dependency the PR set out to eliminate. If --allow-unpinned-head were ever changed to accept a value, or another refused flag were added with a non-store_true action, the gap becomes load-bearing.

The fix is straightforward — add a third || branch for the =VALUE form:

if [[ "$arg" == --a* && "--allow-unpinned-head" == "$arg"* ]] \
    || [[ "$arg" == "--allow-unpinned-head="* ]]; then

Permalink

Fix this →


No other security issues found

The remainder of the changes are clean at HEAD:

  • allow_abbrev=False on both parsers: correct and tested (--mer and --i both exit 2 before any network call).
  • Severity guard regex (\bP[01]\b): word boundaries correctly include badge, bracket, and bare prose forms (P1: ..., P1 must fix), while excluding embedded strings like AP1000 and advisory P2/P3 markers. Correctly case-sensitive for CRITICAL (avoids over-matching prose like "it's critical that...").
  • SECURITY_TEXT_RE (re.IGNORECASE substring match): intentionally broad — any occurrence of "security" in any case flags the thread. False positives route to interactive judgment, not to a bypass; fail-closed is the right direction here.
  • Fail-closed on truncation: both severityFlagged and botOnly default to the safe value when comments_truncated is true. The thread.get("severityFlagged", True) default in classify() likewise fails closed when the projection key is absent.
  • GraphQL mutation (resolve_thread): thread_id is passed as a separate subprocess argument list element — no shell interpolation, no injection surface.
  • Wrapper printf: uses '%s\n' format with "$arg" as the argument — $arg cannot reach the format specifier position.
  • Wrapper metacharacter safety: In bash [[ ]], the double-quoted portion of "$arg"* on the RHS suppresses glob metacharacter interpretation for characters within $arg; only the trailing * acts as a wildcard. Characters like [, ?, * inside $arg are treated as literals.
  • Severity-check ordering in classify(): still checks not thread["isOutdated"] before severityFlagged. The security contract is preserved either way (both are non-resolving skips); this is a diagnostics gap noted in the code-review comment, not a security issue.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 60e19d1d7c

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread plugins/source-control/skills/babysit-prs/scripts/babysit_resolve_thread.py Outdated
Bots emit lowercase forms too (p1:, [p1], priority:p1 fragments); the
case-sensitive token let those threads through the never-P1 condition.
IGNORECASE on the one P-token regex; CRITICAL stays uppercase-only to
match the structured vocabulary rather than ordinary prose.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@claude

claude Bot commented Jul 25, 2026

Copy link
Copy Markdown

Claude finished @kyle-sexton's task in 2m 39s —— View job


Code Review

  • Gather context from PR and prior review rounds
  • Read changed files at HEAD (eaed1c6)
  • Analyze latest commit (lowercase P0/P1 fix)
  • Check open items from prior rounds (ordering, wrapper gap)
  • Post findings

Latest commit (eaed1c6) — correct and complete

The Codex finding was valid: the case-sensitive \bP[01]\b missed lowercase forms that bots emit (p1:, [p1], priority:p1 fragments). The fix is the right one:

# before
SEVERITY_BLOCK_P01_RE = re.compile(r"\bP[01]\b")
# after
SEVERITY_BLOCK_P01_RE = re.compile(r"\bP[01]\b", re.IGNORECASE)

CRITICAL staying case-sensitive is intentional and correctly documented — critical as an English adjective ("it's critical that...") would generate false positives, whereas bots using the structured vocabulary write uppercase CRITICAL. SECURITY_TEXT_RE retains re.IGNORECASE as it should. The two new test cases ("p1: blocking regression" and "[p1] lowercase marker") lock in the behavior.


Two items from prior rounds remain open

Both were raised in round 1 (on 6d18fc7), confirmed in round 2 (on 60e19d1), and are still present at HEAD.

1. classify() severity-check ordering — diagnostics gap and counter undercounting

babysit_resolve_thread.py line 249

if (autonomous or only_outdated) and not thread["isOutdated"]:
    return "skipped-not-outdated"          # ← fires first
if autonomous and thread.get("severityFlagged", True):
    return "skipped-severity-marked"       # ← never reached when also not-outdated

A thread with severityFlagged=True and isOutdated=False in --autonomous mode exits at the first gate and reports skipped-not-outdated. The security contract is still enforced (both are non-resolving skips), but:

  • skippedSeverityMarked in the JSON summary undercounts — an operator auditing the output to confirm the guard fired sees zero even when severity-flagged threads were skipped.
  • The per-thread action field is wrong — a caller reading why a thread was skipped misses the severity signal entirely, which undermines the audit trail this guard was added to provide.

Fix: move the severity check above the not-outdated gate.

if autonomous and thread.get("severityFlagged", True):
    return "skipped-severity-marked"
if (autonomous or only_outdated) and not thread["isOutdated"]:
    return "skipped-not-outdated"

Missing test — severity + not-outdated interaction

No test covers autonomous=True, severity_flagged=True, is_outdated=False. Under the current ordering that thread surfaces as skipped-not-outdated; after the fix it would surface as skipped-severity-marked. The existing test_severity_marked_thread_is_skipped_in_autonomous_mode covers is_outdated=True only. A test asserting the correct label for the is_outdated=False case is needed to lock in the intended behavior.

Fix this →


2. Wrapper --allow-unpinned-head=VALUE bypass — design-principle gap

source-control-babysit-merge line 22

if [[ "$arg" == --a* && "--allow-unpinned-head" == "$arg"* ]]; then

The second clause asks: does the literal --allow-unpinned-head start with $arg? For $arg = "--allow-unpinned-head=foo", the stem is longer than the literal, so the test is false and the argument passes through:

arg = "--allow-unpinned-head=foo"
clause 1: "--allow-unpinned-head=foo" == --a*                     → TRUE
clause 2: "--allow-unpinned-head" == "--allow-unpinned-head=foo"* → FALSE (stem longer than literal)
→ not intercepted; forwarded to babysit_merge.py

Currently non-exploitable: store_true argparse semantics reject any --flag=value form with exit 2. The concern is the wrapper's own stated design principle (line 16–19): "this wrapper is the allow-rule boundary and must not depend on the interpreter behind it." The =VALUE form is exactly the dependency the PR set out to eliminate — if --allow-unpinned-head were ever changed to accept a value, or if another refused flag were added with a non-store_true action, the gap becomes load-bearing.

Add a third branch to close the =VALUE surface:

if [[ "$arg" == --a* && "--allow-unpinned-head" == "$arg"* ]] \
    || [[ "$arg" == "--allow-unpinned-head="* ]]; then

Fix this →


Everything else is correct at HEAD

  • re.IGNORECASE on SEVERITY_BLOCK_P01_RE: correct fix, tests cover badge, bracket, bare prose, and now lowercase forms; AP1000 exclusion verified.
  • CRITICAL stays case-sensitive: intentional and documented — avoids false positives from English adjective use; bots use uppercase structured vocabulary.
  • allow_abbrev=False on both parsers: correct fix, tested (--mer and --i both exit 2 before any network call).
  • Fail-closed on truncated comment pages (severity_flagged = truncated or any(...)): correct.
  • Default True in thread.get("severityFlagged", True): correctly fails closed when the projection key is absent.
  • skippedSeverityMarked counter shape: consistent with existing counter conventions.

Summary: eaed1c6 correctly addresses the Codex lowercase finding — re.IGNORECASE on the P-token regex is the right fix and is now tested. Two items from prior rounds remain: the classify() severity-check ordering (diagnostics gap, skippedSeverityMarked undercounting, missing test for severity+not-outdated) and the wrapper =VALUE bypass (currently non-exploitable but violates the stated design principle). Both are flagged for the third consecutive round.

@claude

claude Bot commented Jul 25, 2026

Copy link
Copy Markdown

Claude finished @kyle-sexton's task in 2m 55s —— View job


Security Review

  • Gather context from PR and prior review rounds
  • Fetch PR diff at HEAD (eaed1c6)
  • Read changed files
  • Analyze for security issues
  • Post findings

Scope: 5 changed files at HEAD (eaed1c6) — source-control-babysit-merge, babysit_merge.py, babysit_resolve_thread.py, and their test files. All four commits reviewed.


SUGGESTION — --allow-unpinned-head=VALUE still bypasses the wrapper guard

Confidence: CONFIRMED (bypass path exists; not currently exploitable due to argparse store_true semantics)

Present since 6d18fc7, unfixed through all subsequent commits. The wrapper check at line 22:

if [[ "$arg" == --a* && "--allow-unpinned-head" == "$arg"* ]]; then

The second clause asks: does the literal --allow-unpinned-head start with $arg? For $arg = "--allow-unpinned-head=anything", the stem is longer than the literal, so the check is false and the argument passes through:

arg = "--allow-unpinned-head=1"
clause 1: "--allow-unpinned-head=1" == --a*                    → TRUE
clause 2: "--allow-unpinned-head" == "--allow-unpinned-head=1"* → FALSE (literal is shorter)
→ not intercepted; forwarded to babysit_merge.py

babysit_merge.py declares --allow-unpinned-head as action="store_true", so argparse rejects --flag=VALUE with exit 2, closing the gap in practice. However the wrapper comment at line 16–20 explicitly states: "this wrapper is the allow-rule boundary and must not depend on the interpreter behind it." The =VALUE form is exactly the gap the PR set out to eliminate, and it is now the only remaining case where the wrapper silently defers to the interpreter.

Fix — add a third || branch:

if [[ "$arg" == --a* && "--allow-unpinned-head" == "$arg"* ]] \
    || [[ "$arg" == "--allow-unpinned-head="* ]]; then

Fix this →


No other security issues found

Everything else in the diff is clean:

  • allow_abbrev=False on both parsers: correct, tested (both --mer and --i exit 2 before any network call).
  • eaed1c6re.IGNORECASE on SEVERITY_BLOCK_P01_RE: correct fix for the Codex lowercase-P01 finding. \bP[01]\b with re.IGNORECASE covers shields badge (/badge/p1-), bracketed ([p1]), and bare prose (p1: blocking regression), while word boundaries correctly exclude AP1000 and all P2/P3 forms. The digits 0/1 in the character class are unaffected by re.IGNORECASE.
  • SEVERITY_BLOCK_WORD_RE = re.compile(r"\bCRITICAL\b") — case-sensitive by design, consistent with babysit_classify.py's treatment of CRITICAL/IMPORTANT (the comment there: "lowercase 'critical'/'important' are ordinary prose"). Narrowing to uppercase avoids the false-positive class; the real structured markers (P0/P1, security) are covered by the other patterns.
  • SECURITY_TEXT_REre.IGNORECASE substring match: intentionally broad; any occurrence of "security" flags to interactive judgment. False positives in the safe direction.
  • Fail-closed on truncation: severity_flagged = truncated or any(...) — a truncated page is treated as severity-flagged, not clean.
  • thread.get("severityFlagged", True) default in classify(): fails closed when the key is absent.
  • GraphQL mutation (resolve_thread): thread ID passed as a typed gh variable list element, not interpolated into the query string — no injection surface.
  • printf '%s\n' "...$arg..." in wrapper: $arg is the argument to %s, not the format string — no format-specifier injection.
  • Bash [[ ]] RHS metacharacter safety: in "$arg"*, the double-quoted "$arg" portion suppresses metacharacter expansion for characters inside $arg; only the trailing bare * acts as a wildcard. Embedded [, ?, * inside $arg are treated as literals.
  • classify() ordering (not-outdated before severity-marked): a thread that is both severity-flagged and not-outdated returns skipped-not-outdated instead of skipped-severity-marked. The security contract holds — both are non-resolving skips. This is a diagnostics gap (undercounting skippedSeverityMarked), not a security issue.

@kyle-sexton
kyle-sexton merged commit 2221a4e into main Jul 25, 2026
26 checks passed
@kyle-sexton
kyle-sexton deleted the fix/babysit-argparse-abbrev branch July 25, 2026 20:01
kyle-sexton added a commit that referenced this pull request Jul 26, 2026
…point (#1428)

Closes #1371.

Replaces #1382, which is closed in favour of this. #1382 was opened
before #1354 landed and is now a strict superset of it: re-scoped here
to the residual only, on a fresh base, with the property bound to the
directory rather than to a list of files.

## The gap

A permission grant states its condition as the literal presence or
absence of a flag in the command text — above all *"no `--merge` means
check-only"*. Argparse's default prefix abbreviation lets `--mer`
resolve to `--merge` while the command text contains no such flag, so
the written command and the resolved behavior diverge. That is exactly
what such a condition has to be able to rule out.

#1354 closed this on `babysit_merge.py` and `babysit_resolve_thread.py`.
Seven entry points still inherited the default:

`babysit_findings.py` · `manage_babysit_lease.py` ·
`manage_feedback_ledger.py` · `pr_queue_snapshot.py` ·
`prune_babysit_worktrees.py` · `refresh_pr_branch.py` ·
`request_review.py`

All nine now set `allow_abbrev=False`.

## Why a gate, not seven more edits

Hardening entry points one at a time is what let the gap survive #1354
for seven files. The guard contract gains a check over the whole
catalogue: every catalogued Python entry point is invoked with `--hel`
and must not exit 0.

`--help` is registered on every parser and short-circuits parsing, so an
abbreviation that *resolves* exits 0 before required-argument validation
ever runs, while one that does not is a usage error. That makes the exit
code a sufficient discriminator without a per-CLI argument shape — which
is what makes this a gate over the catalogue rather than a
hand-maintained list of cases. The message is deliberately not asserted:
several of these parsers have a required mutually exclusive group that
errors before any unrecognized argument is reported.

A companion test asserts that discrimination against argparse itself
rather than assuming it.

## Verification

- `python -m unittest discover -s tests` — 387 tests, OK.
- Detector verified rather than assumed: reverting `request_review.py`'s
`allow_abbrev=False` fails the gate naming that file (`resolved the
abbreviation --hel to --help and exited 0`), and it passes again on
restore.

## Compatibility

Abbreviated invocations that previously worked are now usage errors.
That is the intent, and the version takes a minor bump for it —
`source-control` 0.29.0 → 0.31.0 (0.30.0 is claimed by #1264, open).

## Related

- #1354 — closed the same defect on the first two entry points
- #1382 — the superset PR this replaces
- #1285 — the guard contract this gate is added to

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
kyle-sexton added a commit that referenced this pull request Jul 26, 2026
…#1539)

## Summary

`plugins/source-control/bin/source-control-babysit-merge` exists to add
exactly one refusal on
top of `babysit_merge.py`: it rejects `--allow-unpinned-head` so 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 `=value` spelling of the
flag did exactly that.

**Reproduced on `origin/main` before touching anything:**

```console
$ bash bin/source-control-babysit-merge owner/repo#1 --allow-unpinned-head
source-control-babysit-merge: --allow-unpinned-head is not permitted through the wrapper (--allow-unpinned-head or a prefix of it).
Invoke babysit_merge.py directly for interactive unpinned use.
RC=2                             # the WRAPPER refused

$ bash bin/source-control-babysit-merge owner/repo#1 --allow-unpinned-head=true
usage: babysit_merge.py ...
babysit_merge.py: error: argument --allow-unpinned-head: ignored explicit argument 'true'
RC=2                             # argparse refused — the wrapper let it through
```

Both exit 2, but for different reasons. The guard is `[[ "$arg" == --a*
&& "--allow-unpinned-head"
== "$arg"* ]]` — a test that `$arg` is a *prefix* of the flag.
`--allow-unpinned-head=true` is not a
prefix of `--allow-unpinned-head` (the `=true` tail breaks the
comparison), so the wrapper's own
filter 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 the
guarded 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=value` tail (`stem="${arg%%=*}"`) before the prefix
comparison, so the guard tests
the 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

- [x] **Red first.** Wrote the `check_wrapper_refusal` rows in
`engine.test.sh` and the two new
`guard_contract.py` rows against unmodified `origin/main` and confirmed
they fail: 3 bash rows
FAIL (`--allow-unpinned-head=true`, `--allow-unpinned=1`,
`--allow-unpinned-hea=1` all reach
argparse instead of the wrapper) and 2 Python `test_every_refusal_row`
subtests FAIL the same
      way.
- [x] **Assertions check the wrapper's own refusal *text*, not exit 2.**
Exit 2 is overloaded
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=true` is). `engine.test.sh`
gained a
`check_wrapper_refusal` helper that greps stderr for the wrapper's
refusal text;
`guard_contract.py`'s existing framework already asserts no JSON
envelope was emitted (proof
      Python never ran) for `bash-wrapper`-attributed rows.
- [x] **New engine.test.sh 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 the
fail-closed CLI, verified via an out-of-scope-owner exit 3 so no network
call is needed).
- [x] **New guard_contract.py rows:**
`merge.equals-value-unpinned-head-refused-by-wrapper` and
`merge.equals-value-abbreviated-unpinned-head-refused-by-wrapper`, each
citing #1522. Also
      updated `wrapper_denies()` (used by the doc/parser cross-check
`test_every_wrapper_refusal_row_reaches_the_denial_table`) to strip the
same `=value` tail
before its own prefix check, so the two new rows don't fail that
self-consistency test for an
unrelated reason. Regenerated `reference/guard-contract.md` via `python
      tests/guard_contract.py --emit`.
- [x] **Green after the fix:** all `engine.test.sh` wrapper rows PASS;
full `unittest discover`
suite (442 tests, includes both new rows and the existing 5
unpinned-head-family rows) OK;
      `ruff check` clean; `shellcheck -x` on the wrapper clean.
- [x] **CHECK-SKILL babysit-prs: PASS** via
`scripts/check-changed-skills.sh origin/main` (trigger
phrases preserved, `engine.test.sh` passes). Note: this machine's
default `python3`/`python` on
PATH resolve to a broken local interpreter install unrelated to this
change (a `dataclasses`
import failure); the gate was run with a working Python 3.11+
interpreter on PATH, and the
identical failure reproduces on unmodified `origin/main` too, confirming
it's pre-existing and
      environmental, not a regression.
- [x] Repo gates run locally: `scripts/check-changelog-parity.sh
--check` and `--check-bump
origin/main` ✔; `scripts/validate-plugins.sh` ✔; `markdownlint-cli2` on
the touched docs clean.
- [x] Version bumped `0.32.0` → `0.32.1` with 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](https://claude.com/claude-code)

---------

Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant