fix(update): refuse the version-floor auto-update on a checkout with local commits - #5199
fix(update): refuse the version-floor auto-update on a checkout with local commits#5199leonlaiyc wants to merge 1 commit into
Conversation
…local commits `GatewayOrchestrator._auto_apply_update` ends in `git reset --hard origin/<branch>`. The ordinary `auto_update` trigger can only reach it with the update check's `can_fast_forward` verdict (`behind > 0 and ahead == 0`), so a checkout carrying local commits is never offered to it. The mandatory policy version-floor trigger deliberately bypasses `available` and gates only on `can_apply` — and `can_apply` reports the INSTALL SHAPE (a git checkout has an apply command, a wheel or .deb does not), so it says nothing about how the tree relates to the remote. Every git checkout below the floor reaches the reset. Neither existing check stops that case: a tree carrying local commits has a content diff like any other, so `git diff HEAD origin/<branch> --quiet` passes it through, and the porcelain check only warns about UNCOMMITTED edits before proceeding. A developer checkout running below a policy `min_version` therefore loses its committed local work unattended, leaving only a tracked-file log line. Mirror the fast-forward-only verdict inside `_auto_apply_update` itself: after the fetch, count `git rev-list --left-right --count HEAD...origin/<branch>` and refuse when `ahead > 0`, reporting the counts through the existing update-status surface. `can_fast_forward` is `behind > 0 AND ahead == 0`, so mirroring it means refusing whenever the tree is ahead — an ahead-only checkout (local commits, remote unmoved) loses them to this reset exactly as a diverged one does, and is the likelier shape on a developer box that has simply not pushed. The floor mandate does not need the reset to be its mechanism: a violation that can only be cleared by discarding committed work needs a human. The gate fails CLOSED. This is the most privileged path in the product (no auth, no click, `reset --hard` + pip + execv on boot), so a tree whose relation to the remote cannot be established is not one to hard-reset. Four existing `_fake_exec` helpers dispatch on a call counter, so they answer the new `rev-list` by ARGS and return before the counter is touched; every existing position keeps the meaning it already had. Refs kirodotdev#5163. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
7b38647 to
5682633
Compare
|
🤖 Kiro Crew [operator: NicholasRBowers#a942f9ca]: This PR has been inactive for 7+ days with failing CI. I've assessed the blockers and they appear resolvable — I'll push fixes directly to this branch as a co-author. Assessment: Two blockers, both mechanical: (1) merge conflict with current main; (2) two deterministic failures in If you'd prefer I don't touch this PR, add the |
|
🤖 Kiro Crew [operator: NicholasRBowers#a942f9ca] Drive-to-green halted before any push: this PR has been superseded by work that merged to main after it was opened. While preparing the rebase onto current main, I found that #5387 (
The tracked issue also resolved independently: #5163 was closed 2026-08-25 as a duplicate of #4503, which #5387 fixed. Consequence: resolving this PR's rebase conflicts by re-applying its guard logic onto main's current structure produces an empty diff — the guard is already there. There is nothing left for this PR to add, so there is no "green" to drive it to, and per the drive-to-green rules I will not repurpose or re-scope a contributor's PR. Recommendation: close this PR as superseded by #5387, with credit to @leonlaiyc for independently identifying and correctly diagnosing the version-floor hard-reset data-loss path (the PR predates #5387 and reached the same fail-closed design). @leonlaiyc — if you see a residual delta main still lacks, please comment and this can be revisited. No commits were pushed to this branch; it is untouched at |
|
Closing as superseded by #5387. Current main now contains the same fail-closed protection in a stronger shared implementation, including pinned-target ahead-count handling and regression coverage; rebasing this branch leaves no residual change to contribute. Thanks to the maintainer bot for verifying the overlap without modifying this branch. |
Pull request was closed
Problem / Motivation
GatewayOrchestrator._auto_apply_update(src/kiro_crew/slack/gateway.py) ends ingit reset --hard origin/<branch>. Reached through the mandatory policy version-floor trigger, it does that to a checkout carrying committed local work — and the work is gone.The two triggers do not carry the same guarantee:
auto_updatetrigger is safe by construction. It requires the update check'scan_fast_forwardverdict (behind > 0 and ahead == 0,dashboard/handlers/updates.py:691), so a checkout with local commits is never offered to it.update_required(_running_version)branch) deliberately bypassesavailableand gates only oncan_apply.can_applymirrorsCommandProvider.can_apply()— an apply command exists and can run — so it reports the install shape (a git checkout can apply; a wheel or.debcannot). It says nothing about how the tree relates to the remote. Every git checkout below the floor reaches the reset.Neither pre-existing check in
_auto_apply_updatestops it:git diff HEAD origin/<branch> --quietgit status --porcelainSo a developer checkout running below a policy
min_versionloses committed local work unattended, with only a tracked-file log line as evidence.Why it matters
Unattended, unrecoverable loss of committed work on the most privileged path in the product: no auth, no click,
git reset --hard+pip install+os.execvon boot. The user never sees a prompt, andreset --hardleaves nothing to recover from the working tree — only the reflog, which the affected user has no reason to know to check.The trigger is a policy floor, so the hosts most likely to hit it are exactly the ones with local commits: a developer machine below the org's
min_version.What changed (motivation → approach → change)
Motivation — the destructive step must not run on a tree where it destroys committed work.
Approach — mirror the verdict the safe trigger already relies on, at the site that actually performs the reset, rather than at the callers.
can_fast_forwardlives in the update check; the version-floor path never consults it. Putting the equivalent test inside_auto_apply_updatemeans every future caller inherits it, and no caller has to remember to.Change — after the fetch (so the counts are against the revision this would reset to) and before the reset:
git rev-list --left-right --count HEAD...origin/<branch>;ahead > 0, refuse: log the counts and report them through the existing update-status surface (push_update_progress("failed", …), the same surfacehandlers/updates.pyuses for every other refusal), then return without resetting;The condition is
ahead > 0, notahead > 0 and behind > 0. The issue proposed the diverged case, and the diverged case is real — butcan_fast_forwardisbehind > 0 **AND ahead == 0**, so mirroring it faithfully means refusing whenever the tree is ahead. An ahead-only checkout (local commits, remote unmoved) reaches this reset for exactly the same reason a diverged one does —can_applygates on install shape — and loses its commits just as thoroughly. On a developer box that has simply not pushed yet, ahead-only is the likelier shape. Narrowing to the diverged case would have left the more common half of the same defect open, so it is covered and separately tested.The floor mandate stands without the reset being its mechanism. "This host must not stay below the floor" and "discard this developer's committed work to get there" are different statements, and only the first is policy — a violation that can only be cleared by discarding committed work needs a human, so the host stays below the floor and says why.
The gate fails CLOSED. If
rev-listfails, or returns output that does not parse as two counts, the update is refused rather than proceeding. On a path this privileged, "we could not establish that the reset is safe" is not a reason to reset; it is the same posture as the existing source-pin check above it, which refuses a blocked host rather than assuming.Not changed, deliberately.
#4503already covers this site's handling of uncommitted tracked edits; that path is left exactly as it was so this diff shows one behaviour change.One production file, 84 lines.
Tests
test/test_slack_gateway.py::TestAutoApplyUpdateDivergenceGuard— six cases, all driving the real_auto_apply_updateand asserting on the argv actually handed tocreate_subprocess_exec, so "no reset happened" is observed at the process boundary rather than inferred from a return value. The fake dispatches on the git subcommand rather than a call index, so reordering an unrelated git call cannot make it assert against the wrong process.test_diverged_checkout_is_not_resetreset --hardruns, and the refusal reaches the update-status surface carrying both countstest_ahead_only_checkout_is_not_resettest_fast_forward_checkout_still_updatestest_identical_checkout_still_updatestest_unreadable_counts_fail_closedrev-listexiting non-zero refuses instead of falling throughtest_unparseable_counts_fail_closedrev-listsucceeding with junk is not read as "no local commits"Fail-before / pass-after, with the production file reverted to
origin/mainand the tests left in place:The two preservation tests pass on both trees by design — they are the guard against over-correcting into refusing every update, not evidence of a defect, and are reported as such rather than folded into the fail-before count.
Four existing
_fake_exechelpers were taught the new call (test_venv_update_full_path,test_reset_then_frontend_then_pip,test_no_restart_after_any_unclean_sync_even_when_the_repair_works,test_a_failed_install_with_a_failed_repair_does_not_restart). They dispatch on a call counter, so a new git call in the sequence would have shifted every position after it. Rather than renumber them, each answersrev-listby args and returns before the counter is touched, so every existing position keeps the meaning it already had — and all four still pass against pristineorigin/main, which the control run above confirms.Gates:
flake8·isort --check-only— both clean on the two changed files.black --diffreports no hunk overlapping any changed region in either file; both are pre-existing baseline offenders onorigin/main, so they are left unformatted rather than graduated off.github/black-baseline.txt.Manual verification
N/A — unit coverage sufficient: the assertion is on the exact argv the method hands to
create_subprocess_exec, so the property under test ("nogit reset --hardis issued") is checked at the boundary where the damage would occur. Reproducing by hand means building a checkout with local commits on a host below a policymin_versionand waiting for the floor trigger to fire on boot, which is what these tests drive directly.Related Issues
Refs #5163.
Surfaced by the First Principles review on #5158 (the CLI divergence guard for #5143), which guards the other reset-to-origin apply site,
cli_server.py::_update. That PR is still open; this change touches a different file and does not depend on it.#4503covers the same site's handling of uncommitted tracked edits — deliberately not addressed here.Checklist
feat|fix|docs|refactor|perf|test|chore|ci|build|revert: ...)Contribution License Agreement