Skip to content

drift/plan compare a github_release's live --version with its pin and upstream latest (Refs #613) - #616

Closed
noahgift wants to merge 5 commits into
mainfrom
fix/version-drift
Closed

noahgift wants to merge 5 commits into
mainfrom
fix/version-drift

Conversation

@noahgift

@noahgift noahgift commented Sep 24, 2026 •

Copy link
Copy Markdown
Contributor

Refs #613

The defect

paiml/infra pinned ollama to v0.33.2 on lambda-labs, and the box runs 0.34.2. forjar plan and forjar drift both said nothing, because both trust the lock and a github_release check only asks [ -x <bin> ]. An apply would have silently downgraded the box.

The fix

  • New detector tripwire::drift::version_pin. It works from the declaration, not the lock, so a pinned binary missing from the lock (infra's actual state) is still compared.
    • It runs <bin> --version on the target and parses the first N.N.N at a token boundary. It skips IPs such as 10.42.0.11, 4-part versions and abc1.2.3, and it accepts ollama's Warning: … client version is 0.34.2.
    • Live ≠ pin → DRIFT, with expected: version 0.33.2, actual: version 0.34.2, and a detail saying the apply would DOWNGRADE (or upgrade) the binary.
    • Output with no parseable version → UNMEASURED ("cannot measure"), never clean.
  • Pin behind upstream is also drift. The target asks api.github.com/repos/<repo>/releases/latest with curl, the same way apply_script already fetches releases. If that call fails, the result is UNMEASURED. tag: latest compares live with upstream. tag: nightly is reported as NOT CHECKED.
  • --offline (on both drift and plan) skips the upstream check. The skip is named in the drift census (version pin NOT checked N: id (why), JSON version_not_checked), in the plan rows and in the plan JSON.
  • plan adds a Version pins (live --version vs declared pin): section and a version_pins JSON field. It changes no plan action, because turning version drift into Update would schedule the downgrade it warns about.
  • Kept out of every path that acts on findings:
    • detect_drift_full (the apply drift gate and the pull agent) turns the check off.
    • MCP's unattended drift turns it off and names that ("version pins not checked on this surface").
    • drift --auto-remediate refuses when a version-pin finding exists: "Update the pin in the config instead".

apply never downgrades (added by forjar-d3 on takeover)

The first version kept version findings out of the remediation paths, but apply still downgraded on its own. A lockless github_release has no lock entry, so it plans Create, and its apply_script installed the pin over the newer live binary. That is the exact lambda-labs case. The same happens for a locked one whose hash drifted and is re-applied by the drift gate.

github_release::apply_script now opens with downgrade_guard:

  • If the pin names a version and the live binary answers --version with a newer one, the script exits 1 before any download. The message is ERROR: refusing to downgrade <repo>: the pin is X. Update the pin to the live version, or remove the binary to roll back on purpose. Live: Y.
  • It reads the version the way parse_semver does: the first N.N.N at a token boundary, with ollama's warning lines tolerated. An IP or a 4-part run is not a version.
  • If no version can be read, the install proceeds, because this resource also repairs broken binaries.
  • latest and nightly are not ordered against the box.
  • Every path ends in this script (a lockless create, the drift gate, --force, the pull agent), so this is the one on-box place to stop a downgrade.

src/resources/tests_github_release_downgrade.rs EXECUTES the generated script with a fake binary and a fake curl, so no test touches the network. It covers refusals across patch, minor and major; controls (older, equal, no binary, no parseable version, latest/nightly); and the script passing forjar's own bashrs gate. Mutant (downgrade_guard returns empty): 4 of 6 go red. The 2 control tests stay green, as they should.

tests/falsification_github_release_never_downgrades.rs runs the real forjar apply against localhost with a fresh state dir. The live "ollama" is a script that prints a version, and a fake curl first on PATH records that the download was reached. A v0.33.2 pin over a live 0.34.2 must not reach the download, and the binary must be unchanged. The control: an upgrade from 0.33.1 must reach it. Mutant (downgrade_guard returns empty): the refusal test goes red with "the download of v0.33.2 started over a live 0.34.2", and the control stays green.

Quorum round 1 (f3ad95f): 3/3 PASS, one finding fixed anyway

The guard rejected a version followed by a period at end of line (client version is 0.34.2.), which drift's parse_semver accepts. The two disagreed, and the downgrade went through. It is fixed in ebf82324, and fj613_apply_reads_the_live_version_the_way_drift_does goes red with the old pattern. A second note, that drift --dry-run never shows the version probe, is listed under follow-ups.

Quorum round 2 (ebf8232): 3/3 PASS

The only notes were two asserted, non-blocking ones, listed under follow-ups. e6fa61af then added the real-binary falsifier, because forjar's receipt gate runs a --test target, and the downgrade guard's tests were all unit tests.

Quorum round 3 (e6fa61a): 3/3 PASS

This round judged the integration falsifier. Lane 3 (claude-sonnet-5) made two non-blocking notes, both already follow-ups. First, a pin that is not yet installed but is behind upstream is still reported. Second, the --version probe has no timeout.

file-health fix (d6222c2): delta round 3/3 PASS

Two files were over forjar's 500-line ratchet: src/cli/drift.rs had 551 lines and the new src/tripwire/drift/version_pin.rs had 516. d6222c2c only moves code:

  • The drift --dry-run trio goes to src/cli/drift_dry_run.rs.
  • Semver, parse_semver and their helpers go to src/tripwire/drift/semver.rs.

Re-exports keep every caller path (drift::cmd_drift_dry_run, version_pin::parse_semver). The moved bodies are byte-identical to the originals (checked with diff). A delta round of claude-sonnet-5, claude-haiku-4-5 and claude-sonnet-5 returned 3/3 PASS.

Red / green

The headline test is tripwire::drift::tests_version_pin::fj613_live_version_ahead_of_pin_is_drift_even_when_the_lock_agrees. A fake ollama prints ollama version is 0.34.2 under tag: v0.33.2, with a converged lock entry baselined after the install. That means the digest path compares the box with itself, which is exactly the blind spot.

The test cannot compile against pristine main, because DriftOptions has no check_upstream there and the test must stay off the network. So "revert the fix" means reverse-applying just the two version_pin::detect(...) call sites in drift/mod.rs and drift/lockless.rs (git apply -R wiring.patch). The module stays in place, but nothing calls it.

RED (fix reverted):

$ git apply -R wiring.patch && cargo test --lib tests_version_pin::fj613_live_version_ahead_of_pin_is_drift_even_when_the_lock_agrees
test tripwire::drift::tests_version_pin::fj613_live_version_ahead_of_pin_is_drift_even_when_the_lock_agrees ... FAILED
assertion `left == right` failed: live ollama 0.34.2 under a v0.33.2 pin must be DRIFT; got []
  left: 0
 right: 1
test result: FAILED. 0 passed; 1 failed; 0 ignored; 0 measured; 13572 filtered out

GREEN (fix restored):

$ git apply wiring.patch && cargo test --lib tests_version_pin::fj613_live_version_ahead_of_pin_is_drift_even_when_the_lock_agrees
test tripwire::drift::tests_version_pin::fj613_live_version_ahead_of_pin_is_drift_even_when_the_lock_agrees ... ok
test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 13572 filtered out

The remediation-exclusion test can also fail. With detect_drift_full switched to check_version_pins: true, fj613_remediation_wrapper_never_sees_version_findings goes FAILED, and it passes again after the switch is reverted.

All 16 fj613_* tests pass. They cover parsing, the pure verdict table, the lockless path, a pin absent from the lock, unparseable output → unmeasured, and the disabled surface being named.

End-to-end (real CLI, fake binary, no apply)

The lock exists but has no entry for the resource, which is lambda-labs' shape:

$ forjar drift -f forjar.yaml --state-dir state --offline
  inspected 1 of 1 resource(s) in scope: github_release 1
  version pin NOT checked 1: ollama-binary (upstream not checked (offline): pin 0.33.2 was not compared with the latest ollama/ollama release)
  DRIFTED: ollama-binary on box (live ollama is 0.34.2, declared pin is 0.33.2; apply would DOWNGRADE it)
    Expected: version 0.33.2
    Actual:   version 0.34.2
error: 1 drift finding(s)                                   # exit 1

$ forjar plan -f forjar.yaml --state-dir state              # upstream on
Version pins (live --version vs declared pin):
  DRIFTED ollama-binary (box): live ollama is 0.34.2, declared pin is 0.33.2; apply would DOWNGRADE it
  DRIFTED ollama-binary (box): pin 0.33.2 is behind the latest ollama/ollama release 0.34.4

$ forjar drift ... --offline --auto-remediate
error: refusing --auto-remediate: 1 version-pin finding(s); re-applying would move a live binary to its pin (possibly a downgrade). Update the pin in the config instead

Checks

  • cargo fmt --all -- --check: clean.
  • cargo clippy --all-targets -- -D warnings: clean.
  • cargo test --lib: 13568 passed, 1 failed. The failure is core::store::tests_conda::test_fj1349_store_hash_deterministic, which is conda-only and not touched here. It passes 3/3 in isolation, so it flaked under parallel load.
  • 41 related integration targets (falsification_*drift*, *plan*, *github_release*, *mcp*): all green.
  • cargo test --lib --tests with the downgrade guard (now f3ad95fb): 18443 passed, 2 failed, neither caused by this diff. falsification_release_workflow_shape::rule2_… also fails on main (PMAT-604 added v* tag pushes to three workflows). falsification_replace_running_binary::cargo_provider_replaces_a_running_binary fails only on the lambda-labs host: that host's cargo wrapper (paiml/infra#930) finds the real cargo through $HOME, which the test overrides. With LAMBDA_MEMCAP_REAL_CARGO set, it passes 6/6.

Not fixed here (follow-ups)

  • drift --dry-run does not preview the version checks, and ignores --offline (quorum round 1, lane 3).
  • The <bin> --version probe in version_pin.rs has no timeout, while the upstream curl has --max-time 20. A hung binary would stall drift and plan. macOS has no timeout command, so the fix needs a portable form (quorum round 2, lane 3).
  • plan now runs the target binary and asks upstream by default (--offline turns that off). That widens plan's side effects, and it is documented (quorum round 2, lane 3).
  • infra's ollama-binary is actually type: task (a grep '0\.33\.2' completion_check), not github_release, so this detector does not cover it until it is converted. Converting it is blocked because github_release apply handles only .tar.gz/.zip, not .tar.zst, and installs one binary, not ollama's lib/ tree.
  • Pre-existing: drift over a state dir that exists but holds no lock for a declared machine prints 0 resource(s) inspected … No drift detected and does not fall back to the lockless scan, which runs only when the state dir is absent (forjar#385).

🤖 Generated with Claude Code

noahgift and others added 5 commits September 24, 2026 10:54
… upstream latest (Refs #613)

paiml/infra pinned ollama to v0.33.2 on a box running 0.34.2. `plan` said
Create, `drift` said nothing, and the apply would have downgraded it: both
trusted the lock and asked only whether the binary exists.

- New detector tripwire::drift::version_pin, run from the DECLARATION (so a
  pinned binary that is absent from the lock is still compared). It runs
  `<bin> --version` on the target, parses a semver at a token boundary, and
  reports DRIFT when the live version differs from the pin. Output with no
  parseable version is UNMEASURED ("cannot measure"), never clean.
- It also reports a pin behind the repo's latest GitHub release as drift. It
  asks api.github.com from the target with curl, the same way apply_script
  already fetches releases. `--offline` (drift and plan) skips that check and
  the census, plan rows and JSON NAME the skip. `tag: nightly` is NOT CHECKED
  and named.
- `plan` gains a "Version pins" section plus a `version_pins` JSON field. It
  changes no plan action, because turning version drift into Update would
  schedule the downgrade it warns about.
- Kept out of every path that ACTS on findings: detect_drift_full (apply
  gate, pull agent) and MCP's unattended drift turn the check off and name
  that; `drift --auto-remediate` refuses when version-pin findings exist.

Pmat-Ticket: PMAT-613
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ive binary is newer than a version pin

The guard runs first in apply_script's present branch, so every path that
installs (lockless create, drift gate, --force, pull agent) goes through it.
It reads the live version the way drift does and refuses before the download,
naming the pin and the live version. Unversioned pins are not ordered.
Six tests execute the generated script against a fake binary and a fake curl;
four go red with the guard emptied, the two controls stay green.

Pmat-Ticket: PMAT-613
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Quorum round 1 (lane 3, claude-sonnet-5): the guard's trailer rejected a
period at end of line, so `client version is 0.34.2.` read as no version and
the install went through, while drift's parse_semver reads 0.34.2 from the
same output. A period followed by end of line now ends a version; a
four-part run is still refused.

Pmat-Ticket: PMAT-613
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…inary

The unit tests execute the generated script. This one runs the forjar binary
against localhost with a fresh state dir: a v0.33.2 pin over a live 0.34.2
must not reach the download, and an upgrade from 0.33.1 still must. RED with
`downgrade_guard` returning empty ("the download of v0.33.2 started over a
live 0.34.2"); the upgrade control stays green.

Pmat-Ticket: PMAT-613
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
file-health would fail this branch for two files. src/cli/drift.rs went
from 490 to 551 lines, and the new src/tripwire/drift/version_pin.rs had 516.

- The drift --dry-run trio (record_dry_run_checks, print_dry_run_report,
  cmd_drift_dry_run) moves to src/cli/drift_dry_run.rs. drift.rs re-exports
  it, so every existing caller path still resolves. drift.rs is now 463 lines.
- Semver, parse_semver and its byte helpers move to
  src/tripwire/drift/semver.rs. version_pin re-exports them, so
  version_pin::parse_semver is unchanged. version_pin.rs is now 427 lines.

Both moved bodies are byte-identical to the originals (checked with diff).
clippy -D warnings is clean. The 547 drift, version_pin and downgrade lib
tests and the 2 integration falsifiers pass.

Pmat-Ticket: PMAT-613
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
@noahgift

Copy link
Copy Markdown
Contributor Author

folded into #628 (emergency fold, operator 2026-09-24)

@noahgift noahgift closed this Sep 24, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant