fix(update): gate auto-update on an allowlist, and harden the path it re-enables - #5387
Conversation
Design Review (Fable 5) — 🟡 CONCERNSDesign-level review of Design-Verdict: CONCERNS Sound root-cause fix and well-derived hardening, but the hardening is bound to one call site while sibling update paths spawn bare Watch
Suggestions
[DESIGN-REVIEWED] 5c6714b |
Opus 4.8 Review — ✅ no blocking findingsReviewed Review detailsI've analyzed both candidates against the code, the actual install path, and git's transport behavior. Candidate 1 (
Candidate 2 ( Neither reaches the survival bar; I found nothing else in the changed lines that grounds all three of (a)/(b)/(c) at 80+. No findings. [OPUS-REVIEWED] 5c6714b Verdict parsed from the review's SHA-scoped output markers for commit False positive or not applicable? A repository writer can comment: |
GPT 5.6 Review — ✅ human override acceptedHuman judgment by @iamwhatever overrides the GPT 5.6 finding for This comment is updated in place on each push. The model was not re-run because an authorized human decision supersedes it. False positive or not applicable? A repository writer can comment: |
First Principles Review (Fable 5) — 🟡 CONCERNSPremise-level review of Reading the contract, the intent file, the full patch, and the surrounding repo (git_divergence, updates.py, the three app-side neutralizer copies, security.py) is done — here is the review in the contract's required shape. First-Principles-Verdict: CONCERNS
What this change shipsIntent: make git checkouts of this repo actually receive unattended boot-time updates again, without re-enabling the path's latent data-loss and code-exec holes — a FIX plus derived hardening.
Watch
Subtractions
[FIRST-PRINCIPLES-REVIEWED] 5c6714b |
113e8ec to
b433029
Compare
b433029 to
6068692
Compare
6068692 to
512d182
Compare
512d182 to
02c6991
Compare
da20e3a to
d2db7ff
Compare
d2db7ff to
8a70a87
Compare
Dispositions — GPT 5.6 round on
|
Dispositions — GPT 5.6 round on
|
Dispositions — GPT 5.6 round on
|
Dispositions — GPT 5.6 round on
|
Dispositions — GPT 5.6 round on
|
Dispositions — Opus 4.8 + GPT 5.6 rounds on
|
CI fix — shard 4
|
Dispositions — GPT 5.6 round on
|
Dispositions — GPT 5.6 round on
|
CI fix — Windows shard 4, my own test againHead is now
Two sites, both mine: the round-19 log-encodability test and the round-19 Skipping is the correct outcome, not a workaround: Windows filenames are UTF-16, so there is no invalid-byte name for the hazard to exist in. The guard just has to be reached instead of crashed into. Verified rather than assumed, since I cannot run the Windows shard locally: a pytest plugin overrides instead of erroring. Worth noting what I got wrong in my own diagnosis: my first hypothesis was My So this round fixes two things — the test guard that actually broke the shard, and a Windows-only false refusal that the shard would not have caught because the flag made the test skip there anyway. Local gates on |
Dispositions — GPT 5.6 round on
|
Dispositions — GPT 5.6 round on
|
… re-enables
The unattended boot-time auto-update was gated on `branch != "mainline"`,
inherited verbatim from the internal repo whose primary line carries that
name. This repo's primary line is `main`, so the gate matched nothing and
returned at `logger.debug` — every git checkout, which is the documented
`install.sh` path (`git clone` + `pip install -e .`), silently stopped
receiving updates, with nothing in the logs to say why.
Desktop bundles (electron-updater) and managed wheels were unaffected, and
`kirocrew update` was unaffected because `cli_server.py` fetches the branch it
actually detected. So a released fix reached those installs but required a
manual update on every checkout.
Because that gate had matched nothing for three months, everything downstream
of it was dead code — and its latent defects become live the moment the gate
starts passing. This lands the gate fix together with the guards it needs.
1. PRIMARY-BRANCH ALLOWLIST
`is_primary_branch` reads `PRIMARY_BRANCHES`, a literal in reviewed code, and
nothing else. Resolving the name from `refs/remotes/<remote>/HEAD` instead is
the obvious alternative and is wrong in both directions: that is a LOCAL ref,
one `git remote set-head` from being repointed. Obeying it lets a repoint aim
the boot-time `git reset --hard` + `pip install` + `execv` at an arbitrary
branch of the still-approved origin (the source pin cannot catch it — the
remote URL is unchanged); letting it merely narrow turns the same one-command
repoint into a veto that strands a host below the administrator's
`min_version`, since `_auto_apply_update` is what `update_required` drives on a
checkout. A test fails if any `subprocess.run` is reached from that decision.
2. REPO-CONTROLLED GIT EXEC CONFIG
`git status` and `git diff` do not merely read config — they SPAWN the program
named in `core.fsmonitor`, and a reset runs hooks. These five git invocations
ran on an agent-writable tree with none of that neutralized.
Keys whose NAME is fixed are pinned via `GIT_CONFIG_*` (same precedence as
`-c`), bound once ahead of the first spawn so the whole sequence is covered.
The list carries its membership criterion in a comment — "git may exec this
value, and the key is a literal" — because the first version was an
enumeration without one and was therefore missing `core.gitProxy`, along with
`core.askPass`, `core.alternateRefsCommand`, `uploadpack.packObjectsHook`, the
pager/editor keys and `gpg.program`. Verified empirically in a test that fails
if the pin stops working: a repo-planted fsmonitor program executes without it
and does not with it.
Keys whose NAME is repo-chosen cannot be pinned at all — there is nothing to
override — so those are REFUSED: `filter.<name>.{process,smudge,clean}`,
`diff.<driver>.{textconv,command}` (`command` REPLACES the diff with an
external program, where `textconv` only converts a blob, and both are reached
by the `git diff` this path runs), and `credential.<url>.helper`, whose per-URL
form the pinned bare `credential.helper` does not reach. Same call
`worktree._checkout_filter` makes for `worktree add`. Both config scopes are
probed with `--includes`, and both details are load-bearing: a `--local`
listing does not report worktree-scoped keys, and for a specific-scope query
git defaults include-following off, so a driver reached via `include.path`
resolves at run time while staying invisible to the probe.
A REDIRECTED WORK TREE is the same family but not an exec vector: repo config
can point `core.worktree` elsewhere and the `git reset --hard` then overwrites
matching files THERE, with nothing executed. It cannot be pinned away —
verified that git ignores `core.worktree` supplied through `GIT_CONFIG_*` (a
repo-set value still won), and the `GIT_WORK_TREE` that does override it is
refused without a matching `GIT_DIR` — so it is refused instead, by asking git
where the tree actually resolves. That one probe catches a relative value, a
worktree-scoped one, and one reached through `include.path`; `realpath` on both
sides keeps a symlinked checkout (this repo is reached through one) from
reading as a redirect, and a legitimate linked worktree resolves to the
directory being operated on, so it is unaffected.
Every case is covered against real git repos, because they exist precisely
where a mock would not reproduce git's own resolution.
3. CHECK / APPLY REF MISMATCH
The availability check compares `HEAD` against `@{u}` — whatever the branch
tracks (`dashboard/handlers/updates.py`) — while the apply resets to
`origin/<branch>`. When those are not the same ref the check measures one thing
and a `--hard` reset applies another, so the gap is lost commits rather than a
stale answer. `tracks_upstream` requires BOTH halves to match, because either
alone leaves it open: the remote (a fork whose `main` tracks `upstream/main`
while `origin` is the user's stale fork) and the branch
(`branch.main.remote=origin` with `branch.main.merge=refs/heads/other`, which
points `@{u}` at `origin/other` while the reset targets `origin/main`).
Two other behaviour changes worth calling out:
- A detached HEAD is no longer primary. The old code fabricated
`branch = "mainline"` for it, which on an internal clone would have let a
boot-time `git reset --hard` move a deliberately detached checkout.
- The skip logs at `info` rather than `debug`, so an operator can see why a
host is not updating. The sibling wheel-mismatch branch already warns; this
one said nothing at all.
`PRIMARY_BRANCHES` mirrors `security._PROTECTED_BRANCHES` by intent — the
branch an unattended update may reset to is exactly the branch a push must
never target — but is kept separate so update routing does not reach into a
security-module private. The neutralizer list likewise mirrors the app-side git
callers; `platform/` must not import from `apps/`, and a test asserts the
driver regex stays in agreement with the worktree gate's.
The fixture helpers in `test_governance_updates` build real repos, so they run
git with templates, hooks and identity neutralized: `git init` COPIES a
template directory's hooks and the following `git commit` runs them, which
turned an inherited `GIT_TEMPLATE_DIR` into host-side execution just from
running the suite.
`test_spawn_audit`'s allowlist entry moves with the subprocess call, from
`resolve_remote_url`'s former nested helper to the shared `_git_probe`, with
the justification extended to the new callers.
Not changed: `cli_server.py`'s `or "mainline"` fallback for a detached HEAD. It
is reached only when branch detection returns nothing and then fails loudly at
`git fetch origin mainline`, so it is a confusing-error wart on a
user-initiated path rather than a silent no-op.
Tests: 78 in `test_governance_updates`, plus `TestAutoApplyUpdatePreconditions`
asserting the gateway HONOURS both refusals before spawning anything that would
run a driver or fetch. The three existing `_auto_apply_update` classes gain an
autouse fixture neutralizing the new preconditions, so they keep covering the
reset sequence instead of passing vacuously by refusing first.
Mutation-verified twelve ways: restoring the `mainline` hardcode fails 4;
reintroducing a pointer read fails 3; unpinning `core.fsmonitor` fails 2
(including the real-git exec test); dropping `core.gitProxy` fails the
criterion test; dropping `--includes`, the `--worktree` scope, the
namespaced-credential branch, and the `diff.command` branch each fail their own
case; dropping the work-tree refusal fails the redirect case; dropping either
half of the upstream check fails 1-2; removing either gateway refusal fails its
own test; and removing the fixture git neutralizers reproduces the inherited
template hook running.
no linked issue: found while investigating why a released telemetry fix was not
reaching git-clone installs; no tracker item was filed for the gate itself.
Dispositions — GPT 5.6 round on
|
|
/ai-review override gpt 5c6714b: Removing |
Human judgment recorded@iamwhatever marked the gpt AI finding as false positive, not applicable, or explicitly accepted for
This decision applies only to this commit. A new push requires a new judgment. |
Summary
The unattended boot-time auto-update was gated on
branch != "mainline", inherited verbatim from the internal repo whose primary line carries that name. This repo's primary line ismain, so the gate matched nothing and returned atlogger.debug— every git checkout, which is the documentedinstall.shpath (git clone+pip install -e .), silently stopped receiving updates with nothing in the logs to say why.Because that gate had matched nothing for three months, everything downstream of it was dead code — and its latent defects go live the moment the gate starts passing. So this lands the gate fix together with the two defects that guard it, rather than re-enabling a path with known holes.
How this surfaced
A released fix (ACP
clientInfo.name, shipped in0.3.0) was not showing the expected jump in telemetry. Publishing was fine end to end — the stable feed advertises0.3.0, and the published wheel carries the fix:The artifact was correct and reachable; the apply side was dropping a whole cohort.
dmg/appimage/deb/rpm)_auto_apply_wheel_update()source(git checkout)_auto_apply_update()→ this gatedocker, source tarballIntroduced in
64e47961a(2026-06-02), the initial de-Amazoned fork commit. No test covered the branch name.security.pyalready documents the correct answer two files away.1. Primary-branch allowlist
is_primary_branchreadsPRIMARY_BRANCHES— a literal in reviewed code — and nothing else.Resolving the name from
refs/remotes/<remote>/HEADis the obvious alternative. I shipped it first and it is wrong in both directions; thanks to the review bot for catching each half. That is a local ref, onegit remote set-headfrom being repointed by anything with write access to the checkout:git reset --hard+pip install+execvat an arbitrary branch of the still-approved origin. The source pin cannot catch it — the remote URL is unchanged.maincheckout's pointer atmainlineand the host silently stops updating, including for a mandatory floor, since_auto_apply_update()is whatupdate_required()drives on a checkout (gateway.py). That strands the host below the administrator'smin_version.A test fails if any
subprocess.runis reached from that decision.Cost, stated plainly: a fork whose primary line is named something else (
develop,trunk) gets no unattended update, only the badge.kirocrew updateand the dashboard apply path still serve it, and both have a human in the loop.2. Repo-controlled git exec config
git statusandgit diffdo not merely read config — they spawn the program named incore.fsmonitor, and a reset runs hooks. All five git invocations here ran on an agent-writable tree with none of that neutralized.Keys whose name is fixed are pinned via
GIT_CONFIG_*(same precedence as-c), bound once ahead of the first spawn so the whole sequence is covered and a later-added command cannot quietly opt out.The list now carries its membership criterion in a comment — "git may exec this value, and the key is a literal" — because the first version was an enumeration without one and was therefore missing
core.gitProxy(caught in review), along withcore.askPass,core.alternateRefsCommand,uploadpack.packObjectsHook, the pager/editor keys andgpg.program. A test asserts the criterion over the whole set rather than one key at a time, so the next omission fails here instead of in review.Verified empirically, asserted in both directions so it cannot pass vacuously:
Keys whose name is repo-chosen are refused, not pinned — there is nothing to override. Same call
worktree._checkout_filtermakes forworktree add. Both scopes are probed with--includes, and both details are load-bearing:--localfilter.evil.smudge--worktreefilter.evil.process(a--locallisting does not report it)include.path→filter.evil.clean(invisible without--includes)diff.evil.textconvcredential.<url>.helper(per-URL, so the pinned bare key misses it)Each is tested against a real git repo, because these cases exist precisely where a mock would not reproduce git's own resolution. The
include.pathtest asserts git itself resolves the driver first, so it cannot pass on a broken fixture.3. Check / apply ref mismatch
The availability check compares
HEADagainst@{u}— whatever the branch tracks (dashboard/handlers/updates.py) — while the apply resets toorigin/<branch>. When those are not the same ref the check measures one thing and a--hardreset applies another, so the gap is lost commits rather than a stale answer.tracks_upstreamrequires both halves of the upstream to match, because either alone leaves it open:maintracksupstream/mainwhileoriginis the user's own stale fork;branch.main.remote=originwithbranch.main.merge=refs/heads/other, which still points@{u}atorigin/otherwhile the reset targetsorigin/main. (The remote-only check was the first version; the branch half was caught in review.)Two other intentional behaviour changes
branch = "mainline"for it, which on an internal clone would have let a boot-timegit reset --hardmove a deliberately detached checkout.info, notdebug, so an operator can see why a host is not updating. The sibling wheel-mismatch branch already warns; this one said nothing at all.Notes for review
PRIMARY_BRANCHESmirrorssecurity._PROTECTED_BRANCHESby intent (the branch an unattended update may reset to is exactly the branch a push must never target) but is kept separate so update routing does not reach into a security-module private. The neutralizer list likewise mirrors the app-side git callers —platform/must not import fromapps/— and a test asserts the driver regex stays in agreement with the worktree gate's.test_spawn_audit's allowlist entry moves with the subprocess call, fromresolve_remote_url's former nested helper to the shared_git_probe, justification extended to the new callers._auto_apply_updatetest classes gain an autouse fixture neutralizing the new preconditions, so they keep covering the reset sequence instead of passing vacuously by refusing first.cli_server.py'sor "mainline"fallback for a detached HEAD. Reached only when branch detection returns nothing, then fails loudly atgit fetch origin mainline— a confusing-error wart on a user-initiated path, not a silent no-op.Testing
71 cases in
test_governance_updates+TestAutoApplyUpdatePreconditionsintest_slack_gateway, which asserts the gateway honours both refusals before spawning anything that would run a driver or fetch.Mutation-verified nine ways: restoring the
mainlinehardcode fails 4 (the real-checkout test included); reintroducing a pointer read fails 3; unpinningcore.fsmonitorfails 2 (including the real-git exec test); droppingcore.gitProxyfails the criterion test; dropping--includesfails the include case; dropping the--worktreescope fails the worktree case; dropping the namespaced-credential branch fails its case; dropping either half of the upstream check fails 1–2; and removing either gateway refusal fails its own test.Local gates green:
pytest538 across the update/gateway/governance/spawn-audit/cli-server suites,flake8,isort,mypy, black baseline gate, woke scan of added lines.