fix(source-control): require per-thread pins for autonomous bulk babysit thread resolves - #561
Conversation
…sit thread resolves babysit_resolve_thread.py's bulk resolve path (--resolve with no --thread-id) did not require the per-thread TOCTOU pins the single-thread path enforces. An autonomous worker's own push marks a review thread isOutdated, so the bulk autonomous path could clear threads that changed since they were vetted with no proof the finding was addressed. Reject bulk autonomous resolves (maintainer Option 1): a --autonomous --resolve call must carry a single pinned --thread-id, turning it into a per-thread vetted loop that reuses the existing pin guard. Also refuse --allow-unpinned-thread in --autonomous mode so there is no unpinned autonomous resolve. Update the module docstring and SKILL.md Autopilot step 2 to the per-thread loop, add regression tests, bump the plugin to 0.10.0. Closes #326 Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01KyLj6oaFVTE6xFuoYCC2KC
|
Claude finished @kyle-sexton's task in 2m 29s —— View job PR Review — fix(source-control): require per-thread pins for autonomous bulk babysit thread resolves
Overall assessmentThis PR is well-executed and correct. The security fix is well-scoped, the guards are layered correctly, and the tests directly exercise the new refusal paths. No actionable bugs found. Guard logic —
|
| Input | Guard fires | Result |
|---|---|---|
--autonomous --resolve (no thread-id) |
line 326 | refused ✓ |
--autonomous --resolve --allow-unpinned-thread (no thread-id) |
line 326 first | refused ✓ |
--autonomous --resolve --thread-id X --allow-unpinned-thread |
line 346 | refused ✓ |
--autonomous --resolve --thread-id X (no pins) |
line 363 (existing) | refused ✓ |
--autonomous --resolve --thread-id X --expected-comment-count N --expected-last-updated T |
none | proceeds to TOCTOU check ✓ |
The "before any network fetch" claim in the test comments is accurate — parse_repo_number (line 290) is a pure string parse, not a network call.
One minor observation on guard #2 (line 346): The condition is args.resolve and args.autonomous and args.allow_unpinned_thread — it correctly requires --resolve to be present. Without --resolve, --allow-unpinned-thread in autonomous mode is harmless (list-only), and blocking it would add noise for any autonomous list call. The condition is tighter than it needs to be and that's a good thing.
The classify() function at line 194 still filters not isOutdated threads in autonomous mode — so even if a bug slipped past these guards, the isOutdated requirement would remain as a backstop. Defense in depth is intact.
Test coverage — test_guards.py
Both new tests are correctly structured:
-
test_autonomous_bulk_resolve_without_thread_id_is_refused— asserts exit 2 and that"thread-id"and"bulk-resolve"both appear in the error. Both strings are present in the error message verbatim. ✓ -
test_autonomous_allow_unpinned_thread_is_refused— asserts exit 2 and"allow-unpinned-thread"in the error, which matches the error string. ✓
The subprocess-based test pattern (no gh stub needed, refused before any network call) is consistent with the existing guard tests in the class and is the right approach here.
Observation: There's no positive-path test for the newly required form (--autonomous --resolve --thread-id X --expected-comment-count N --expected-last-updated T reaching the TOCTOU check rather than hitting a guard). This would require a gh stub to go further, which the existing infrastructure doesn't do for this module. The refusal tests are the critical coverage for the security boundary, so this is an acceptable gap in scope.
Documentation
SKILL.md step 7 — the updated runbook text is accurate and complete. The per-thread loop form is now documented with the correct flag set:
--autonomous --resolve --thread-id <id> --expected-comment-count <n> --expected-last-updated <ts>
The inline rationale ("so a worker cannot clear a thread its own push merely displaced") correctly explains why the bulk form is refused, which is important for operators who may have relied on the old behavior.
CHANGELOG.md — the entry under [0.10.0] is detailed and accurate. It correctly calls out the behavior change to the autonomous-worker contract and covers both the bulk-resolve refusal and the --allow-unpinned-thread restriction. This is useful for consumers upgrading from 0.9.3.
Version bump — plugin.json
0.9.3 → 0.10.0 (minor bump) is correct. This is a breaking change to the autonomous-worker call contract (commands that previously worked will now exit 2), so a minor bump is appropriate. A patch bump would have understated the change.
Summary
No bugs or security gaps found. The fix is minimal, fail-closed, correctly layered with the existing TOCTOU guard, and documents the new contract clearly. Approve.
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 37c7a02fd5
ℹ️ 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".
Review-response classification
No code changes pushed in this pass — finding 2's fix is a flow redesign gated on a merge-gate posture decision (see #571); finding 1 requires none. |
|
Control-tower audit verdict: HOLD — one cheap, separable fix required before merge. The diff faithfully implements #326's ratified Option 1. But SKILL.md step-7 (diff L59) and the docstring (L26-33) assert a worker cannot clear a thread its own push merely displaced — and the PR's own unresolved P1 thread (classified VALID by the author, root fix deferred to #571) demonstrates that claim is false as shipped: the per-thread pins cover comment count/last-updated only, so a displacing push flips isOutdated while both pins still match, and a pinned resolve clears an unaddressed finding. Verified against babysit_merge.py:71-95 — resolved-via-displacement threads drop out of the merge blocker set, so the hole is live, not theoretical. Required before merge: correct/caveat the L59 + docstring language to describe only the enforced behavior (pins prevent comment-state races; displacement bypass remains until #571). Deferring the displacement fix to #571 is a legitimate, correctly-surfaced posture call — shipping a known-false present-tense guarantee in an area:security skill is the only blocker. Keep #571 linked in ## Related. |
…pin guarantee The babysit-prs bulk-resolve pin docs asserted a present-tense guarantee the shipped code does not enforce: that a worker cannot clear a thread its own push merely displaced. The per-thread pins (--expected-comment-count, --expected-last-updated) cover comment-state only; a displacing push flips isOutdated while both pins still match, so a pinned autonomous resolve clears an unaddressed finding (verified against babysit_merge.py unresolved_threads() and the classify() gate, which never checks pre-push outdatedness). Correct the three present-tense guarantees to describe only enforced behavior (pins prevent comment-state races) and state plainly that the displacement bypass remains open, resting on the pre-push-outdated agent-discipline rule rather than machine enforcement: - babysit_resolve_thread.py module docstring - SKILL.md thread-resolution tool description - SKILL.md operational-runbook step 7 - CHANGELOG.md 0.10.0 entry (dropped the "fail-closed by construction" overclaim) Addresses the control-tower HOLD on #561. The machine-enforced root fix for the displacement bypass is tracked in #571. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01KyLj6oaFVTE6xFuoYCC2KC
|
Claude finished @kyle-sexton's task in 1m 31s —— View job Review of new commit — docs(source-control): caveat displacement-bypass gap in bulk-resolve pin guarantee
HOLD status: ResolvedThe control-tower required:
Commit b9abec0 corrects all four affected locations. Each now accurately states:
Per-location verificationModule docstring ( The old text claimed the per-thread pins are "what prove each thread is unchanged since the agent vetted it" — that was the false guarantee. The new text:
This is technically precise and complete for a module docstring. SKILL.md tool description ( The old text said the worker "requires the thread to have been outdated in the PRE-push snapshot, pinned via SKILL.md step 7 ( The old text concluded with "so a worker cannot clear a thread its own push merely displaced" — the exact false guarantee the control-tower flagged. The new text replaces this with an accurate description of what the bulk-resolve refusal actually prevents (missing comment-state pins) versus what it doesn't (displacement). ✓ Minor cosmetic observation (not blocking): The step-7 sentence ending "tracked in #571" is immediately followed by "In autopilot, after addressing..." without a paragraph break. The transition is slightly abrupt — "In autopilot" starts a conceptually separate topic. This pre-existed; the new commit inserted the displacement caveat inline in the existing paragraph. Not inaccurate, just a readability note for a future prose pass. CHANGELOG.md ( The "fail-closed by construction" overclaim is gone. The new language correctly scopes the fix: "the per-thread pins now close the bulk and comment-drift gaps. They do not close the displacement bypass." This is accurate and appropriately scoped for a changelog. ✓ Consistency checkAll four locations say the same thing about the displacement bypass and all four point to #571. The language is consistent. Cross-references to OverallThe new commit precisely addresses the control-tower HOLD — no false guarantees remain in the documentation, the displacement gap is accurately characterized in all affected locations, and the #571 cross-reference is consistent. The security-correctness fix from 37c7a02 (guard logic + tests) remains intact and correct as reviewed in the previous pass. HOLD is lifted. Ready to merge. |
HOLD addressed — displacement-bypass caveat applied (docs-only)Per the control-tower HOLD, the present-tense guarantee that a worker cannot clear a thread its own push merely displaced was false as shipped: the per-thread pins ( Corrected the language to describe only enforced behavior (pins prevent comment-state races) and to state plainly that the displacement bypass remains open — it rests on the pre-push-outdated agent-discipline rule (
No
|

Summary
babysit_resolve_thread.py's bulk resolve path (--resolvewith no--thread-id) did not require the per-thread TOCTOU pins the single-thread path already enforces. Because an autonomous worker's own push marks a review threadisOutdated, the bulk--autonomous --resolvepath could clear threads that changed since they were vetted with no proof the finding was addressed — the actor signing its own permission slip. This implements the maintainer-confirmed Option 1: reject bulk autonomous resolves and require a per-thread vetted loop.Fix
babysit_resolve_thread.py,--autonomous --resolvewithout--thread-idis now refused (exit 2, before any network fetch), forcing the unattended-worker path through a per-thread loop where each thread reuses the existing pin guard (--expected-comment-count+--expected-last-updated). No second pinning model is introduced.--allow-unpinned-threadis also refused in--autonomousmode — otherwise--autonomous --resolve --thread-id X --allow-unpinned-threadwould reopen the exact "resolve with no pins" hole. It stays an interactive-only override. Flagged here explicitly for maintainer visibility; it is restriction-direction (auto-ratified) and fail-closed, consistent with the Option 1 rationale.SKILL.mdAutopilot step 2 from a bulk call to the per-thread vetted loop, aligning them with the pinned form already documented inreference/orchestration.md,reference/safety.md, and the worker prompt template (those were already correct; the code and step 2 were the stragglers).test_guards.pyprove the bulk autonomous resolve is refused and that--allow-unpinned-threadcannot bypass pins in autonomous mode.0.9.3 → 0.10.0(behavior change to the autonomous-worker contract) + CHANGELOG entry.Verification
Full plugin engine suite (stdlib unittest + ruff + guarded-wrapper behavior) via
bash engine.test.sh:New regression tests (
python -m unittest tests.test_guards -v):markdownlint-cli2 on the two changed Markdown files:
Summary: 0 error(s). Plugin + catalog manifests:All plugin manifests and the catalog validated.Closes #326
Related
🤖 Generated with a claude-code-plugins work-2 lane implementation subagent (model: opus)