Skip to content

Plugin integrity verification, plugin discovery, CI, and the release workflow#1

Merged
stevenwcarter merged 26 commits into
mainfrom
feat/2026-07-25-plugin-integrity-registry-ci
Jul 25, 2026
Merged

Plugin integrity verification, plugin discovery, CI, and the release workflow#1
stevenwcarter merged 26 commits into
mainfrom
feat/2026-07-25-plugin-integrity-registry-ci

Conversation

@stevenwcarter

Copy link
Copy Markdown
Owner

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 install already recorded a sha256 checksum per plugin — nothing ever read it, so a swapped or tampered .wasm loaded silently. register_plugins now verifies the discovered bytes after reading them but before constructing the CapabilityCtx and 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 to build_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 run is 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 a plugin_index_url config override, surfaced by rustline plugin search [QUERY] [--json] [--refresh]. Discovery grants nothing — the index's capabilities field 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 a doctor advisory row (never affects the exit code), a checksum_status field in plugin list/--json, and a plugin build prompt that offers to refresh a checksum its rebuild just invalidated. Both diagnostics call the same verify_checksum the gate uses rather than reimplementing it.

CI. Four jobs on PRs and pushes to main, each routed through an existing just recipe so the gate and the documented local command can't drift. This also closes two real blind spots: the five plugins/* 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_dispatch is included so it can be rehearsed before a real tag.

Verification

941 tests passing, 0 failing. cargo fmt --all --check clean; 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 openssl and -i native-tls both 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':

  • A smoke-test fixture stamped the index cache with a far-future fetched_at, believing that made it "fresh". index_is_fresh treats now < fetched_at as stale by design, so those tests were silently hitting the network and passing only because the index URL 404s pre-merge — merging would have turned main red. Now stamped from the real clock, with a dead-loopback URL as defense-in-depth and a dedicated test asserting no fetch is attempted.
  • The schema-version guard ran on the fetch path but not on cache reads — i.e. off in the common case. Now validated at a single choke point inside read_cache.
  • A test claiming to pin the install→load seam actually called the same function on both sides; the real seam test now drives write_install_recordConfig::loadverify_checksum.
  • A test that couldn't fail for its stated reason: registry state can't distinguish a checksum refusal from an instantiation failure, so the gate's position was unguarded. Now asserted via the emitted warning, verified by reordering the gate and watching it fail.

Known limits, stated plainly in the README rather than left implicit: verification does not stop a same-user attacker (anyone who can write the .wasm can edit the config), and it does not pin against a compromised upstream (install/update rewrite 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

…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
@stevenwcarter
stevenwcarter merged commit aed640b into main Jul 25, 2026
4 checks passed
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