plugin update compares no version: an OLDER package auto-applies as an update (measured, 0.0.1 over 9.9.9) #255

Closed
opened 2026-09-10 16:12:13 +02:00 by buildagent · 0 comments
Member

What was measured

code-index plugin update applied version 0.0.1 over an installed 9.9.9 of the same package, printed applied, and made the older digest the live one. Every one of #237's authority conditions was satisfied, because none of them is about the version.

Black box: shipped binaries only (target/debug at ee39fd1), an operator-generated publisher key via plugin key generate / plugin sign / plugin trust add, and python3 -m http.server as the source. No test harness, no fixtures crate.

Repro

Two copies of tests/packages/xaml, identical but for [package] version:

installed version: version = "9.9.9"
served    version: version = "0.0.1"
operator key fingerprint: sha256:ae83cb73d4003ef23be6b1283c41d5d9a1619838bc5eb71c23e689c0f5a0ea51
hi: digest=sha256:825fb17653969661ae734239f70ef69b99348ead92d4b05e47efaac6f251ec23
hi: extraction_identity=sha256:7beb5e88b193a824865b0fa9481dd78ca968a7205341610e75fa139384bdc4a7
lo: digest=sha256:b02db7882198bb28d560ec0299d6954cbaf2c00c26f21c815c8a20d96f636890
lo: extraction_identity=sha256:7beb5e88b193a824865b0fa9481dd78ca968a7205341610e75fa139384bdc4a7

Note the two extraction_identity values are equal — package.version is excluded from it by design — so the cost clause does not fire either.

Install 9.9.9, then configure the older one as the update source:

[update."de.h-dv.xaml"]
source = "http://127.0.0.1:18731/lo.cip"
auto_apply = true

code-index plugin update:

note                configured: de.h-dv.xaml from http://127.0.0.1:18731/lo.cip (auto_apply true)
update              de.h-dv.xaml applied sha256:825fb...ec23 -> sha256:b02db...6890 (version 0.0.1)
update_notifications 0 of 1 configured package(s) have something to act on

code-index plugin status afterwards:

approved  sha256:825fb...ec23  de.h-dv.xaml  …  [NOT ACTIVE — sha256:b02db...6890 is the live digest of `de.h-dv.xaml` here (package_id_already_active)]
approved  sha256:b02db...6890  de.h-dv.xaml  …

The 0.0.1 digest is live.

Why every condition passed

crates/indexer/src/update.rs:283-485 compares package id, publisher fingerprint, granted capabilities/bridges/derived_names (subset), [[displaces]] rows, the declined tombstone, and extraction_identity. There is no ordering or monotonicity comparison anywhere, and update_one's only identity short-circuit is if candidate.digest == parsed_current (crates/cli/src/plugin.rs:3866) — equal, not newer. A downgrade differs from an upgrade in exactly the field nothing reads.

Why it matters

UpdateConfig holds no digest pin, by design (crates/indexer/src/config.rs:150-160: a pin "would have to be edited on every release"), and its own doc already accepts that "a VALIDLY SIGNED BUT DIFFERENT package can be served … the set a server may substitute from is one publisher's, and every member of it is then held to conditions 2 through 4". The measurement above is that conditions 2-4 do not exclude an earlier member of that set. So anyone who can choose the bytes at the configured URL — the publisher, a compromised release pipeline, or a network position, since the transport is trusted for nothing here — can roll a project back to a superseded version of the same package with the same grant. That is the one substitution the design says the authority clauses catch, and it is not caught.

auto_apply = false (the default) is unaffected in practice: a human reads the line, and the line does print (version 0.0.1). The exposure is auto_apply = true, where by construction nobody reads it. The version is disclosed but not graded.

What is not claimed

This is not a signature or trust bypass: the bytes are genuinely signed by the anchored publisher. It is that "nothing about the authority changed" is currently true of a downgrade.

Suggested shape

One clause beside the others in assess_auto_apply, with its own reason code (update_version_not_newer), comparing candidate.version to current.version. Two things worth deciding explicitly rather than by default:

  • an unparseable or non-semver version must refuse rather than compare as equal — the Err arm is where this class of clause usually leaks;
  • equal versions with different digests are already possible (the extractor changed without a version bump — 80-operator-recovery.md §9 says so). That is arguably a refusal too, and is a separate decision from ordering.

Per the repo's "generic mechanisms, not a fix per case" rule this is one clause resting on a structural fact (versions are ordered), not a special case.

Test gap it sits in

crates/cli/tests/plugin_update_cli.rs has 15 tests and a refusal arm for every other compared field. There is no version test because there is no version clause. A fix needs one, plus the mutation showing it can fail.

Found by Lane C while walking the operator install/update path against a published release.

