fix(source-control): assert babysit worktree HEAD == PR head before mutate; merge-only, upstream refspec push - #895
Conversation
…utate; merge-only, upstream refspec push A babysit worker can be assigned a worktree in detached HEAD (its PR branch checked out in a sibling/foreign worktree) or on a stale local branch tip; the checkout/freshness mechanics then merged and pushed from that tip, so a stale-tip integration could silently revert the newest branch commit — a near-miss where safety depended on the assigned HEAD happening to match, not a guard (#548). Fix the safety.md contract AND the worker mechanics that implement it: - Assert the worktree HEAD equals the true PR head (`gh pr view --json headRefOid`, authoritative for same-repo and fork PRs) before any merge/edit/push; stop on a stale/detached mismatch. safety.md Checkout And Push Invariants + loop.md §5.1.2 (acquire the head via `gh pr checkout`, `--detach` when the branch is locked in a sibling worktree) + SKILL.md Step 0.2 + orchestration.md's conflict-worker all follow it, degrading to read-only on mismatch. - Push by refspec to the branch's CONFIGURED UPSTREAM — `git push "$(git config --get branch.<headRefName>.remote)" HEAD:<headRefName>` — which resolves to origin for a same-repo head and to the fork's remote for a write-allowed cross-repo (in-owner fork) head. Never hardcode origin: for a fork head that writes a same-named branch on the base repo instead of the fork head. Fast-forward given the head assertion, never force. - Freshness is now MERGE-ONLY. The prior loop.md rebase + --force-with-lease path violated the skill's own never-force-push invariant (safety.md "Never Do Automatically", orchestration.md) and was the silent-revert vector. Behavior change: linear-history branches now carry an interim merge commit during freshness instead of being rebased (the final squash merge still flattens history). evals.json id 6 updated to the merge-only + upstream refspec contract. Enforcement remains agent discipline; a deterministic push-safety guard is tracked in #885. Per-plugin bump 0.15.9 -> 0.15.10 (patch) + CHANGELOG. Closes #548 Refs #885 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
|
Claude finished @kyle-sexton's task in 3m 55s —— View job PR Review
The fix addresses a genuine and well-characterized hazard. The Finding 1 — HIGH:
|
|
Claude finished @kyle-sexton's task in 4m 16s —— View job Security Review — PR #895
All changes in this PR are prose/documentation (markdown + JSON). No GitHub Actions workflows, shell scripts, or executable code are modified. Findings are about security properties of the instructions given to AI agents — the agent treats them as a behavioral specification, so logic gaps here are as real as bugs in code. IMPORTANT —
|
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: f471b63640
ℹ️ 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".
#882 (source-control) merged into main at 0.16.0, which this branch's prior 0.16.1 was already one past — resolved the resulting plugin.json/CHANGELOG.md conflict, kept as-is. Full fresh collision sweep after the merge found source-control now carries TWO additional open PRs beyond #840: #895 (fix/548-babysit-worktree-head-safety, claims 0.15.10) and #898 (feat/399-shared-worktree-helper, claims 0.17.0 — a minor bump above this branch's prior 0.16.1). Re-bumped source-control to 0.17.1 to stay ahead of all three (#898's 0.17.0, #895's and #840's 0.15.10). claude-ops (0.17.5, no collision), work-items (0.20.1, held behind #861's 0.20.0), repo-hygiene (0.4.6, no collision), and guardrails (0.9.6, no collision) re-verified against current main and all live open PRs — unchanged. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01K1V3gkrfSf75isB8MiDy3o
Compose both intents on the version-bump + CHANGELOG collision: - plugin.json: 0.16.1 — one patch increment past main's current 0.16.0 (this branch's #548 change is Fixed-only, so patch, not minor). - CHANGELOG: renumber the #548 entry [0.15.10] -> [0.16.1] and stack it above main's [0.16.0] (#511) entry; both entries preserved, descending order. SKILL.md auto-merged both sides cleanly (main's #511 self-comment suppression rework + this branch's Step 0.2 / cross-tier merge-only invariants); verified both intents survive without contradiction. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…ush paths Resolve $PUSH_REMOTE once at full-mode entry so the freshness, conflict-continue, and fix-cycle push paths share one destination, and make it fail closed: a fork head whose remote cannot be validated (e.g. a --detach checkout with no branch config) degrades to read-only instead of defaulting to origin — origin is the base repo, so that fallback silently wrote a same-named branch on base rather than updating the fork head, the exact cross-repo regression this branch guards. Addresses three codex review findings on the loop.md push contract: - fail-open origin fallback for an unresolved fork remote; - PUSH_REMOTE left unset on the <=3-file conflict-continue push; - plain `git push` in the fix cycle, rejected from a detached HEAD (now refspec). safety.md's Checkout And Push Invariants aligned to the same fail-closed contract. Refs #548 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
|
Claude finished @kyle-sexton's task in 2m 42s —— View job PR Review — Follow-up Pass (commit
|
|
Claude finished @kyle-sexton's task in 3m 38s —— View job Security Review — PR #895 (head: 1d7a992)
All changes are prose/documentation (markdown + JSON). Security findings apply to the behavioral spec these documents encode for AI agents — logic gaps in agent instructions are as real as code bugs. Status of Prior Review FindingsCommit 1d7a992 ("fail-closed push-remote resolution") directly addresses the
IMPORTANT — SKILL.md Step 0.3 still says "merge vs rebase per the branch's own history"Confidence: HIGH The cross-tier invariants block (updated by this PR, line 117–118) now reads:
But the per-PR checklist Step 0.3 that workers execute step-by-step still reads:
These two statements in the same file directly contradict each other. An agent following the checklist literally still sees "merge vs rebase" as valid and may choose to rebase, which is the silent-revert vector the PR is designed to close. Commit 1d7a992 updated every other mention but left Step 0.3 untouched. Fix: Change the Step 0.3 parenthetical to SUGGESTION — Unescaped
|
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 1d7a992dc6
ℹ️ 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".
…ail-closed push remote Extend the fail-closed push-remote resolution to the sibling contracts that document the same invariant, so no tier is left with the fail-open origin fallback: - orchestration.md conflict-worker: push via the fail-closed $PUSH_REMOTE (points at safety.md as the canonical contract) instead of the bare `git config --get branch.<h>.remote` that defaulted to origin. - safety.md: name the decision mechanism — same-repo vs fork is decided from `gh pr view --json isCrossRepository`, never inferred from whether git config resolves — closing the re-implementation gap that could reintroduce the root-cause pattern. Refs #548 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
|
Claude finished @kyle-sexton's task in 2m 57s —— View job PR Review — Follow-up Pass (commit
|
|
Claude finished @kyle-sexton's task in 2m 50s —— View job Security Review — PR #895 (head:
|
| Finding | Status |
|---|---|
IMPORTANT — || echo origin sends fork-in-detach push to base repo |
✅ FIXED (1d7a992) |
SUGGESTION — unescaped $BRANCH in sibling-worktree grep regex |
❌ Not fixed |
| PR Review Finding 1 (HIGH) — SKILL.md Step 0.3 "merge vs rebase" contradicts cross-tier invariants | ❌ Not fixed |
PR Review Finding 2 (MEDIUM) — §5.1.4 fix-cycle git push fails in detached HEAD |
✅ FIXED (1d7a992) |
PR Review Finding 3 (LOW) — grammar/orphaned clause in safety.md |
✅ FIXED (1d7a992) |
Codex P2 — PUSH_REMOTE uninitialized for conflict push |
✅ FIXED (1d7a992) |
IMPORTANT — SKILL.md Step 0.3 still contradicts the merge-only invariant
Confidence: HIGH
The cross-tier invariants block (line 117–118, updated by this PR) now reads:
never a force-push (freshness is merge-only, refspec-pushed fast-forward — reference/loop.md)
Every other location in the PR — loop.md, orchestration.md, safety.md, and the evals.json eval — now asserts merge-only. But the per-PR checklist Step 0.3 that workers execute step-by-step still reads:
- [ ] **Step 0.3 — Branch freshness:** fetch + `git merge-base --is-ancestor`; integrate
(merge vs rebase per the branch's own history), graduated conflict handling (§5.1.2)
This is a direct in-file self-contradiction. The cross-tier invariants are normative, but Step 0.3 is what a worker executes literally. An agent following the checklist sees "merge vs rebase" as a valid choice and can rebase — rewriting the branch's commit history, requiring a --force-with-lease or --force push, and opening the exact silent-revert vector this PR was written to close. 180793c updated orchestration.md but did not touch this line.
Fix: Change the parenthetical on line 343 from (merge vs rebase per the branch's own history) to (merge-only — never rebase, §5.1.2).
SUGGESTION — Unescaped $BRANCH in sibling-worktree grep (unchanged from prior pass)
Confidence: HIGH
elif git worktree list | grep -vF "$CUR_WT " | grep -q "\[$BRANCH\]"; then$BRANCH is the PR's headRefName, interpolated unescaped into a basic-regex pattern. A branch name containing ., *, or [ causes metacharacter mismatches; feat/my.thing makes . match any character, creating a potential false-positive that routes into the --detach path when the branch is not actually locked. Practical blast radius is contained by the HEAD assertion immediately after ([ "$(git rev-parse HEAD)" = "$PR_HEAD" ]), which degrades to read-only on mismatch — so no unauthorized mutation occurs.
Fix: grep -qF "[$BRANCH]" (fixed-string match, brackets matched literally).
Findings confirmed fixed by this PR
|| echo originfork-push regression (1d7a992) — theisCrossRepositorygate with|| truefallback andcase ""|.|origin) → read-onlycorrectly blocks the cross-repo write in the sibling-locked fork path. The fail-closed logic covers the detached-HEAD case where no branch config exists.- Fix-cycle
git pushfails in detached HEAD (1d7a992) — §5.1.4 now usesgit push "$PUSH_REMOTE" HEAD:$BRANCH. PUSH_REMOTEuninitialized for conflict push (1d7a992) — pre-resolved once before all three push sites.- No new attack surfaces in
180793c— the final commit only updatesorchestration.mdprose: replacing an inlinegit configexpansion with$PUSH_REMOTEand adding the fail-closed description. No new logic paths, no new shell invocations, no new API calls.
What looks good in the full PR
- The
isCrossRepositorygate design: same-repo heads unconditionally getorigin; fork heads must resolve a remote fromgit config --get "branch.$BRANCH.remote", with empty/./originall routing to read-only. Fail-closed on API failure (empty result ≠"false"→ takes the fork path → read-only if no branch config). PR_HEAD=$(gh pr view "$PR_NUMBER" --json headRefOid -q .headRefOid)failure (empty$PR_HEAD) correctly causes all post-checkout HEAD comparisons to return false → read-only. Fail-closed.safety.mdCheckout And Push Invariants — the prose is now internally consistent and correctly covers detached HEAD, stale local tip, and fork remote resolution. The explicit "stop (read-only) instead" clause closes the earlier gap.orchestration.mdconflict-worker — now correctly references$PUSH_REMOTEand delegates the resolution logic to safety.md rather than duplicating it inline with a potentially stale copy.
Bottom line: One IMPORTANT issue blocks clean merge — SKILL.md Step 0.3 line 343 still says "merge vs rebase per the branch's own history," directly contradicting the merge-only invariant now enforced everywhere else. It is a one-word parenthetical change. All other findings from prior review rounds are resolved. 180793c introduced no new security issues.
…d-only D1-D5 Two top-level checklist lines drifted from the contract this branch establishes: - SKILL.md Step 0.3 still said "merge vs rebase per the branch's own history", contradicting the merge-only freshness (§5.1.2) and re-admitting the rebase + force-push path this branch forbids; now states merge-only, never rebase. - loop.md §5.1.3 gated the whole per-finding D-cycle behind a full-mode checkout, which would skip D1-D5 (investigate/classify/reply) in the read-only safety-fallback cases where the contract still runs them; now requires only a HEAD-asserted checkout and scopes full mode to the D6-D7 fix cycle. Refs #548 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
|
Claude finished @kyle-sexton's task in 1m 37s —— View job PR Review — Follow-up Pass (commit
|
| Finding | Round | Status |
|---|---|---|
IMPORTANT — || echo origin fallback sends fork-in-detach push to base repo |
Security r1 | ✅ Fixed 1d7a992 |
Finding 2 (HIGH) — §5.1.4 fix-cycle git push fails in detached HEAD |
PR r1 | ✅ Fixed 1d7a992 |
Finding 3 (LOW) — safety.md grammar/orphaned clause |
PR r1 | ✅ Fixed 1d7a992 |
Codex P2 — PUSH_REMOTE uninitialized for conflict push |
Codex | ✅ Fixed 1d7a992 |
orchestration.md conflict-worker uses inline git config instead of $PUSH_REMOTE |
Codex P2 r2 | ✅ Fixed 180793c |
safety.md PUSH_REMOTE authority + fail-closed description |
PR r3 | ✅ Fixed 180793c |
| Finding 1 (HIGH/IMPORTANT) — SKILL.md Step 0.3 says "merge vs rebase" | PR r1 / Security r1–r3 | ✅ Fixed d2b62d6 |
| LOW — §5.1.3 preamble ambiguity ("full-mode" excludes D1-D5 in read-only) | PR r3 | ✅ Fixed d2b62d6 |
SUGGESTION — unescaped $BRANCH in sibling-worktree grep |
Security r1–r3 | ❌ Still present (non-blocking) |
Minor — isCrossRepository API failure causes silent read-only for same-repo PRs |
PR r3 | ❌ Still present (non-blocking) |
Remaining non-blocking items
SUGGESTION (unchanged) — unescaped $BRANCH in sibling-worktree grep
elif git worktree list | grep -vF "$CUR_WT " | grep -q "\[$BRANCH\]"; then$BRANCH content is unescaped basic-regex; a . in a branch name could match the wrong worktree. The HEAD assertion immediately after degrades to read-only on mismatch — no write occurs on a false positive. Not a blocker; grep -qF "[$BRANCH]" would fix it.
Bottom line
No blockers remain. All HIGH and IMPORTANT findings from all prior review rounds are resolved. The SKILL.md Step 0.3 fix — the last remaining blocker flagged three times across two reviewers — is closed by d2b62d6. The only open items are a low-severity regex suggestion and a minor diagnostic observation, both confirmed non-blocking in the prior rounds.
The PR is ready to merge, subject to the ordering note in the PR description: if #882 merges first, re-bump above 0.16.0 (→ 0.16.1) during recompose.
|
Claude finished @kyle-sexton's task in 2m 17s —— View job Security Review — PR #895 (head:
|
| Finding | Status |
|---|---|
IMPORTANT — || echo origin fallback sends fork-in-detach push to base repo |
✅ FIXED (1d7a992) |
| IMPORTANT — SKILL.md Step 0.3 "merge vs rebase" contradicts cross-tier invariants | ✅ FIXED (d2b62d6) |
MEDIUM — §5.1.4 fix-cycle git push fails in detached HEAD |
✅ FIXED (1d7a992) |
LOW — safety.md grammar / orphaned clause |
✅ FIXED (1d7a992) |
Codex P2 — PUSH_REMOTE uninitialized for conflict push |
✅ FIXED (1d7a992) |
| Codex P2 — orchestration.md conflict-worker inline push remote | ✅ FIXED (180793c) |
| Codex P2 — §5.1.3 "full-mode" prerequisite blocks D1-D5 in read-only | ✅ FIXED (d2b62d6) |
SUGGESTION — unescaped $BRANCH in sibling-worktree grep |
❌ Not fixed (see below) |
What d2b62d6 fixes
SKILL.md Step 0.3 — SKILL.md:343
The parenthetical now reads merge-only (never rebase — rebasing a PR branch needs a forbidden force-push). The self-contradiction that existed through every prior review round is closed. The per-PR checklist step that workers execute literally now matches the cross-tier invariants, loop.md, orchestration.md, and safety.md.
loop.md §5.1.3 preamble — loop.md:279-282
"Must hold a full-mode checkout" → "Must hold a HEAD-asserted checkout" with explicit "Read-only mode still runs D1-D5 (investigate/classify/reply); only the D6-D7 fix cycle requires full mode." The ambiguity that would have caused agents to skip D1-D5 investigation in the exact safety-fallback cases where investigation still has value is resolved. No new logic paths; no new shell invocations.
Remaining — SUGGESTION (unchanged from prior passes)
Unescaped $BRANCH in sibling-worktree grep
elif git worktree list | grep -vF "$CUR_WT " | grep -q "\[$BRANCH\]"; then$BRANCH (the PR's headRefName) is interpolated as unescaped basic-regex. A branch name containing . makes . match any character; feat/my.thing would match [feat/myXthing] — a potential false positive routing into the --detach path when the branch is not actually locked. The HEAD assertion immediately after provides a safety net: [ "$(git rev-parse HEAD)" = "$PR_HEAD" ] degrades to read-only on any mismatch, so no unauthorized mutation occurs from a false-positive sibling-detection. The fail-closed PUSH_REMOTE block also catches the fork-in-detach case if the grep misfires. Not blocking.
Fix when convenient: grep -qF "[$BRANCH]" (fixed-string, brackets matched literally).
What looks correct across the full PR
- Checkout ordering — dirty-tree guard fires first (read-only before any checkout attempt), then HEAD == PR_HEAD (already current), then sibling-locked (
--detach), then heal-via-checkout. Fail-closed at each branch. - PUSH_REMOTE resolution —
isCrossRepository=false→ hardcodedorigin; fork head →git config --get "branch.$BRANCH.remote" || truewithcase ""|.|origin) → read-only;isCrossRepositoryquery failure (empty result) takes the fork/conservative path → read-only when no branch config. Fail-closed at every exit. - All three push sites (freshness merge, conflict-continue, fix-cycle D6) consume the same pre-resolved
$PUSH_REMOTE. No site reintroduces the inlinegit configexpansion. PR_HEADfailure path — ifgh pr view --json headRefOidreturns empty, all subsequent HEAD comparisons return false → read-only. Fail-closed.- No GitHub Actions changes — no
pull_request_target,workflow_run,run:injection surfaces, or permission-widening changes. - evals.json id 6 — correctly updated to test merge-only + upstream-refspec push behavior; expectations no longer reference the old merge-commit detection heuristic.
Bottom line: d2b62d6 closes the last IMPORTANT gap (SKILL.md Step 0.3 self-contradiction and §5.1.3 read-only D1-D5 ambiguity). All IMPORTANT and CRITICAL findings across four review rounds are now resolved. The one SUGGESTION (unescaped branch name in the sibling-worktree grep) is low blast-radius and acceptable to merge with open. No new security issues were introduced by any commit in this PR.
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: d2b62d6b05
ℹ️ 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".
…-HEAD support This branch adds sibling-locked `gh pr checkout --detach` full-mode support, but several contracts still assumed an attached checkout, so a worker on a valid detached HEAD (== the true PR head) would wrongly stop or skip the fix cycle in exactly the sibling-locked case this branch enables: - orchestration.md conflict worker: "refuse a stale or detached tip" now reads "stale or head-mismatched tip" (a detached HEAD equal to the head is fine). - loop.md §5.1.4 gate and the full-mode / wrong-branch guards now key on the HEAD-at-true-head assertion (attached or detached) instead of branch attachment. - SKILL.md Step 5 push clarified as a refspec push that works from detached HEAD. Refs #548 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
|
Claude finished @kyle-sexton's task in 2m 6s —— View job PR Review — Follow-up Pass (commit
|
| Finding | Status |
|---|---|
IMPORTANT — || echo origin fallback sends fork-in-detach push to base repo |
✅ Fixed 1d7a992 |
HIGH — §5.1.4 fix-cycle git push fails in detached HEAD |
✅ Fixed 1d7a992 |
LOW — safety.md grammar / orphaned clause |
✅ Fixed 1d7a992 |
Codex P2 — PUSH_REMOTE uninitialized for conflict push |
✅ Fixed 1d7a992 |
| orchestration.md conflict-worker inline push remote | ✅ Fixed 180793c |
safety.md PUSH_REMOTE authority + fail-closed description |
✅ Fixed 180793c |
| HIGH/IMPORTANT — SKILL.md Step 0.3 says "merge vs rebase" | ✅ Fixed d2b62d6 |
| LOW — §5.1.3 "full-mode" prerequisite blocks D1-D5 in read-only | ✅ Fixed d2b62d6 |
| Codex P2 — conflict-worker rejects asserted detached HEAD | ✅ Fixed 33f1fbe |
| Codex P2 — fix cycle gated on branch attachment instead of full mode | ✅ Fixed 33f1fbe |
SUGGESTION — unescaped $BRANCH in sibling-worktree grep |
❌ Not fixed (non-blocking) |
Remaining — SUGGESTION (unchanged, non-blocking)
elif git worktree list | grep -vF "$CUR_WT " | grep -q "\[$BRANCH\]"; then$BRANCH is interpolated as unescaped basic-regex; a . in a branch name could match a wrong worktree. The HEAD assertion on line 165 degrades to read-only on a false positive — no unauthorized write occurs. The fail-closed PUSH_REMOTE block guards the fork-in-detach case even if the grep misfires. Not a blocker; fix when convenient with grep -qF "[$BRANCH]".
No blockers remain. Every HIGH and IMPORTANT finding from all five review rounds is resolved. 33f1fbe correctly closes the last two Codex P2 findings — the conflict-worker detached-HEAD rejection and the branch-attachment gate on the fix cycle — with minimal, targeted changes and no new issues introduced.
The PR is ready to merge, subject to the ordering note in the PR description: if #882 merges first, re-bump above 0.16.0 (→ 0.16.1) during recompose.
|
Claude finished @kyle-sexton's task in 2m 27s —— View job Security Review — PR #895 (head:
|
| Finding | Status |
|---|---|
IMPORTANT — || echo origin fallback sends fork-in-detach push to base repo |
✅ FIXED (1d7a992) |
| IMPORTANT — SKILL.md Step 0.3 "merge vs rebase" contradicts cross-tier invariants | ✅ FIXED (d2b62d6) |
MEDIUM — §5.1.4 fix-cycle git push fails in detached HEAD |
✅ FIXED (1d7a992) |
LOW — safety.md grammar / orphaned clause |
✅ FIXED (1d7a992) |
Codex P2 — PUSH_REMOTE uninitialized for conflict push |
✅ FIXED (1d7a992) |
| Codex P2 — orchestration.md conflict-worker inline push remote | ✅ FIXED (180793c) |
| LOW — §5.1.3 "full-mode" prerequisite blocks D1-D5 in read-only | ✅ FIXED (d2b62d6) |
| Codex P2 — "Don't reject asserted detached conflict worktrees" | ✅ FIXED (33f1fbe) |
| Codex P2 — "Don't require a branch checkout for fix cycles" | ✅ FIXED (33f1fbe) |
SUGGESTION — unescaped $BRANCH in sibling-worktree grep |
❌ Not fixed (see below) |
What 33f1fbe fixes (security-relevant)
orchestration.md conflict-worker assertion — The prior text "refuse to resolve onto a stale or detached tip" would have blocked the legitimate sibling-locked path where gh pr checkout --detach places the worktree in detached HEAD at exactly the PR head. The new text:
refuse to resolve onto a stale or head-mismatched tip (a detached HEAD that equals the head is fine — the sibling-locked case)
correctly distinguishes the two cases. The safety property is unchanged: the assertion HEAD == gh pr view --json headRefOid must still pass before any merge or push. Only the description of what triggers a refusal is tightened. No new attack surface.
loop.md §5.1.4 fix-cycle gate — The prior "When on the PR branch AND a comment is classified VALID" excluded detached-HEAD checkouts from the fix cycle. The replacement "When in full mode (HEAD asserted at the true PR head — attached or detached per §5.1.2) AND" correctly enables fixes in the sibling-locked path. The security chain is intact:
CHECKOUT_MODE=fullis only set whengit rev-parse HEADequals the liveheadRefOid(lines 155, 165, 171 of loop.md)- For fork PRs in detached HEAD,
CHECKOUT_MODEis overridden toread-onlyby theisCrossRepositorygate (lines 186–191) because no branch config exists to validate the fork remote — so the fix cycle cannot run there - Same-repo PRs in detached HEAD have
PUSH_REMOTE=originset unconditionally and reach full mode correctly; the pushgit push origin HEAD:<headRefName>is correct
No new attack surface; this completes the sibling-locked path.
Remaining — SUGGESTION (unchanged from prior passes)
Unescaped $BRANCH in sibling-worktree grep
elif git worktree list | grep -vF "$CUR_WT " | grep -q "\[$BRANCH\]"; then$BRANCH (the PR's headRefName) is interpolated as unescaped basic-regex. A branch name containing . makes . match any character; feat/my.thing would match [feat/myXthing] — a potential false positive routing into the --detach path when the branch is not actually locked. Practical blast radius is contained by the HEAD assertion on line 165 ([ "$(git rev-parse HEAD)" = "$PR_HEAD" ]), which degrades to read-only on any mismatch — no unauthorized mutation occurs from a false-positive sibling-detection. The fail-closed PUSH_REMOTE block also catches the fork-in-detach case if the grep misfires. Not blocking.
Fix when convenient: grep -qF "[$BRANCH]" (fixed-string, brackets matched literally).
What looks correct across the full PR
- Checkout ordering — dirty-tree guard → HEAD == PR_HEAD already → sibling-locked
--detach→ heal-via-checkout. Fail-closed at each branch. - PUSH_REMOTE resolution —
isCrossRepository=false→ hardcodedorigin; fork head →git config --get "branch.$BRANCH.remote" || truewithcase ""|.|origin) → read-only;isCrossRepositoryquery failure (empty ≠"false") takes the fork/conservative path → read-only when no branch config. Fail-closed at every exit. - Fix cycle in detached HEAD for same-repo PRs — freshness merge creates a new commit on top of PR_HEAD; the push
HEAD:$BRANCHis fast-forward against the remote tip (which was at PR_HEAD). Fix-cycle commits stack on top — also fast-forward. No force-push at any point. - All three push sites — freshness merge (line 210), conflict-continue prose (line 233), fix-cycle D6 (line 324) — all consume the same pre-resolved
$PUSH_REMOTE. No site reintroduces the inlinegit configexpansion. PR_HEADfailure path — ifgh pr view --json headRefOidreturns empty, all HEAD comparisons return false → read-only. Fail-closed.- No GitHub Actions changes — no
pull_request_target,workflow_run,run:injection surfaces, or permission-widening changes.
Bottom line: 33f1fbe introduces no new security issues. It closes the two remaining Codex P2 findings from the d2b62d6 review: the conflict-worker now correctly accepts detached HEAD == PR head, and the fix cycle now correctly gates on full mode + HEAD assertion rather than branch attachment. All IMPORTANT and CRITICAL findings across all review rounds are resolved. The one SUGGESTION (unescaped branch name in the sibling-worktree grep) is low blast-radius, guarded by the HEAD assertion, and acceptable to merge with open.
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 33f1fbea8e
ℹ️ 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".
… name The full-mode PUSH_REMOTE resolution rejected only the literal remote names "", ".", and "origin" before treating a cross-repo branch.<b>.remote as a validated fork destination. Remote names are arbitrary: gh pr checkout can wire a fork branch to the base checkout remote (commonly "upstream", but any name) when it cannot create a writable fork remote, so a non-origin name could push to the BASE repo and write a same-named branch there instead of the fork head. Validate the destination by URL identity instead: resolve pushRemote or remote (each may hold a remote name OR a bare URL) to owner/repo and require it to equal the PR head repository, else degrade to read-only. Update safety.md's canonical Checkout And Push Invariants prose to match.
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: eaaa57362d
ℹ️ 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".
Resolve CHANGELOG conflict: main released 0.16.1 (guarded-wrapper bin/-path fix); this branch's head-safety entry is renumbered to 0.16.2 and stacked above it, plugin.json bumped to 0.16.2 to match.
|
Claude finished @kyle-sexton's task in 3m 23s —— View job PR Review — Follow-up Pass (commit
|
| Finding | Status |
|---|---|
IMPORTANT — || echo origin fallback sends fork-in-detach push to base repo |
✅ Fixed 1d7a992 |
HIGH — §5.1.4 fix-cycle git push fails in detached HEAD |
✅ Fixed 1d7a992 |
LOW — safety.md grammar / orphaned clause |
✅ Fixed 1d7a992 |
Codex P2 — PUSH_REMOTE uninitialized for conflict push |
✅ Fixed 1d7a992 |
| orchestration.md conflict-worker inline push remote | ✅ Fixed 180793c |
safety.md PUSH_REMOTE authority + fail-closed description |
✅ Fixed 180793c |
| HIGH/IMPORTANT — SKILL.md Step 0.3 says "merge vs rebase" | ✅ Fixed d2b62d6 |
| LOW — §5.1.3 "full-mode" prerequisite blocks D1-D5 in read-only | ✅ Fixed d2b62d6 |
| Codex P2 — conflict-worker rejects asserted detached HEAD | ✅ Fixed 33f1fbe |
| Codex P2 — fix cycle gated on branch attachment instead of full mode | ✅ Fixed 33f1fbe |
Codex P1 — fork-remote guard only rejects literal origin name |
✅ Fixed eaaa573 |
| Merge introduces no regressions in head-safety mechanics | ✅ Verified 98c35e3 |
| LOW — §5.1.6 cleanup condition excludes detached-HEAD full mode | ❌ Still open |
LOW — unescaped $BRANCH in sibling-worktree grep |
❌ Still open (all rounds) |
Bottom line: The merge commit is clean — the 0.16.1 bin-path changes integrated without touching the head-safety contract. All IMPORTANT/HIGH findings across all rounds are resolved. Two LOW findings remain open, both non-blocking: the §5.1.6 cleanup condition and the unescaped branch-name grep. The merge also correctly re-numbers this PR's version to 0.16.2, consistent with the ordering note in the PR description.
|
Claude finished @kyle-sexton's task in 4m 12s —— View job Security Review — PR #895 (head:
|
| Finding | Status |
|---|---|
IMPORTANT — || echo origin fallback sends fork-in-detach push to base repo |
✅ FIXED (1d7a992) |
| IMPORTANT — SKILL.md Step 0.3 "merge vs rebase" contradicts cross-tier invariants | ✅ FIXED (d2b62d6) |
MEDIUM — §5.1.4 fix-cycle git push fails in detached HEAD |
✅ FIXED (1d7a992) |
LOW — safety.md grammar / orphaned clause |
✅ FIXED (1d7a992) |
Codex P2 — PUSH_REMOTE uninitialized for conflict push |
✅ FIXED (1d7a992) |
| Codex P2 — orchestration.md conflict-worker inline push remote | ✅ FIXED (180793c) |
| LOW — §5.1.3 "full-mode" prerequisite blocks D1-D5 in read-only | ✅ FIXED (d2b62d6) |
| Codex P2 — conflict-worker rejects asserted detached HEAD | ✅ FIXED (33f1fbe) |
| Codex P2 — fix cycle gated on branch attachment instead of full mode | ✅ FIXED (33f1fbe) |
IMPORTANT — fork-remote guard accepted base-repo remote by non-origin name |
✅ FIXED (eaaa573) |
SUGGESTION — unescaped $BRANCH in sibling-worktree grep |
❌ Still open (see below) |
SUGGESTION — Unescaped $BRANCH in sibling-worktree grep (unchanged from all prior passes)
Confidence: HIGH
elif git worktree list | grep -vF "$CUR_WT " | grep -q "\[$BRANCH\]"; then$BRANCH (the PR's headRefName) is interpolated as unescaped basic-regex. A branch name containing . makes . match any character; feat/my.thing would match [feat/myXthing] — a false positive routing into the --detach path when the branch is not actually locked. The HEAD assertion on line 165 degrades to read-only on a mismatch, so no unauthorized mutation occurs from a false-positive sibling-detection. The fail-closed URL-validated PUSH_REMOTE block also guards the fork-in-detach case if the grep misfires. Not blocking.
Fix when convenient: grep -qF "[$BRANCH]" (fixed-string match; brackets are also matched literally under -F).
LOW — §5.1.6 cleanup gate excludes detached-HEAD full-mode path (Codex P2, previously unresolved)
Confidence: HIGH
The PR-transition cleanup reads:
- If on a PR branch with uncommitted changes from a failed fix:
git reset --hard HEADthengit clean -fd
The fix cycle (§5.1.4) was updated by 33f1fbe to gate on CHECKOUT_MODE=full regardless of branch attachment — so a sibling-locked --detach checkout can now run D6-D7. But §5.1.6 still conditions cleanup on "on a PR branch." An agent following it literally skips cleanup when in detached HEAD (the sibling-locked path), even if a D6 edit was left uncommitted due to a failed fix. The dirty state then bleeds into the next PR's §5.1.2 dirty-tree guard (git status --porcelain), which correctly forces that next PR to read-only — but the cause ("leftover edits from previous PR's failed fix") is opaque, and the dirty worktree persists until the agent eventually returns to the parking branch.
git reset --hard HEAD and git clean -fd both work correctly in detached HEAD, so the fix is only in the condition wording.
Scenario: Sibling-locked PR → gh pr checkout --detach → CHECKOUT_MODE=full → D6 edit begins → commit fails → §5.1.6 transition fires → "if on a PR branch" is false (detached HEAD) → cleanup skipped → next PR's dirty-tree check forces read-only with no explanation.
Fix: change the condition to apply to any full-mode checkout:
- If in full mode with uncommitted changes from a failed fix (attached or detached —
git status --porcelainis non-empty):git reset --hard HEADthengit clean -fd
LOW — §5.2 parking: git rev-parse --abbrev-ref HEAD returns "HEAD" in detached mode
Confidence: HIGH
PARKING_BRANCH=$(git rev-parse --abbrev-ref HEAD)
...
git checkout "$PARKING_BRANCH"git rev-parse --abbrev-ref HEAD outputs the literal string HEAD in detached mode, so git checkout HEAD re-checks-out the current commit — a no-op. After processing a sibling-locked PR, the worktree stays detached at the PR's last merged head commit rather than returning to the parking branch. The next babysit iteration starts in detached HEAD; the dirty-tree guard fires clean, the HEAD == PR_HEAD check may match a now-stale commit, and the sibling-worktree detection may or may not fire depending on the branch state — unpredictable rather than systematically wrong, but the parking invariant is broken.
Scenario: parking branch is main → sibling-locked PR processed in detached HEAD → all PRs done → PARKING_BRANCH="HEAD" → git checkout HEAD no-op → next session starts detached at old PR head → first HEAD assertion compares against a different PR → falls to read-only.
Fix: capture the parking ref as a SHA when in detached HEAD, or record the home branch before the first PR is processed rather than assuming the initial HEAD is named:
PARKING_BRANCH=$(git rev-parse --abbrev-ref HEAD)
[ "$PARKING_BRANCH" = "HEAD" ] && PARKING_BRANCH=$(git rev-parse HEAD) # detached: use SHANot a blocker; the failure mode is conservative (next session's head assertion fires read-only), not write-permissive.
What looks correct across the full PR (unchanged from prior passes)
isCrossRepositorygate: same-repo heads unconditionally getorigin; fork heads must resolve viabranch.<b>.pushRemote/branch.<b>.remote, and the URL is extracted and compared against the PR head repo'snameWithOwner. Empty/unresolvable → read-only.isCrossRepositoryquery failure (empty ≠"false") → takes fork/conservative path → read-only when no branch config. Fail-closed.- URL slug extraction (
sed -E 's#^[^/]+://[^/]+/##; s#^[^@]+@[^:]+:##; s#\.git$##'): handles HTTPS, SSH-protocol, and SCP-style URLs; unrecognized URL formats fail to extract a slug that matchesHEAD_REPOand fall to read-only. Fail-closed. PR_HEADempty guard: ifgh pr view --json headRefOidreturns empty, all HEAD comparisons return false → every path degrades to read-only. Fail-closed.- Force-push removal: the
--force-with-leaserebase path is gone; the replacement (merge + fast-forward refspec push) is strictly weaker in privilege and correct by construction. - All three push sites (freshness merge, conflict-continue, fix-cycle D6) consume the same pre-resolved
$PUSH_REMOTE. No site reintroduces the inlinegit configexpansion or the|| echo originfallback. - No GitHub Actions changes — no
pull_request_target,workflow_run,run:injection surfaces, or permission-widening changes.
Bottom line: No new CRITICAL or IMPORTANT findings. The two remaining open items from prior rounds — the unescaped sibling-worktree grep (SUGGESTION) and the §5.1.6 cleanup gate that excludes detached-HEAD full-mode (LOW/Codex P2) — persist unchanged. Both are conservative-failure: a false positive on the grep degrades to read-only; a missed cleanup forces the next PR to read-only. Neither enables unauthorized writes. A third LOW (§5.2 parking in detached mode) is a functional regression introduced by enabling detached-HEAD full mode without updating the parking capture. All are small, targeted text fixes.
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 98c35e3e44
ℹ️ 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".
…AD checkouts The fix cycle runs in full mode from either an attached PR branch or a detached HEAD (sibling-locked branch reused via `gh pr checkout --detach`), but the transition cleanup and the never-leave-dirty invariant were still gated on being "on a PR branch." A detached worker that left uncommitted edits from a failed fix would skip the reset/clean and carry a dirty worktree into parking or the next PR. Both now cover any full-mode PR checkout, attached or detached.
|
Claude finished @kyle-sexton's task in 3m 12s —— View job PR Review — Follow-up Pass (commit
|
| Finding | Status |
|---|---|
IMPORTANT — || echo origin fallback sends fork-in-detach push to base repo |
✅ Fixed 1d7a992 |
HIGH — §5.1.4 fix-cycle git push fails in detached HEAD |
✅ Fixed 1d7a992 |
LOW — safety.md grammar / orphaned clause |
✅ Fixed 1d7a992 |
Codex P2 — PUSH_REMOTE uninitialized for conflict push |
✅ Fixed 1d7a992 |
| orchestration.md conflict-worker inline push remote | ✅ Fixed 180793c |
safety.md PUSH_REMOTE authority + fail-closed description |
✅ Fixed 180793c |
| HIGH/IMPORTANT — SKILL.md Step 0.3 says "merge vs rebase" | ✅ Fixed d2b62d6 |
| LOW — §5.1.3 "full-mode" prerequisite blocks D1-D5 in read-only | ✅ Fixed d2b62d6 |
| Codex P2 — conflict-worker rejects asserted detached HEAD | ✅ Fixed 33f1fbe |
| Codex P2 — fix cycle gated on branch attachment instead of full mode | ✅ Fixed 33f1fbe |
Codex P1 — fork-remote guard accepts base-repo remote by non-origin name |
✅ Fixed eaaa573 |
| Merge introduces no regressions | ✅ Verified 98c35e3 |
| LOW/Codex P2 — §5.1.6 cleanup condition excludes detached-HEAD full mode | ✅ Fixed 635d5d4 |
| LOW/Codex P2 — §5.4 NEVER-do list: "PR branch" excludes detached mode | ✅ Fixed 635d5d4 |
LOW — Codex P1: git remote get-url validates fetch URL, not push URL |
❌ Still open |
| LOW — §5.2 parking in detached mode | ❌ Still open (non-blocking) |
SUGGESTION — unescaped $BRANCH in sibling-worktree grep |
❌ Still open (non-blocking) |
No blocking issues remain. 635d5d4 cleanly fixes the last blocking Codex P2 (§5.1.6 cleanup gate + NEVER-do list) with two focused rewrites. The fix is mechanically correct — git reset --hard HEAD and git clean -fd work in detached HEAD mode.
Three non-blocking items persist: the fetch-vs-push-URL gap (LOW, practical impact limited to manually-configured pushurl), the parking detached-mode issue (LOW, next iteration degrades to read-only), and the unescaped branch grep (SUGGESTION, guarded by HEAD assertion). None of these enable unauthorized writes.
The PR is ready to merge, subject to the ordering note: if #882 merges first, re-bump above 0.16.0 (→ 0.16.1) during recompose; if the merge at 98c35e3 already rebased above that, keep the current version.
|
Claude finished @kyle-sexton's task in 3m 42s —— View job Security Review — PR #895 (head:
|
| Finding | Status |
|---|---|
IMPORTANT — || echo origin fallback sends fork-in-detach push to base repo |
✅ FIXED (1d7a992) |
| IMPORTANT — SKILL.md Step 0.3 "merge vs rebase" contradicts cross-tier invariants | ✅ FIXED (d2b62d6) |
MEDIUM — §5.1.4 fix-cycle git push fails in detached HEAD |
✅ FIXED (1d7a992) |
LOW — safety.md grammar / orphaned clause |
✅ FIXED (1d7a992) |
Codex P2 — PUSH_REMOTE uninitialized for conflict push |
✅ FIXED (1d7a992) |
| Codex P2 — orchestration.md conflict-worker inline push remote | ✅ FIXED (180793c) |
| LOW — §5.1.3 "full-mode" prerequisite blocks D1-D5 in read-only | ✅ FIXED (d2b62d6) |
| Codex P2 — conflict-worker rejects asserted detached HEAD | ✅ FIXED (33f1fbe) |
| Codex P2 — fix cycle gated on branch attachment instead of full mode | ✅ FIXED (33f1fbe) |
IMPORTANT — fork-remote guard accepted base-repo remote by non-origin name |
✅ FIXED (eaaa573) |
| Codex P2 — §5.1.6 cleanup gate excludes detached-HEAD full-mode | ✅ FIXED (635d5d4) |
SUGGESTION — unescaped $BRANCH in sibling-worktree grep |
❌ Still open |
Codex P1 (98c35e3) — git remote get-url without --push validates fetch URL, not push URL |
❌ Still open |
LOW — §5.2 parking: git rev-parse --abbrev-ref HEAD returns "HEAD" in detached mode |
❌ Still open |
IMPORTANT — git remote get-url validates the fetch URL, not the URL git push will use
Confidence: HIGH
REMOTE_URL=$(git remote get-url "$PUSH_REMOTE" 2>/dev/null || printf '%s' "$PUSH_REMOTE")git remote get-url <name> returns remote.<name>.url — the fetch URL. When remote.<name>.pushurl is configured, git push uses that value instead; git remote get-url without --push never reads it. The validation can therefore pass (fetch URL matches the PR head repo) while the actual push goes to a different destination (whatever pushurl resolves to).
git remote get-url --push <name> returns the push URL — remote.<name>.pushurl if set, otherwise falls back to remote.<name>.url if unset. The fix is a one-word flag addition.
Scenario: An in-owner fork PR is checked out via gh pr checkout. For some reason (manual config, a prior git remote set-url --push, or an edge case in gh pr checkout's own remote wiring), the remote ends up with:
remote.<name>.url=https://github.com/fork-owner/fork-repo.git→ slugfork-owner/fork-repomatchesHEAD_REPO→ validation passes,CHECKOUT_MODEstaysfullremote.<name>.pushurl=https://github.com/base-owner/base-repo.git→git push "$PUSH_REMOTE" HEAD:$BRANCHwrites to the base repo
The entire URL-validation fix introduced in eaaa573 — and described in both loop.md's inline comment and safety.md's Checkout And Push Invariants — explicitly claims to validate "the URL git push will actually use." The implementation uses the wrong command to get that URL, meaning the guarantee the spec claims to provide is not actually delivered.
Fix: Change line 194 to use --push:
REMOTE_URL=$(git remote get-url --push "$PUSH_REMOTE" 2>/dev/null || printf '%s' "$PUSH_REMOTE")git remote get-url --push still falls back to the fetch URL when no pushurl is configured, so this change is backwards-compatible for the common case while correctly validating the push destination when pushurl diverges.
SUGGESTION — Unescaped $BRANCH in sibling-worktree grep (unchanged from all prior passes)
Confidence: HIGH
elif git worktree list | grep -vF "$CUR_WT " | grep -q "\[$BRANCH\]"; then$BRANCH is interpolated as unescaped basic-regex; a branch name containing . (common in semver branch names like release/1.2) makes . match any character, creating a false-positive that routes into the --detach path when the branch is not actually sibling-locked. The HEAD assertion on line 165 degrades to read-only on mismatch — no unauthorized mutation occurs. Not blocking.
Fix: grep -qF "[$BRANCH]" (fixed-string; square brackets are also literal under -F).
LOW — §5.2 parking: git rev-parse --abbrev-ref HEAD returns "HEAD" in detached mode (unchanged from prior pass)
Confidence: HIGH
PARKING_BRANCH=$(git rev-parse --abbrev-ref HEAD)If a babysit session starts from a detached-HEAD worktree, PARKING_BRANCH captures the literal string "HEAD". After all PRs are processed, git checkout "HEAD" is a no-op — the worktree stays detached at whatever commit the final PR left it on. The next session's dirty-tree guard fires clean, but the HEAD assertion compares against a different PR's head and likely degrades to read-only. The failure mode is conservative (no unauthorized write), but the parking invariant is broken and the cause is opaque to the operator. Not blocking.
Fix: capture the parking ref as a SHA in detached mode:
PARKING_BRANCH=$(git rev-parse --abbrev-ref HEAD)
[ "$PARKING_BRANCH" = "HEAD" ] && PARKING_BRANCH=$(git rev-parse HEAD)What looks correct in the full PR
635d5d4§5.1.6 cleanup —git reset --hard HEADandgit clean -fdwork correctly in detached HEAD; extending the condition to "full mode (attached or detached)" is the right fix. The §5.4 NEVER-do alignment is consistent.isCrossRepositorygate — same-repo heads unconditionally getorigin; the detached fork path degrades to read-only (no branch config → emptyPUSH_REMOTE→casegate →read-only). Fail-closed on API failure (empty ≠"false"→ fork path → read-only).PR_HEADempty guard — ifgh pr view --json headRefOidreturns empty, all HEAD comparisons return false → read-only throughout. Fail-closed.- Force-push removal — the
--force-with-leaserebase path is gone; the replacement (merge + fast-forward refspec push) is strictly weaker in privilege. - All three push sites (freshness merge, conflict-continue, fix-cycle D6) consume the same pre-resolved
$PUSH_REMOTE. No site reintroduces the|| echo originfallback. - No GitHub Actions changes — no
pull_request_target,workflow_run,run:, or permission-widening changes.
Bottom line: 635d5d4 correctly closes the §5.1.6 dirty-worktree gap (the Codex P2 from the 98c35e3 round). One IMPORTANT gap remains open: git remote get-url without --push validates the fetch URL rather than the URL git push actually uses — a one-word flag addition (--push) closes it. The SUGGESTION (unescaped branch grep) and LOW (parking in detached mode) are unchanged from prior rounds and remain non-blocking.
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 635d5d4784
ℹ️ 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".
The round-1 URL-identity gate closed the name-based fail-open but left two holes in the full-mode PUSH_REMOTE validation: - Host was stripped: reducing both sides to owner/repo ignored the host, so a remote on a different host with the same path (e.g. git@evil.example.com:owner/claude-code-plugins) matched the head repo and passed. Canonicalize to host + owner/repo via a shared repo_id() and compare the head repo's authoritative html_url (gh api repos/<nameWithOwner>) — since headRepository exposes no URL — so both host and path must match. - Fetch URL was validated, push URL was used: git remote get-url returns the fetch URL, but git push honors remote.<name>.pushurl. A remote whose fetch URL is a real fork but whose pushurl targets the base repo passed, then pushed to base. Validate git remote get-url --push, the address git push actually writes. safety.md's Checkout And Push Invariants prose updated in lockstep.
|
Claude finished @kyle-sexton's task in 3m 21s —— View job PR Review — Follow-up Pass (commit
|
| Finding | Status |
|---|---|
IMPORTANT — || echo origin fallback sends fork-in-detach push to base repo |
✅ Fixed 1d7a992 |
HIGH — §5.1.4 fix-cycle git push fails in detached HEAD |
✅ Fixed 1d7a992 |
LOW — safety.md grammar / orphaned clause |
✅ Fixed 1d7a992 |
Codex P2 — PUSH_REMOTE uninitialized for conflict push |
✅ Fixed 1d7a992 |
| orchestration.md conflict-worker inline push remote | ✅ Fixed 180793c |
safety.md PUSH_REMOTE authority + fail-closed description |
✅ Fixed 180793c |
| HIGH/IMPORTANT — SKILL.md Step 0.3 says "merge vs rebase" | ✅ Fixed d2b62d6 |
| LOW — §5.1.3 "full-mode" prerequisite blocks D1-D5 in read-only | ✅ Fixed d2b62d6 |
| Codex P2 — conflict-worker rejects asserted detached HEAD | ✅ Fixed 33f1fbe |
| Codex P2 — fix cycle gated on branch attachment instead of full mode | ✅ Fixed 33f1fbe |
Codex P1 — fork-remote guard accepts base-repo remote by non-origin name |
✅ Fixed eaaa573 |
| Merge introduces no regressions | ✅ Verified 98c35e3 |
| LOW/Codex P2 — §5.1.6 cleanup condition excludes detached-HEAD full mode | ✅ Fixed 635d5d4 |
IMPORTANT — git remote get-url validates fetch URL, not push URL |
✅ Fixed 1acce0c |
| HOST not included in URL comparison (same-path different-host attack) | ✅ Fixed 1acce0c |
LOW — §5.2 parking: abbrev-ref HEAD returns "HEAD" in detached mode |
❌ Still open (non-blocking) |
SUGGESTION — unescaped $BRANCH in sibling-worktree grep |
❌ Still open (non-blocking) |
Codex P1 (635d5d4) — watched-owner policy not re-checked in shell block |
❌ Still open (see below) |
Remaining non-blocking items
Codex P1 (635d5d4) — watched-owner policy gate not present in the shell block
The shell block validates the fork push destination by URL identity but does not explicitly check mutation_policy.branch_write_allowed or whether the head repo's owner is within <watched-owners>. safety.md documents this gate ("Honor mutation_policy.branch_write_allowed: never push… when it is false" and the external-fork stop-and-ask rule), but the shell block doesn't enforce it inline — a cross-repo PR outside <watched-owners> whose URL check passes would stay in full mode.
This is not a regression introduced by this PR — the watched-owner gate is an orchestrator-layer constraint documented in safety.md and presumably enforced before a worker is ever assigned. The PR's scope is git mechanics (HEAD assertion + push routing), not policy enforcement. Not a blocker, but the gap between the policy's stated scope in safety.md and its absence from the inline shell spec is worth a follow-up.
LOW — §5.2 parking in detached mode (unchanged)
git rev-parse --abbrev-ref HEAD returns "HEAD" in detached mode; git checkout "HEAD" is a no-op. After a sibling-locked PR, the next session's HEAD assertion degrades to read-only (conservative failure). Not blocking.
SUGGESTION — unescaped $BRANCH in sibling-worktree grep (unchanged)
grep -q "\[$BRANCH\]" — $BRANCH unescaped basic-regex; . in a branch name matches any character. HEAD assertion degrades false positives to read-only. Not blocking; fix with grep -qF "[$BRANCH]".
No blockers remain. All IMPORTANT and HIGH findings across all review rounds are resolved. 1acce0c closes the last security-relevant gap: the URL validation now reads the actual push URL (via --push), includes the host in the comparison (via repo_id()), and derives the authoritative head-repo identity from gh api rather than the incomplete headRepository.nameWithOwner. The fail-closed logic holds at every edge.
The PR is ready to merge, subject to the ordering note in the PR description: if #882 merges first, re-bump above 0.16.0 (→ 0.16.1) during recompose — but the merge at 98c35e3 already absorbed origin/main at 0.16.1, so the current version is 0.16.2, which is correct.
|
Claude finished @kyle-sexton's task in 4m 30s —— View job Security Review — PR #895 (head:
|
| Finding | Status |
|---|---|
IMPORTANT — || echo origin fallback sends fork-in-detach push to base repo |
✅ FIXED (1d7a992) |
| IMPORTANT — SKILL.md Step 0.3 "merge vs rebase" contradicts cross-tier invariants | ✅ FIXED (d2b62d6) |
MEDIUM — §5.1.4 fix-cycle git push fails in detached HEAD |
✅ FIXED (1d7a992) |
LOW — safety.md grammar / orphaned clause |
✅ FIXED (1d7a992) |
Codex P2 — PUSH_REMOTE uninitialized for conflict push |
✅ FIXED (1d7a992) |
| Codex P2 — orchestration.md conflict-worker inline push remote | ✅ FIXED (180793c) |
| LOW — §5.1.3 "full-mode" prerequisite blocks D1-D5 in read-only | ✅ FIXED (d2b62d6) |
| Codex P2 — conflict-worker rejects asserted detached HEAD | ✅ FIXED (33f1fbe) |
| Codex P2 — fix cycle gated on branch attachment instead of full mode | ✅ FIXED (33f1fbe) |
IMPORTANT — fork-remote guard accepted base-repo remote by non-origin name |
✅ FIXED (eaaa573) |
| Codex P2 — §5.1.6 cleanup gate excludes detached-HEAD full mode | ✅ FIXED (635d5d4) |
IMPORTANT — git remote get-url validates fetch URL, not push URL |
✅ FIXED (1acce0c) |
| IMPORTANT — host stripped from URL comparison (different-host same-path bypass) | ✅ FIXED (1acce0c) |
SUGGESTION — unescaped $BRANCH in sibling-worktree grep |
❌ Still open |
LOW — §5.2 parking: git rev-parse --abbrev-ref HEAD returns "HEAD" in detached mode |
❌ Still open |
Codex P1 (635d5d4) — watched-owner policy not enforced in bash fork-push gate |
❌ Still open (deferred) |
Remaining — SUGGESTION (unchanged from all prior passes)
Unescaped $BRANCH in sibling-worktree grep
elif git worktree list | grep -vF "$CUR_WT " | grep -q "\[$BRANCH\]"; then$BRANCH is interpolated as unescaped basic-regex. A branch name containing . (e.g. release/1.2) makes . match any character — a false positive routes into the --detach path when the branch is not actually sibling-locked. The HEAD assertion on line 165 degrades to read-only on mismatch; no unauthorized mutation occurs. Not blocking.
Fix: grep -qF "[$BRANCH]" (fixed-string; brackets are also matched literally under -F).
Remaining — LOW (unchanged from prior passes)
§5.2 parking in detached mode
git rev-parse --abbrev-ref HEAD returns the literal string "HEAD" in detached mode. After processing a sibling-locked PR, git checkout "HEAD" is a no-op — the next session starts detached at the old PR head. The HEAD assertion in the next §5.1.2 correctly degrades to read-only on mismatch; the failure is conservative (no wrong write) but the parking invariant is broken silently. Not blocking.
Fix: [ "$PARKING_BRANCH" = "HEAD" ] && PARKING_BRANCH=$(git rev-parse HEAD).
Remaining — Codex P1 (deferred, still open)
Watched-owner policy not enforced in bash fork-push gate
The URL-identity gate (now correct) validates that the push destination matches the PR head repository by host and path. It does not additionally check whether the head repo's owner is within <watched-owners> or whether mutation_policy.branch_write_allowed is true. safety.md's Stop-And-Ask section specifies "A refresh, edit, commit, or push would write to an external-fork head outside <watched-owners>" as a stop condition, but this is prose the agent follows, not an enforcement gate in the bash script. For an external fork with maintainer_edits_allowed=true whose URL validates, the bash gate permits full mode without consulting the watched-owner boundary.
This was the Codex P1 raised at the 635d5d4 round and explicitly deferred by the PR author to a follow-up issue. It is noted here as still open; it is not introduced or worsened by 1acce0c.
What looks correct in 1acce0c
repo_id()sed pipeline — handles all common git URL formats (HTTPS, SSH-scheme with port, SCP-style, with or withoutuser@, with or without.git). The host is preserved by stripping only the scheme and user prefix, then normalizing the remaining separator from:to/. Traced correctly above.gh api "repos/$HEAD_REPO" --jq .html_url— uses GitHub's own authoritative canonical URL (alwayshttps://github.com/<owner>/<repo>); no injection risk sincenameWithOwneris GitHub-controlled and restricted to alphanumeric/hyphen/underscore. API failure returns empty →HEAD_IDempty → read-only. Fail-closed. ✓git remote get-url --push— the--pushflag makes it queryremote.<name>.pushurlfirst (falling back tourl), matching whatgit pushactually writes to. The2>/dev/null || printf '%s' "$PUSH_REMOTE"fallback handles the bare-URL config value case (wherePUSH_REMOTEis itself a URL, not a remote name). ✓- Single pre-resolved
$PUSH_REMOTE— all three push sites (freshness merge, conflict-continue, fix-cycle D6) consume the same value. No site reintroduces the inline config expansion. ✓ - No new GitHub Actions surface — no workflow files changed. ✓
Bottom line: 1acce0c closes the two IMPORTANT findings from the 635d5d4 round — the push-URL/fetch-URL mismatch and the host-stripping bypass. The repo_id() + --push + html_url design is correct and fail-closed. The one SUGGESTION (unescaped branch grep), one LOW (parking in detached mode), and one deferred Codex P1 (watched-owner enforcement) persist unchanged and none enable unauthorized writes.
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 1acce0c1d4
ℹ️ 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".
…validation The [0.16.2] safety.md push bullet still described name-based push resolution (git config branch.<b>.remote, "the fork's remote for a cross-repo head"), which predates the URL-identity gate. Update it to what now ships: a fail-closed destination validated by host + owner/repo identity against the head repo's canonical URL, using the actual push URL (git remote get-url --push).
|
Claude finished @kyle-sexton's task in 2m 40s —— View job Claude finished @kyle-sexton's task —— PR Review — Follow-up Pass (commit
|
| Finding | Status |
|---|---|
IMPORTANT — || echo origin fallback sends fork-in-detach push to base repo |
✅ Fixed 1d7a992 |
HIGH — §5.1.4 fix-cycle git push fails in detached HEAD |
✅ Fixed 1d7a992 |
LOW — safety.md grammar / orphaned clause |
✅ Fixed 1d7a992 |
Codex P2 — PUSH_REMOTE uninitialized for conflict push |
✅ Fixed 1d7a992 |
| orchestration.md conflict-worker inline push remote | ✅ Fixed 180793c |
safety.md PUSH_REMOTE authority + fail-closed description |
✅ Fixed 180793c |
| HIGH/IMPORTANT — SKILL.md Step 0.3 says "merge vs rebase" | ✅ Fixed d2b62d6 |
| LOW — §5.1.3 "full-mode" prerequisite blocks D1-D5 in read-only | ✅ Fixed d2b62d6 |
| Codex P2 — conflict-worker rejects asserted detached HEAD | ✅ Fixed 33f1fbe |
| Codex P2 — fix cycle gated on branch attachment instead of full mode | ✅ Fixed 33f1fbe |
Codex P1 — fork-remote guard accepts base-repo remote by non-origin name |
✅ Fixed eaaa573 |
| Merge introduces no regressions | ✅ Verified 98c35e3 |
| LOW/Codex P2 — §5.1.6 cleanup condition excludes detached-HEAD full mode | ✅ Fixed 635d5d4 |
IMPORTANT — git remote get-url validates fetch URL, not push URL |
✅ Fixed 1acce0c |
| IMPORTANT — host stripped from URL comparison (different-host same-path bypass) | ✅ Fixed 1acce0c |
| CHANGELOG text still described name-based push resolution | ✅ Fixed 74a4198 |
SUGGESTION — unescaped $BRANCH in sibling-worktree grep |
❌ Still open (non-blocking) |
LOW — §5.2 parking: abbrev-ref HEAD returns "HEAD" in detached mode |
❌ Still open (non-blocking) |
Codex P1 (635d5d4) — watched-owner policy not enforced in bash fork-push gate |
❌ Deferred (non-blocking) |
Codex P1 (1acce0c) — multiple pushurl values not all validated |
❌ Still open (non-blocking) |
Codex P2 (1acce0c) — branch identity not asserted in HEAD-match path |
❌ Still open (non-blocking) |
No blockers remain. 74a4198 correctly aligns the CHANGELOG to the URL-identity implementation that landed in 1acce0c. All IMPORTANT and HIGH findings across all review rounds are resolved. The five remaining open items are all non-blocking: guarded by the HEAD assertion, conservatively fail-closed, or explicitly deferred to a follow-up issue. The PR is ready to merge, subject to the ordering note: if #882 has merged (it was unmerged at PR open), re-verify the version bump is still above 0.16.0; the 98c35e3 merge already absorbed origin/main at 0.16.1, so 0.16.2 is correct unless another bump landed since.
|
Claude finished @kyle-sexton's task in 5m 30s —— View job Security Review — PR #895 (head:
|
| Finding | Status |
|---|---|
IMPORTANT — || echo origin fallback sends fork-in-detach push to base repo |
✅ FIXED (1d7a992) |
| IMPORTANT — SKILL.md Step 0.3 "merge vs rebase" contradicts cross-tier invariants | ✅ FIXED (d2b62d6) |
MEDIUM — §5.1.4 fix-cycle git push fails in detached HEAD |
✅ FIXED (1d7a992) |
LOW — safety.md grammar / orphaned clause |
✅ FIXED (1d7a992) |
Codex P2 — PUSH_REMOTE uninitialized for conflict push |
✅ FIXED (1d7a992) |
| Codex P2 — orchestration.md conflict-worker inline push remote | ✅ FIXED (180793c) |
| LOW — §5.1.3 "full-mode" prerequisite blocks D1-D5 in read-only | ✅ FIXED (d2b62d6) |
| Codex P2 — conflict-worker rejects asserted detached HEAD | ✅ FIXED (33f1fbe) |
| Codex P2 — fix cycle gated on branch attachment instead of full mode | ✅ FIXED (33f1fbe) |
IMPORTANT — fork-remote guard accepted base-repo remote by non-origin name |
✅ FIXED (eaaa573) |
| Codex P2 — §5.1.6 cleanup gate excludes detached-HEAD full mode | ✅ FIXED (635d5d4) |
IMPORTANT — git remote get-url validates fetch URL, not push URL |
✅ FIXED (1acce0c) |
| IMPORTANT — host stripped from URL comparison (different-host same-path bypass) | ✅ FIXED (1acce0c) |
SUGGESTION — unescaped $BRANCH in sibling-worktree grep |
❌ Open (unchanged, see below) |
LOW — §5.2 parking: git rev-parse --abbrev-ref HEAD returns "HEAD" in detached mode |
❌ Open (unchanged, see below) |
SUGGESTION — Multi-pushurl gap: only the first pushurl is validated, but git push writes to all (new finding, Codex P1 from 1acce0c round)
Confidence: HIGH
REMOTE_URL=$(git remote get-url --push "$PUSH_REMOTE" 2>/dev/null || printf '%s' "$PUSH_REMOTE")git remote get-url --push <name> returns the first configured remote.<name>.pushurl (falling back to the fetch URL when none is set). When a remote has multiple pushurl entries, git push writes to all of them. If a second remote.<name>.pushurl pointing at the base repo is present, the validation passes on the first (fork) URL, but the subsequent git push "$PUSH_REMOTE" HEAD:$BRANCH silently also writes to the base repo via the extra pushurl — the exact regression this PR was written to close.
git remote get-url --push --all <name> returns all configured pushurls. The fix is to iterate over all of them and degrade to read-only if any resolves to a non-head-repo destination:
# Validate EVERY configured push URL — git push writes to all pushurls, not just the first.
while IFS= read -r url; do
if [ "$(repo_id "$url")" != "$HEAD_ID" ]; then
echo "Fork remote has a pushurl not matching the PR head repo — read-only"
CHECKOUT_MODE="read-only"
break
fi
done < <(git remote get-url --push --all "$PUSH_REMOTE" 2>/dev/null || printf '%s\n' "$PUSH_REMOTE")Practical blast radius: this requires a second pushurl to have been deliberately added to the local git config. gh pr checkout does not configure multiple pushurls; the scenario requires the local git repository to have been pre-tampered with. An operator with that level of local access can already push directly. Not blocking, but the spec's stated guarantee ("the address git push will actually use") is not fully delivered when multiple pushurls are in play.
SUGGESTION — Unescaped $BRANCH in sibling-worktree grep (unchanged from all prior passes)
Confidence: HIGH
elif git worktree list | grep -vF "$CUR_WT " | grep -q "\[$BRANCH\]"; then$BRANCH is interpolated as unescaped basic-regex. A branch name containing . (e.g. release/1.2) makes . match any character — false positive routes into the --detach path when the branch is not actually sibling-locked. The HEAD assertion on line 165 degrades to read-only on any mismatch; no unauthorized mutation occurs from a false-positive detection. Not blocking.
Fix when convenient: grep -qF "[$BRANCH]" (fixed-string; brackets are also matched literally under -F).
LOW — §5.2 parking: git rev-parse --abbrev-ref HEAD returns "HEAD" in detached mode (unchanged from prior passes)
Confidence: HIGH
git rev-parse --abbrev-ref HEAD outputs the literal string "HEAD" in detached mode. After processing a sibling-locked PR in detached full mode, git checkout "HEAD" is a no-op — the next session starts detached at the old PR commit. The HEAD assertion in the next §5.1.2 correctly degrades to read-only on mismatch (conservative failure, no wrong write), but the parking invariant is broken silently. Not blocking.
Fix: [ "$PARKING_BRANCH" = "HEAD" ] && PARKING_BRANCH=$(git rev-parse HEAD) to capture a SHA fallback.
What looks correct across the full PR
repo_id()coverage — traces correctly across HTTPS (https://github.com/o/r.git), SSH-scheme with port (ssh://git@github.com:22/o/r.git), SCP-style (git@github.com:o/r.git), and URL-encoded forms. A same-path remote on a different host (git@evil.example.com:o/r.git) correctly canonicalizes toevil.example.com/o/r≠github.com/o/r→ read-only.HEAD_IDfromgh api—gh api "repos/$HEAD_REPO"uses GitHub's own authoritativehtml_url(https://github.com/owner/repo), guaranteeing the host is alwaysgithub.comfor GitHub-hosted PRs. API failure (emptyHEAD_ID) is caught by[ -z "$HEAD_ID" ]→ read-only. Fail-closed.isCrossRepositoryfail-closed — an API error (empty result ≠"false") routes to the fork/conservative path → read-only whenPUSH_REMOTEis empty or unresolved. Fail-closed.PR_HEADempty guard — ifgh pr view --json headRefOidreturns empty, all HEAD comparisons return false → read-only throughout. Fail-closed.- All three push sites (freshness merge line 231, conflict-continue prose line 254, fix-cycle D6 line 345) consume the same pre-resolved
$PUSH_REMOTE. No site reintroduces the|| echo originfallback or the inlinegit configexpansion. - Force-push removal — the
--force-with-leaserebase path is gone; the replacement (merge + fast-forward refspec push) is strictly weaker in privilege and correct by construction. - No GitHub Actions changes — no
pull_request_target,workflow_run,run:injection surfaces, or permission-widening changes. - SKILL.md Step 0.3 — correctly reads
merge-only (never rebase — rebasing a PR branch needs a forbidden force-push). All cross-tier locations are consistent. - safety.md Checkout And Push Invariants — prose is internally consistent and covers detached HEAD, stale local tip, fork pushurl masking, same-path different-host attack, and the non-
originremote-name bypass. All three threats are explicitly named and the response is unambiguously stop (read-only).
Bottom line: 74a4198 is a CHANGELOG-only doc update introducing no new attack surface. The one new finding from this round — the multi-pushurl gap (Codex P1 from the 1acce0c review that was not previously surfaced) — requires deliberate local git config manipulation and is not a blocker. Both remaining open items (unescaped grep, parking in detached mode) are conservative-failure non-blockers confirmed across every prior round. All IMPORTANT and CRITICAL findings from all rounds are resolved.
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 74a419818f
ℹ️ 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".
…sh urls + branch identity) Three coherent gaps in the full-mode push guard, closed together: - Every push url, not just the first. `git remote get-url --push` returned only the first push url; git push writes to ALL of a remote's pushurls, so a second base-repo or attacker pushurl received the refspec after the first matched. Resolve --push --all and require EVERY url to canonicalize to the head repo. - Watched-owner trust boundary. A cross-repo head whose remote url matched headRepository stayed full-mode even when its owner was outside <watched-owners> (external fork with maintainer edits) — a push outside the trust boundary the safety contract's Stop And Ask forbids. Gate the fork path on the head owner being within <watched-owners> before accepting the remote. - PR-branch identity before full mode. A clean worktree whose tip merely equalled headRefOid while on some OTHER local branch entered full mode; a fix committed there advanced that unrelated branch while only the refspec push landed on the PR branch. Require the checkout to be on the PR branch or detached. The stdin canonicalizer is shared for the head-repo html_url and every push url. safety.md Checkout And Push Invariants updated to describe the complete guard, and records its threat-model boundary: git's push-time url rewrites (url.<base>.pushInsteadOf and similar) are outside a static guard's scope, as they do not arise from the documented gh pr checkout flow.
|
Claude finished @kyle-sexton's task in 5m 11s —— View job PR Review — Follow-up Pass (commit
|
| Finding | Status |
|---|---|
IMPORTANT — || echo origin fallback sends fork-in-detach push to base repo |
✅ Fixed 1d7a992 |
HIGH — §5.1.4 fix-cycle git push fails in detached HEAD |
✅ Fixed 1d7a992 |
LOW — safety.md grammar / orphaned clause |
✅ Fixed 1d7a992 |
Codex P2 — PUSH_REMOTE uninitialized for conflict push |
✅ Fixed 1d7a992 |
| orchestration.md conflict-worker inline push remote | ✅ Fixed 180793c |
safety.md PUSH_REMOTE authority + fail-closed description |
✅ Fixed 180793c |
| HIGH/IMPORTANT — SKILL.md Step 0.3 says "merge vs rebase" | ✅ Fixed d2b62d6 |
| LOW — §5.1.3 "full-mode" prerequisite blocks D1-D5 in read-only | ✅ Fixed d2b62d6 |
| Codex P2 — conflict-worker rejects asserted detached HEAD | ✅ Fixed 33f1fbe |
| Codex P2 — fix cycle gated on branch attachment instead of full mode | ✅ Fixed 33f1fbe |
Codex P1 — fork-remote guard accepts base-repo remote by non-origin name |
✅ Fixed eaaa573 |
| Merge introduces no regressions | ✅ Verified 98c35e3 |
| LOW/Codex P2 — §5.1.6 cleanup condition excludes detached-HEAD full mode | ✅ Fixed 635d5d4 |
IMPORTANT — git remote get-url validates fetch URL, not push URL |
✅ Fixed 1acce0c |
| IMPORTANT — host stripped from URL comparison (different-host same-path bypass) | ✅ Fixed 1acce0c |
| CHANGELOG text still described name-based push resolution | ✅ Fixed 74a4198 |
Codex P1 — multiple pushurl values not all validated |
✅ Fixed a407f08 |
| Codex P2 — branch identity not asserted in HEAD-match path | ✅ Fixed a407f08 |
Codex P1 (635d5d4) — watched-owner policy not enforced in bash fork-push gate |
✅ Fixed a407f08 |
SUGGESTION — unescaped $BRANCH in sibling-worktree grep |
❌ Still open (non-blocking) |
LOW — §5.2 parking: abbrev-ref HEAD returns "HEAD" in detached mode |
❌ Still open (non-blocking) |
Remaining — SUGGESTION (unchanged from all prior passes)
elif git worktree list | grep -vF "$CUR_WT " | grep -q "\[$BRANCH\]"; then$BRANCH is interpolated as unescaped basic-regex; . in a branch name (e.g. release/1.2) matches any character and could cause a false-positive sibling-lock detection. The HEAD assertion on line 169 degrades to read-only on any mismatch — no unauthorized mutation occurs. Not blocking. Fix when convenient: grep -qF "[$BRANCH]".
Remaining — LOW (unchanged from prior passes)
PARKING_BRANCH=$(git rev-parse --abbrev-ref HEAD)Returns "HEAD" in detached mode. After a sibling-locked PR, git checkout "HEAD" is a no-op — the next session starts detached at the old PR commit. The next §5.1.2 HEAD assertion correctly degrades to read-only on mismatch (conservative, no wrong write), but the parking invariant is broken silently. Not blocking. Fix: [ "$PARKING_BRANCH" = "HEAD" ] && PARKING_BRANCH=$(git rev-parse HEAD).
What looks correct in a407f08
BADcomputation —printf '%s\n' "$PUSH_URLS" | canon | grep -vxF "$HEAD_ID":printfcorrectly handles single and multi-URL cases (no spurious empty lines);grep -vxFrequires exact full-line matches;|| truemakes the pipeline exit-0 when all URLs match. All three sub-cases traced correctly:- All URLs match
HEAD_ID→ BAD="" → gate passes ✓ - Any URL doesn't match → BAD non-empty → read-only ✓
PUSH_URLSempty (no remote config) →[ -z "$PUSH_URLS" ]fires → read-only ✓
- All URLs match
CUR_BRANCHon detached HEAD —git symbolic-ref --short -q HEAD || trueoutputs empty string on detached HEAD (not"HEAD"). The condition[ -z "$CUR_BRANCH" ]correctly handles detached mode as equivalent to "not on the wrong branch". ✓- Watched-owner gate ordering — runs before
HEAD_IDandPUSH_REMOTEresolution; a blocked owner still has those variables computed butCHECKOUT_MODEis already"read-only"so no push path uses them. Fail-closed. ✓ canon()renamed fromrepo_id()— processes stdin line-by-line (sed reads a stream), making it composable withprintf ... | canon | grep .... The sed pipeline was traced against HTTPS, SSH-scheme with port, SCP-style,user:pass@host, and GHES forms — all produce correcthost/owner/repo. ✓- Known limitation documented —
url.<base>.pushInsteadOfrewrites are correctly called out insafety.mdas outside the static guard's threat model. ✓
No blockers remain. a407f08 closes the last three Codex P1/P2 findings from the 74a4198 round — multi-pushurl validation, branch-identity in the HEAD-match path, and the watched-owner trust boundary — with minimal, targeted changes and no new issues introduced. The two remaining open items (unescaped branch grep and parking in detached mode) are both conservative-failure non-blockers confirmed across multiple prior rounds.
The PR is ready to merge. Version is 0.16.2, which is correct given the 98c35e3 merge from origin/main at 0.16.1.
|
Claude finished @kyle-sexton's task in 5m 12s —— View job Security Review — PR #895 (head:
|
| Finding | Status |
|---|---|
IMPORTANT — || echo origin fallback sends fork-in-detach push to base repo |
✅ FIXED (1d7a992) |
| IMPORTANT — SKILL.md Step 0.3 "merge vs rebase" contradicts cross-tier invariants | ✅ FIXED (d2b62d6) |
MEDIUM — §5.1.4 fix-cycle git push fails in detached HEAD |
✅ FIXED (1d7a992) |
LOW — safety.md grammar / orphaned clause |
✅ FIXED (1d7a992) |
Codex P2 — PUSH_REMOTE uninitialized for conflict push |
✅ FIXED (1d7a992) |
| Codex P2 — orchestration.md conflict-worker inline push remote | ✅ FIXED (180793c) |
| LOW — §5.1.3 "full-mode" prerequisite blocks D1-D5 in read-only | ✅ FIXED (d2b62d6) |
| Codex P2 — conflict-worker rejects asserted detached HEAD | ✅ FIXED (33f1fbe) |
| Codex P2 — fix cycle gated on branch attachment instead of full mode | ✅ FIXED (33f1fbe) |
IMPORTANT — fork-remote guard accepted base-repo remote by non-origin name |
✅ FIXED (eaaa573) |
| Codex P2 — §5.1.6 cleanup gate excludes detached-HEAD full mode | ✅ FIXED (635d5d4) |
IMPORTANT — git remote get-url validates fetch URL, not push URL |
✅ FIXED (1acce0c) |
| IMPORTANT — host stripped from URL comparison (different-host same-path bypass) | ✅ FIXED (1acce0c) |
Codex P1 (1acce0c) — multiple pushurl values not all validated |
✅ FIXED (a407f08) |
Codex P1 (635d5d4) — watched-owner policy not enforced in bash fork-push gate |
✅ FIXED (a407f08) |
Codex P2 (1acce0c) — branch identity not asserted in HEAD-match path |
✅ FIXED (a407f08) |
SUGGESTION — unescaped $BRANCH in sibling-worktree grep |
❌ Still open |
LOW — §5.2 parking: git rev-parse --abbrev-ref HEAD returns "HEAD" in detached mode |
❌ Still open |
Fail-closed chain verification for a407f08
Watched-owner gate interaction with URL validation: When case fails (owner not in <watched-owners>), CHECKOUT_MODE="read-only" is set. The URL validation block continues to execute (no early return), resolving PUSH_REMOTE, PUSH_URLS, and BAD. The URL validation can only SET CHECKOUT_MODE="read-only" — it has no path back to "full". So the watched-owner failure is correctly sticky regardless of whether URL validation also fails. All three push sites (freshness merge, conflict-continue, fix-cycle D6) gate on if [ "$CHECKOUT_MODE" = "full" ] — none of them run. ✓
Empty-owner edge case: If gh pr view --json headRepository fails and HEAD_REPO is empty, HEAD_OWNER is also empty. The case pattern *",,"* matches only when <watched-owners> contains a double comma (misconfigured). In a correct config this falls to read-only. Additionally, HEAD_ID=$(gh api "repos/" ...) will fail → HEAD_ID empty → [ -z "$HEAD_ID" ] → read-only. Doubly fail-closed. ✓
BAD computation with empty HEAD_ID: If HEAD_ID is empty, grep -vxF "" matches every non-empty line (empty string with -x only matches empty lines), so all push URLs appear in BAD → read-only. The explicit [ -z "$HEAD_ID" ] guard is redundant but harmless. ✓
isCrossRepository failure: A non-"false" result (including empty) takes the fork/conservative path, where an unresolved push remote or a URL mismatch sends the worker to read-only. ✓
Branch-identity path: git symbolic-ref --short -q HEAD || true produces an empty string in detached HEAD; in that case [ -z "$CUR_BRANCH" ] is true and full mode is granted when HEAD == PR_HEAD. On a named branch, [ "$CUR_BRANCH" = "$BRANCH" ] must also hold. ✓
Remaining — SUGGESTION (unchanged, non-blocking)
Unescaped $BRANCH in sibling-worktree grep
elif git worktree list | grep -vF "$CUR_WT " | grep -q "\[$BRANCH\]"; then$BRANCH is interpolated as unescaped basic-regex. A . in a branch name (e.g. release/1.2) matches any character and could produce a false positive routing into the --detach path when the branch is not actually locked. The HEAD assertion on line 169 degrades to read-only on mismatch — no unauthorized mutation. Not blocking.
Fix: grep -qF "[$BRANCH]" (fixed-string; brackets are also literal under -F).
Remaining — LOW (unchanged, non-blocking)
§5.2 parking: git rev-parse --abbrev-ref HEAD returns "HEAD" in detached mode
After processing a sibling-locked PR in detached full mode, git rev-parse --abbrev-ref HEAD returns the literal "HEAD" and git checkout "HEAD" is a no-op. The next session's HEAD assertion correctly degrades to read-only on mismatch (no wrong write), but the parking invariant is silently broken. Not blocking.
Codex P2 from 74a4198 round — false positive
The prior Codex P2 comment on safety.md:35 ("the new invariant says to assert HEAD equals the true PR head before any push — after a freshness merge HEAD is intentionally ahead of headRefOid") is a misreading. The "Before any merge, edit, or push" clause describes the worktree assignment assertion at the start of the session (before the first operation). The second bullet's "Re-check the PR head SHA … before pushing" is re-running gh pr view --json headRefOid to detect concurrent remote pushes — not comparing the local HEAD to headRefOid. After a freshness merge your local HEAD is ahead of headRefOid by design; that is expected and correct. No security issue.
No new attack surface in a407f08
canon()is a renamed equivalent of the priorrepo_id()— the sed pipeline is unchanged and traces correctly for all URL forms: HTTPS, SSH-scheme-with-port, SCP-style, with or withoutuser@,.git, or trailing slashes.evil.example.com/owner/repo≠github.com/owner/repo→ read-only. ✓gh api "repos/$HEAD_REPO" --jq .html_urluses GitHub's authoritativehtml_url;nameWithOwneris GitHub-controlled and restricted to alphanumeric/hyphen/underscore — no injection risk. API failure →HEAD_IDempty → read-only. ✓- No GitHub Actions changes — no
pull_request_target,workflow_run,run:injection surfaces, or permission-widening changes. ✓
Bottom line: a407f08 closes the last three open Codex findings: every configured pushurl is validated (not just the first), external-fork owners outside <watched-owners> are rejected before URL validation runs, and the HEAD-match path now requires the correct branch identity or detached HEAD. No new security issues were introduced. All IMPORTANT and CRITICAL findings across all review rounds are resolved. The two remaining items (unescaped branch grep, parking in detached mode) are conservative-failure non-blockers confirmed across all prior rounds and are unchanged by this commit.
Summary
Fixes #548. A babysit PR worker can be assigned a git worktree in detached HEAD — because the PR branch is already checked out in a sibling/foreign worktree — or on a stale local branch tip behind the PR head. The checkout/freshness mechanics then merged and pushed from that tip, so a stale-tip integration could silently revert the newest branch commit — a near-miss where safety depended on the assigned
HEADhappening to match, not on a guard.The initial reviewer pass confirmed the safety.md contract text was sound but raised a CRITICAL: the actual worker mechanics (loop.md/SKILL.md/orchestration.md) were unreconciled, so an agent following the literal steps still hit the failure. This PR fixes the contract and the mechanics that implement it.
The fix
reference/safety.mdCheckout And Push Invariants — assert the assigned worktree'sHEADequals the true PR head (gh pr view <N> --json headRefOid; equal toorigin/<headRefName>for a same-repo PR) before any merge/edit/push; stop on a stale/detached mismatch. Push by explicit refspec to the branch's configured upstreamgit push "$(git config --get branch.<headRefName>.remote)" HEAD:<headRefName>(fast-forward by construction, never--force/--force-with-lease). The reuse rule is reconciled so it permits the detached-HEAD path under the same assertion (no self-contradiction).reference/loop.md§5.1.2 — acquire the head viagh pr checkout(heals a behind-origin local branch;--detachfor a sibling-locked branch; resolves fork PRs), assertHEAD == PR_HEAD(the liveheadRefOid) in every checkout path, degrade to read-only on mismatch.SKILL.mdStep 0.2 + the cross-tier invariants andreference/orchestration.md's conflict-worker follow the same assertion + refspec push.worktrees.mdgets a one-line pointer (not a duplicate).--force-with-leased linear-history branches — which both violated the skill's own never-force-push invariant (safety.md"Never Do Automatically",orchestration.md) and was the silent-revert vector. Behind-default branches now always integrate viagit merge+ a fast-forward refspec push. Behavior change: linear-history branches now carry an interim merge commit during freshness instead of being rebased (the final squash merge still flattens history).Scope decisions (called out for review)
git push "$(git config --get branch.<headRefName>.remote)" HEAD:<headRefName>— which resolves tooriginfor a same-repo head and to the fork's remote for a write-allowed in-owner fork head (a real supported case:branch_write_allowed=truefor cross-repo heads under<watched-owners>). An earlier revision hardcodedorigin, which the re-review flagged as a regression that would silently write a same-named branch on the base repo for a fork PR; this fixes it while keeping the origin refspec for same-repo heads. An external-fork head outside watched owners remains safety.md's read-only stop-and-ask case. The assertion uses the liveheadRefOid, correct for any PR type.Verification (local gates)
check-skill-portability.sh origin/main— PASS (5 skill files).check-changelog-parity.sh --check-bump origin/main— PASS (0.15.10 entry present).markdownlint-cli2on the changed docs — clean; SKILL.md 499/500 lines;evals.json(id 6 updated to the merge-only + upstream-refspec contract) valid;plugin.jsonvalid.Version
Per-plugin bump 0.15.9 → 0.15.10 (patch).
marketplace.jsonpins no version forsource-control, so onlyplugin.jsonchanges.Closes #548
Related
🤖 Generated with Claude Code