Plugin integrity verification, plugin discovery, CI, and the release workflow#1
Merged
stevenwcarter merged 26 commits intoJul 25, 2026
Merged
Conversation
…elease Covers W19 (verify a plugin .wasm against its recorded checksum before instantiation), W49 (curated registry/index.json + `plugin search`), greenfield GitHub Actions CI, and the tag-triggered release workflow. Claude-Session: https://claude.ai/code/session_01QUGuvDUj6gG5xQPiN3X2bG
- Create registry/index.json with the five bundled plugins (weather, counter, filewatch, httpget, cmdrun) and their capabilities. - Implement plugin_index module with: - PluginIndex and IndexEntry wire types (serde-serializable) - parse_index() for JSON text and parse_index_value() for JSON values - filter_entries() for case-insensitive name/description search - index_is_fresh() for TTL-based cache freshness - Schema versioning to reject unsupported formats - Full test coverage (12 tests, all passing) - Add mod plugin_index; to main.rs - Discovery grants nothing; capabilities field is informational only Claude-Session: https://claude.ai/code/session_01QUGuvDUj6gG5xQPiN3X2bG
register_plugins now verifies a plugin's bytes against [plugins.<name>].checksum before constructing its CapabilityCtx or handing the module to wasmtime. Absent checksum loads as before; mismatch and malformed both fail closed with a warn, matching the existing collision/name/ABI skip sites (N2). plugin run stays warn-only — it is a dev harness used right after a rebuild. sha256_hex moves to rustline-wasm so install-time and load-time digests share one definition; the bin re-exports it. No new unit test here on purpose: on a stub file a checksum refusal and an instantiation failure are indistinguishable, so such a test could not fail for the right reason. The gate is proven against a real .wasm in the next commit.
One test per legitimate producer that must survive the new load funnel (unpinned, correctly pinned, tampered, malformed), plus a round-trip test tying the install-time digest to the load-time check.
wasm_wiring.rs overrode XDG_CONFIG_HOME/XDG_DATA_HOME but not XDG_RUNTIME_DIR, so on a box running `rustline daemon` as a service, the `render right` subprocess these tests spawn found the REAL daemon socket and rendered the developer's production status line instead of the test's isolated fixture -- a false pass over the checksum gate this feature just changed. smoke.rs's `isolate()` helper already guards against exactly this; mirror it here at both call sites. Verified: with the override removed, both tests fail against the real daemon (one on a content mismatch, one on a missing denials.jsonl) since they never touch the fixture at all; restored, `just test-wasm` passes in a shell with a live daemon and a real XDG_RUNTIME_DIR set.
register_plugins gates a plugin's registration on its recorded checksum before host::build_plugin ever sees the module (invariant N1). The existing e2e tests prove a mismatched/malformed plugin is refused, but they can't tell that apart from the gate running too late (after build_plugin) - on a stub file both leave the registry empty either way. Add two hermetic unit tests (no wasm-e2e feature, no real .wasm) that drive register_plugins against a non-wasm stub file and assert on the captured tracing message itself: "plugin checksum mismatch, skipping" / "recorded plugin checksum is not a valid sha256 digest, skipping", not "failed to instantiate plugin, skipping". Verified empirically that moving the gate after build_plugin flips both tests to the instantiation-failure message. Also note the checksum skip in register_plugins's doc comment, which previously only listed the built-in-collision/name-mismatch/ instantiation-error skip cases. Claude-Session: https://claude.ai/code/session_01QUGuvDUj6gG5xQPiN3X2bG
a_digest_written_at_install_time_is_accepted_at_load_time claimed to pin the install->load round trip, but both "sides" called the same sha256_hex from the same import - operationally indistinguishable from plugin_with_a_matching_checksum_registers plus a length assertion. It never touched write_install_record (the actual install-time writer, private to the bin crate and unreachable from rustline-wasm's tests) or Config::load. Its doc comment also overclaimed: verify_checksum's normalize() already tolerates a sha256: prefix and any hex case, so those would not "fail loudly" as advertised. Add the genuine seam test where it can actually be driven: two ungated unit tests in crates/rustline/src/plugin_install.rs that call the real write_install_record(...) and rustline_core::Config::load(...), then check the result with rustline_wasm::verify_checksum - one proving a matching round trip, one proving a swapped-bytes round trip correctly mismatches. Also add an optional wasm-e2e-gated test staging the real weather.wasm and asserting register_plugins accepts it end to end. Rename the original e2e.rs test to a_canonically_shaped_digest_is_accepted_at_load_time and rewrite its doc comment to state plainly what it checks (the load gate accepts a well-formed digest) and what it doesn't (the install seam), pointing at the new seam test's location. Coverage kept, not deleted - just honestly scoped. Claude-Session: https://claude.ai/code/session_01QUGuvDUj6gG5xQPiN3X2bG
…ride (W49) Reuses plugin_install's existing rustls Downloader seam, caches under the state dir for 24h, serves the last-known-good copy when a fetch fails, and honours a new plugin_index_url config key.
Adds `rustline plugin search [QUERY] [--json] [--refresh]`, the user-facing command tying together the curated registry/index.json (task 0f8017b) and the fetch+TTL-cache+config-override plumbing (task 4019cf6): load the index, filter by query, mark each entry installed/not via discover_plugin_names, and print either human text (build hint for bundled entries, install command for installable ones) or a --json array via the new search_json helper. Also removes plugin_index.rs's module-level #![allow(dead_code)] now that the module has real callers, and gates parse_index (only ever exercised by this module's own tests; production always goes through parse_index_value) behind #[cfg(test)] instead of masking it with a blanket allow. Claude-Session: https://claude.ai/code/session_01QUGuvDUj6gG5xQPiN3X2bG
They are excluded workspace members, so nothing at the root has ever linted or tested them. The wasm32 clippy pass is what actually covers each plugin's #[cfg(target_arch = "wasm32")] guest module.
Every job shells out to an existing just recipe so the CI gate and the documented local command stay identical. Omits the brief's workflow-level `RUSTFLAGS: -D warnings`. Verified locally that it currently builds clean (a full rebuild under it passes all 902+22+11 tests with zero promoted warnings), but keeping it would promote every rustc warning across the *entire* dependency graph - including extism/wasmtime/cranelift, which this project doesn't control - to a hard failure in every job, not just clippy's lint pass over our own code. `dtolnay/rust-toolchain@stable` tracks whatever stable Rust is current, so a future toolchain bump surfacing a new warning inside that graph would redden test/test-plugins/test-wasm/ plugins for a reason `#[allow]` on our own source can't fix. `just lint`'s `cargo clippy --all-targets -- -D warnings` already enforces the zero-warnings bar this project actually committed to. Claude-Session: https://claude.ai/code/session_01QUGuvDUj6gG5xQPiN3X2bG
…under --json Two review-flagged defects in the W49 plugin index: - `read_cache` deserialized the on-disk cache envelope without ever calling `validate`, so a cache written by a different (e.g. future) schema build was served unvalidated for the whole 24h TTL — the common case. `validate` is now composed inside `read_cache` itself, the single choke point both of `load_index`'s cache-serving branches go through, so a rejected schema falls through to a fetch exactly like a corrupt cache. - `plugin search`'s `--json` early return happened before the stale-cache warning, so a scripted JSON consumer had no way to learn it received a day-old copy. The warning (stderr only, JSON shape unchanged) now fires before the early return. Adds unit tests proving a bad-schema cache triggers a real fetch (via the FakeDownloader call counter) while a valid-schema cache still avoids one, a smoke test pinning the stale-warning-under-json fix (offline, via an unreachable plugin_index_url), and coverage for the previously-untested (bundled=false, source=None) search-output arm and the empty-result human messages.
…rballs Each tarball carries the binary, generated shell completions, README and LICENSE; the publish job emits SHA256SUMS and cuts a pre-release.
…mment - just lint now also runs clippy with --features wasm-e2e so crates/rustline-wasm/tests/e2e.rs and crates/rustline/tests/wasm_wiring.rs (both #![cfg(feature = "wasm-e2e")]) are actually clippy-checked; previously no CI job compiled them at all. Host-target only, no wasm32 target needed. - justfile's plugin list (weather counter filewatch httpget cmdrun) was hardcoded twice, in lint-plugins and test-plugins; both now read from one `plugins` variable so a new plugin can't be added to one loop and forgotten in the other. - ci.yml's lint job comment pointed at a nonexistent CONTRIBUTING.md; it now points at CLAUDE.md/README.md, which actually document `just lint`.
… story Sync CLAUDE.md and README.md for W19 (plugin checksum verification at load time) and W49 (curated plugin index + `rustline plugin search`), plus the greenfield CI/release workflows and the two new lint-plugins/test-plugins just recipes. Also annotates the plan doc with a note on the renamed install->load seam test.
plugin list never opens a .wasm — it reads only the config's [plugins.*] entries and checks for a manifest sidecar (plugin_cmd.rs:156-175), so it lists a checksum-failing or entirely missing plugin exactly like a healthy one. Only the bar and the daemon go through register_plugins' gate. Also note in README that a blank checksum counts as 'not recorded'. Claude-Session: https://claude.ai/code/session_01QUGuvDUj6gG5xQPiN3X2bG
seed_index_cache and two inline cache seeds stamped fetched_at with a far-future timestamp under a comment claiming that keeps the cache "fresh regardless of the clock." index_is_fresh actually treats a future fetched_at as stale (now >= fetched_at fails), so every one of these tests was attempting a real fetch to raw.githubusercontent.com and only passing because registry/index.json isn't live yet. Once merged, plugin_search_lists_the_index/json_emits_an_array/ prints_no_action_hint/reports_an_empty_index would break against the real index. Stamp fetched_at from the real clock so the cache is genuinely fresh, and add isolate_with_dead_plugin_index (an isolated XDG_CONFIG_HOME pointing plugin_index_url at a dead loopback address) as belt-and- braces on every plugin search test that expects the cache served untouched, mirroring the existing staleness test's pattern. Verified via temporary local-HTTP-server scaffolding (removed before this commit): the fixed seeding ignores a live server entirely, while the old far-future seeding reaches it and would have surfaced its content. Claude-Session: https://claude.ai/code/session_01QUGuvDUj6gG5xQPiN3X2bG
- CLAUDE.md: correct the inverted claim that allows_load() is what register_plugins consults — it matches the ChecksumVerdict variants directly (for distinct warn! messages); allows_load()'s one caller is instantiate_named (the plugin run dev harness), which only uses it to decide whether to warn, never to refuse. - README.md: "search is entirely read-only" was false on three counts (it reads config for plugin_index_url, reads the plugin dir for the installed marker, and writes the index cache) — rewrite to state it never modifies config/plugin dir/toggles, matching CLAUDE.md's already-accurate narrower phrasing. - README.md: release.yml sets prerelease unconditionally, not "while under v0.x" — state it unconditionally. - README.md: the index is fetched over HTTPS only for the built-in default; plugin_index_url accepts a plain http:// override too — qualify the claim. - Document a real hazard: only `plugin install`/`plugin update` write [plugins.<name>].checksum; `plugin build`/`just build-plugin` overwrite the .wasm but never touch it, so rebuilding an installed plugin invalidates its recorded checksum and it silently drops out of the bar. Note the recovery (clear the checksum field, or `plugin update` if a GitHub source is recorded) in both CLAUDE.md and README.md. - State checksum verification's actual security scope in both docs: it catches accidental swaps/corruption/stale builds, but is not a same-user privilege boundary (no strict/require-checksum mode) and not a pin against a compromised upstream (install/update both rewrite the digest from whatever they just downloaded). Claude-Session: https://claude.ai/code/session_01QUGuvDUj6gG5xQPiN3X2bG
normalize() stripped the "sha256:" prefix case-sensitively, so a recorded "SHA256:<hex>" checksum landed in Malformed and the plugin was skipped despite documentation promising case-insensitivity. Match the prefix case-insensitively (via eq_ignore_ascii_case over a safe str::get slice, avoiding a panic on a short/non-boundary input) before the existing 64-hex-digit validation. Only normalize()'s prefix handling changed; the gate's position ahead of CapabilityCtx/build_plugin (invariant N1) is untouched. Adds the two missing tests: an uppercase SHA256: prefix now verifies (Match), and a sha256: prefix combined with a malformed digest still fails closed (Malformed), proving prefix-stripping and validation compose rather than the prefix short-circuiting validation. Claude-Session: https://claude.ai/code/session_01QUGuvDUj6gG5xQPiN3X2bG
This repo has zero tags and the release workflow has never executed, so its maiden run would otherwise be the irreversible public v0.1.0 tag. Add workflow_dispatch alongside the tag-push trigger so it can be exercised on a branch first. Two things needed to make that dry run actually safe/useful: - GITHUB_REF_NAME is a branch name (which may contain "/") on a manual run rather than a tag; sanitize it before building the package stage name, or a nested directory would form and the dist/*.tar.gz glob (upload-artifact and the Checksums step) would silently miss the asset, tripping if-no-files-found: error. - Gate the actual softprops/action-gh-release step to real tag pushes only, so a manual dispatch still exercises build/package/checksum (inspectable via the uploaded artifacts) without publishing a real GitHub release under a branch-derived name. Also delete the dead `echo "asset=..." >> "$GITHUB_ENV"` line; no later step reads it. The needs: build all-or-nothing publish semantics are unchanged. Claude-Session: https://claude.ai/code/session_01QUGuvDUj6gG5xQPiN3X2bG
…build A plugin skipped by register_plugins' checksum gate was previously invisible -- just a tracing::warn! in a log file no one reads by default. This closes that loop on three sides: - `rustline doctor` gains an advisory-only "plugin checksums" row (verified/unpinned counted, mismatched/malformed/missing named explicitly) that never affects the exit code. - `rustline plugin list`/`--json` gains a per-plugin checksum status, computed the same way. - `rustline plugin build` now notices when a rebuild no longer matches a recorded checksum: it offers to refresh it (Enter = yes) on a real terminal, accepts `--yes` for scripts/CI, and otherwise leaves the checksum alone with a clear notice -- never silently re-pinning it non-interactively, which would let a compromised local toolchain bless itself automatically. Both diagnostic surfaces go through a new shared `plugin_checksum` module that calls `rustline_wasm::verify_checksum` -- neither re-implements the comparison `register_plugins` gates loading on. Also rewires `just build-plugin`/`build-weather` to call the real `rustline plugin build` instead of a raw `cargo build` + `cp`, so the project's own workflow exercises the same stale-checksum path a user's `plugin build` invocation does; drops the resulting `build-weather` dependency from `install`/`mold-install` (which would otherwise be circular -- `build-weather` now needs `rustline` itself built). 910 -> 940 tests. fmt/clippy clean (both the default and --features wasm-e2e passes). No new dependency. Claude-Session: https://claude.ai/code/session_01QUGuvDUj6gG5xQPiN3X2bG
The prior fix wave reintroduced the claim that `plugin list` goes through register_plugins' checksum gate (CLAUDE.md), contradicting correct text later in the same file; and left the same falsehood at its source, where search()'s doc comment claimed it touches neither the config nor the plugin dir directly above the two lines that read both. Also adds plugin_search_with_a_fresh_cache_attempts_no_fetch. The other plugin_search tests seed a fresh cache AND a dead loopback URL, so a regression in freshness stamping would leave them green (the failed fetch falls back to the same bytes). Asserting the staleness warning is absent pins "no fetch attempted" — verified RED by reverting to the far-future stamp. Claude-Session: https://claude.ai/code/session_01QUGuvDUj6gG5xQPiN3X2bG
README's plugin-rebuild section still described the pre-fix hazard (a rebuilt plugin's stale checksum silently drops it from the bar); it now describes what plugin build actually does today: detect the mismatch, prompt to refresh (Enter = yes), and leave the non-TTY case alone by design. CLAUDE.md's CLI reference was missing three surfaces entirely (doctor's advisory-only "plugin checksums" row, plugin list --json's checksum_status field, and plugin build's --yes flag/prompt behavior), and its Config section carried the same two stale claims README did (the unmitigated hazard, and "plugin list never opens a .wasm" — it now does, to compute that same status). Also drops a leftover "W?" placeholder in a doc comment; the feature came from code review with no tracked work item. Claude-Session: https://claude.ai/code/session_01QUGuvDUj6gG5xQPiN3X2bG
The four `plugin_build_*` tests in smoke.rs cargo-compile a throwaway crate for wasm32-unknown-unknown, so `cargo test --workspace` only passed locally because that target happens to be installed on this machine. CI's `workspace tests` job never installs it (by design — `just test` is documented hermetic), so those tests failed there with "can't find crate for `std`". Move the four tests plus their two private helpers (scaffold_minimal_plugin_crate, wait_with_timeout) verbatim into a new tests/plugin_build_wasm.rs, gated `#![cfg(feature = "wasm-e2e")]` like the existing wasm_wiring.rs/e2e.rs. No assertions changed. Since integration test files are separate crates, smoke.rs's `isolate` helper is replicated locally rather than shared. Wire the new file into `just test-wasm` (already runs after `build-weather`, which installs the wasm target) and update the lint recipe's comment and CLAUDE.md's test-wasm description accordingly. No workflow change needed — the wasm-e2e CI job already declares the wasm32 target and already runs `just test-wasm`. cargo test --workspace: 941 -> 937 (the four tests no longer run by default). just test-wasm: 11 -> 15 tests, all passing. Claude-Session: https://claude.ai/code/session_01QUGuvDUj6gG5xQPiN3X2bG
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Ships plugin integrity verification (W19), plugin discovery (W49), the repo's first CI, and a tag-triggered release workflow.
What's in it
Plugin integrity (W19).
plugin installalready recorded a sha256checksumper plugin — nothing ever read it, so a swapped or tampered.wasmloaded silently.register_pluginsnow verifies the discovered bytes after reading them but before constructing theCapabilityCtxand before wasmtime sees the module, so a rejected plugin never obtains a capability object (invariant N1). It verifies the same in-memory buffer it hands tobuild_plugin, so there's no check/load window.Policy: no recorded checksum loads exactly as before (backward compatible); a well-formed mismatch is skipped; an unparseable digest fails closed.
plugin runis deliberately warn-only — it's the dev harness you use right after a rebuild, where a recorded digest legitimately goes stale every time.Plugin discovery (W49). A curated
registry/index.json, fetched with a 24h TTL cache and aplugin_index_urlconfig override, surfaced byrustline plugin search [QUERY] [--json] [--refresh]. Discovery grants nothing — the index'scapabilitiesfield is advertising copy the host never consults.Making failures visible. A checksum rejection was originally invisible (a
warn!in a log file, with tmux discarding stderr). Added adoctoradvisory row (never affects the exit code), achecksum_statusfield inplugin list/--json, and aplugin buildprompt that offers to refresh a checksum its rebuild just invalidated. Both diagnostics call the sameverify_checksumthe gate uses rather than reimplementing it.CI. Four jobs on PRs and pushes to
main, each routed through an existingjustrecipe so the gate and the documented local command can't drift. This also closes two real blind spots: the fiveplugins/*crates are excluded workspace members, so nothing had ever linted or tested them; and two#![cfg(feature = "wasm-e2e")]test files were never clippy-checked by anything.Release. Tag-triggered, four native targets (linux gnu/musl, macOS arm64/x86_64), tarballs with binary + completions + README + LICENSE, plus
SHA256SUMS, published as a pre-release.workflow_dispatchis included so it can be rehearsed before a real tag.Verification
941 tests passing, 0 failing.
cargo fmt --all --checkclean; clippy clean both with and without--features wasm-e2e.just lint-plugins,just test-plugins(5 suites),just test-wasm(11 tests) all pass.cargo tree -i openssland-i native-tlsboth empty — the rustls-only posture is intact.Notes for review
Six defects were caught by review and fixed in-branch, most of them mine rather than the implementers':
fetched_at, believing that made it "fresh".index_is_freshtreatsnow < fetched_atas stale by design, so those tests were silently hitting the network and passing only because the index URL 404s pre-merge — merging would have turnedmainred. Now stamped from the real clock, with a dead-loopback URL as defense-in-depth and a dedicated test asserting no fetch is attempted.read_cache.write_install_record→Config::load→verify_checksum.Known limits, stated plainly in the README rather than left implicit: verification does not stop a same-user attacker (anyone who can write the
.wasmcan edit the config), and it does not pin against a compromised upstream (install/updaterewrite the digest — TOFU records, it doesn't pin). What it does catch: accidental swaps, corrupt or truncated downloads, stale build artifacts, and a plugin dir writable by a different principal than the config.Design: spec · plan
https://claude.ai/code/session_01QUGuvDUj6gG5xQPiN3X2bG