## What was measured `code-index plugin update` applied **version 0.0.1 over an installed 9.9.9** of the same package, printed `applied`, and made the older digest the live one. Every one of #237's authority conditions was satisfied, because none of them is about the version. Black box: shipped binaries only (`target/debug` at `ee39fd1`), an operator-generated publisher key via `plugin key generate` / `plugin sign` / `plugin trust add`, and `python3 -m http.server` as the source. No test harness, no fixtures crate. ### Repro Two copies of `tests/packages/xaml`, identical but for `[package] version`: ``` installed version: version = "9.9.9" served version: version = "0.0.1" operator key fingerprint: sha256:ae83cb73d4003ef23be6b1283c41d5d9a1619838bc5eb71c23e689c0f5a0ea51 hi: digest=sha256:825fb17653969661ae734239f70ef69b99348ead92d4b05e47efaac6f251ec23 hi: extraction_identity=sha256:7beb5e88b193a824865b0fa9481dd78ca968a7205341610e75fa139384bdc4a7 lo: digest=sha256:b02db7882198bb28d560ec0299d6954cbaf2c00c26f21c815c8a20d96f636890 lo: extraction_identity=sha256:7beb5e88b193a824865b0fa9481dd78ca968a7205341610e75fa139384bdc4a7 ``` Note the two `extraction_identity` values are **equal** — `package.version` is excluded from it by design — so the cost clause does not fire either. Install 9.9.9, then configure the *older* one as the update source: ```toml [update."de.h-dv.xaml"] source = "http://127.0.0.1:18731/lo.cip" auto_apply = true ``` `code-index plugin update`: ``` note configured: de.h-dv.xaml from http://127.0.0.1:18731/lo.cip (auto_apply true) update de.h-dv.xaml applied sha256:825fb...ec23 -> sha256:b02db...6890 (version 0.0.1) update_notifications 0 of 1 configured package(s) have something to act on ``` `code-index plugin status` afterwards: ``` approved sha256:825fb...ec23 de.h-dv.xaml … [NOT ACTIVE — sha256:b02db...6890 is the live digest of `de.h-dv.xaml` here (package_id_already_active)] approved sha256:b02db...6890 de.h-dv.xaml … ``` The 0.0.1 digest is live. ## Why every condition passed `crates/indexer/src/update.rs:283-485` compares package id, publisher fingerprint, granted capabilities/bridges/`derived_names` (subset), `[[displaces]]` rows, the `declined` tombstone, and `extraction_identity`. **There is no ordering or monotonicity comparison anywhere**, and `update_one`'s only identity short-circuit is `if candidate.digest == parsed_current` (`crates/cli/src/plugin.rs:3866`) — equal, not newer. A downgrade differs from an upgrade in exactly the field nothing reads. ## Why it matters `UpdateConfig` holds **no digest pin, by design** (`crates/indexer/src/config.rs:150-160`: a pin "would have to be edited on every release"), and its own doc already accepts that "a VALIDLY SIGNED BUT DIFFERENT package can be served … the set a server may substitute from is one publisher's, and every member of it is then held to conditions 2 through 4". The measurement above is that **conditions 2-4 do not exclude an earlier member of that set.** So anyone who can choose the bytes at the configured URL — the publisher, a compromised release pipeline, or a network position, since the transport is trusted for nothing here — can roll a project back to a superseded version of the same package with the same grant. That is the one substitution the design says the authority clauses catch, and it is not caught. `auto_apply = false` (the default) is unaffected in practice: a human reads the line, and the line *does* print `(version 0.0.1)`. The exposure is `auto_apply = true`, where by construction nobody reads it. **The version is disclosed but not graded.** ## What is not claimed This is not a signature or trust bypass: the bytes are genuinely signed by the anchored publisher. It is that "nothing about the authority changed" is currently true of a downgrade. ## Suggested shape One clause beside the others in `assess_auto_apply`, with its own reason code (`update_version_not_newer`), comparing `candidate.version` to `current.version`. Two things worth deciding explicitly rather than by default: * **an unparseable or non-semver version** must refuse rather than compare as equal — the `Err` arm is where this class of clause usually leaks; * **equal versions with different digests** are already possible (the extractor changed without a version bump — `80-operator-recovery.md` §9 says so). That is arguably a refusal too, and is a separate decision from ordering. Per the repo's "generic mechanisms, not a fix per case" rule this is one clause resting on a structural fact (versions are ordered), not a special case. ## Test gap it sits in `crates/cli/tests/plugin_update_cli.rs` has 15 tests and a refusal arm for every *other* compared field. There is no version test because there is no version clause. A fix needs one, plus the mutation showing it can fail. Found by Lane C while walking the operator install/update path against a published release.
Sign in to join this conversation.
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set

Reference
h-dv/code-index#255
No description provided.