docs/reference

Releases & hardening

Check release files and build results. Read current limits.

Releases

Release keys

Releases are signed with minisign. The binary embeds two public keys: the current key and the next key. The next key is embedded one release before its first use, so a rotation never needs a flag day.

LabelKey idFilePublic key
current5F6E09C78F555F34keys/vibeke-2026.pubRWQ0X1WPxwluX2gFO4vO586PSTdpSfJqrb+xsQnZ2ctND/VDw7VCWx5z
next69536A23D04E2C7Ckeys/vibeke-next.pubRWR8LE7QI2pTaSsb4srEFbF1j78fXZzbORy4KGRzHErddJwSJLxwqH3x

The key id is the fingerprint minisign prints for a key. To check a download by hand:

minisign -V -P RWQ0X1WPxwluX2gFO4vO586PSTdpSfJqrb+xsQnZ2ctND/VDw7VCWx5z -m SHA256SUMS
sha256sum -c --ignore-missing SHA256SUMS

The keys live in vk_remote::bootstrap (KEY_CURRENT, KEY_NEXT, RELEASE_KEYS) and in scripts/install.sh. A test (scripts/tests/release-sign-test.sh) checks that those two and keys/*.pub agree. The secret keys stay on the maintainer's Mac in ~/.vibeke-release-keys/ and are never committed, uploaded to CI or read by any script in this repository. Only minisign reads them, and it asks for the password on the terminal.

What verification requires

Every release path requires a valid signature by the current or the next key. The error messages name both key ids.

PathWhat is verified
scripts/install.shSHA256SUMS.minisig over SHA256SUMS with the minisign tool (brew install minisign), then the binary against its SHA256SUMS entry.
vibeke ssh, vibeke machine upgrade (bootstrap = "push")A cached or VIBEKE_ARTIFACT_DIR binary: its SHA256SUMS entry and the signature, before upload. The remote re-checks the sha256 before the switch.
bootstrap = "remote-download"The laptop downloads and verifies manifest.json and manifest.json.minisig. The signature must name version:<v> in its trusted comment and <v> must be this build's version. The remote then downloads the file and checks the manifest's sha256.
vibeke updateA cached or --from binary: checksum and signature before it is executed.
vibeke integration update (manifest channel)index.json.minisig over the index, by the same two keys. The channel has no key of its own.

Unsigned development builds

VIBEKE_ALLOW_UNSIGNED=1 accepts an artifact whose signature cannot be verified, with a warning that names the file and its SHA-256. It does not waive the checksum:

  • The expected checksum must come from SHA256SUMS or a <binary>.sha256 file next to the binary.
  • A missing checksum file or a mismatch is always refused.
  • The installer accepts the opt-in the same way, with SHA256SUMS from the download.
  • remote-download has no unsigned mode, because there is no local file to checksum.
  • The manifest channel keeps its separate development opt-in VIBEKE_ALLOW_UNSIGNED_MANIFESTS=1. The sha256 and serial checks still apply.

SSH can upload the running binary when the host platforms match, but only with VIBEKE_ALLOW_UNSIGNED=1. Without an external checksum, its hash protects only the transfer.

The remote host checks the uploaded file before it changes current. On mismatch, it preserves the previous version. Where mv -T is available, the change uses an atomic rename:

ln -s versions/<v> current.new && mv -Tf current.new current

Other systems use ln -sfn, which is not atomic. For cached updates, the directory name supplies the version. With --from, the verified binary supplies it.

Checksums alone detect corruption. They do not prove who built the file: an attacker who replaces both files passes an unsigned check. The signature is what proves the maintainer's key signed the checksum list.

Release repository and tokens

The default release URL is https://github.com/MidgardAI/vibeke/releases/download/v<version>. Set VIBEKE_RELEASE_URL for a mirror, or VIBEKE_INSTALL_FROM=<dir> for offline installation.

While the repository is private, GitHub does not serve the plain download URL without authentication. The installer and remote-download therefore honour VIBEKE_GITHUB_TOKEN, else GITHUB_TOKEN:

  • With a token and a github.com/<owner>/<repo>/releases/download/... URL, the file is fetched through the GitHub API asset endpoint (/repos/<owner>/<repo>/releases/assets/<id>) with Accept: application/octet-stream and Authorization: Bearer <token>.
  • curl reads the headers from stdin, so the token is never on a command line (ps). Nothing prints it. Error messages say only that a token is needed.
  • The token is sent only to github.com release URLs and the GitHub API, never to a mirror in VIBEKE_RELEASE_URL. curl drops it when GitHub redirects to its storage host.
  • For remote-download the laptop resolves the asset URL. The remote then runs curl with the headers from a here-document on the stdin of sh -s, so the token is not visible in the remote's process list or shell history either.
  • A fine-grained token with read access to the repository's contents is enough.

Public releases need no token. VIBEKE_GITHUB_API_URL overrides the API base (tests, GitHub Enterprise).

Build release files locally

scripts/release-build.sh <version> builds the artifacts reproducibly into dist/<version>/. It checks that <version> matches the workspace Cargo.toml, builds with SOURCE_DATE_EPOCH from the commit, --remap-path-prefix, --locked and stripped symbols (the environment scripts/repro-check.sh uses), and writes a .sha256 file per binary plus SHA256SUMS. It signs and publishes nothing.

ArtifactTarget tripleBuilt by
vibeke-macos-aarch64aarch64-apple-darwincargo build --release, on an Apple-silicon Mac
vibeke-linux-x86_64x86_64-unknown-linux-muslcargo zigbuild --release
vibeke-linux-aarch64aarch64-unknown-linux-muslcargo zigbuild --release

A target whose toolchain is missing is skipped with a notice. Use mise exec -- sh scripts/release-build.sh <version> so Zig and cargo-zigbuild are on PATH. --repro-check first runs scripts/repro-check.sh for both Linux targets. --only macos|linux builds one family.

Linux binaries use static musl linking. Zig 0.16 provides the C toolchain for libghostty-vt and linking. mise.toml selects the Zig version.

mise run dist (scripts/dist.sh) is the development variant. It also copies the files to ~/.cache/vibeke/releases/<version>/, which SSH installation and vibeke update read. Set VIBEKE_RELEASES_DIR to use another cache directory. Its output is unsigned, so using it needs VIBEKE_ALLOW_UNSIGNED=1 or a signed SHA256SUMS.

Release steps

Signing happens on the maintainer's Mac. CI never holds a signing secret.

  1. Update the workspace version in Cargo.toml and commit it.
  2. Run mise run ci, then mise run repro-check (see the hardening guide).
  3. Tag and push: git tag v<version> && git push origin v<version>.
  4. The release workflow (.github/workflows/release.yml) builds the macOS and Linux artifacts and creates a draft release v<version> with the unsigned binaries and .sha256 files. It needs no secret beyond the repository's own GITHUB_TOKEN.
  5. Download the draft's files into one directory, for example with gh release download v<version> --dir dist/<version> (a private repository needs gh auth login). Alternatively build locally with scripts/release-build.sh <version> and use dist/<version>/. If you built both, compare the sha256 of the files first: they should be identical.
  6. Sign: scripts/release-sign.sh dist/<version>. The script writes SHA256SUMS and manifest.json, then runs minisign twice (minisign -S -s ~/.vibeke-release-keys/vibeke-2026.key -m <file> -t "vibeke v<version>"). Enter the key password when minisign asks. The script finishes by verifying both signatures with the public key embedded in the binary, and fails if they do not verify.
  7. Upload the four signing outputs to the draft: gh release upload v<version> dist/<version>/SHA256SUMS dist/<version>/SHA256SUMS.minisig dist/<version>/manifest.json dist/<version>/manifest.json.minisig. Also upload scripts/install.sh if the release should carry the installer.
  8. Check the result with a fresh checkout of the files: minisign -V -P <public key> -m SHA256SUMS.
  9. Publish the draft (gh release edit v<version> --draft=false).
  10. Smoke test: run scripts/install.sh with VIBEKE_VERSION=<version> (and GITHUB_TOKEN while the repository is private) on a clean HOME.

manifest.json lists {version, artifacts: [{target, sha256, url}]}. Its signature carries the trusted comment vibeke v<version> version:<version>, so an old manifest cannot be replayed under a new version.

Key rotation

The binary embeds the current and the next key. To rotate:

  1. Release N is signed with the current key and already embeds the next key. Binaries from release N accept signatures by either key.
  2. Release N+1 is signed with the next key: scripts/release-sign.sh --key next dist/<version> (it uses ~/.vibeke-release-keys/vibeke-next.key and verifies against KEY_NEXT). Binaries from release N accept it, so they can update to N+1.
  3. In release N+1 (or later), promote the keys in vk_remote::bootstrap and scripts/install.sh: the old next key becomes the current key, and a newly generated key becomes next. Update keys/ and the tables here.
  4. Announce each rotation one release ahead.

If a key is lost or exposed, publish the replacement through a separate trusted channel and state the first version that uses it. Binaries that embed only the exposed key need a manual reinstall.

Not provided

  • Sigstore build provenance. Spec 09 §10 lists it as a later addition.
  • An Apple Developer ID: the macOS binary is not notarized, so Gatekeeper may warn when it is downloaded in a browser. A binary fetched by curl or the installer carries no quarantine flag.

API compatibility and generated references

The server method tables generate docs/api/methods.json and docs/api/README.md. docs/api/vibeke-1.frozen.json records protected methods and access flags.

The compatibility test rejects removals or changes to protected access flags. Additions pass. The policy remains a draft until version 1.0.

Before a release, review the catalog changes. Then update the snapshot:

VIBEKE_UPDATE_API_FREEZE=1 cargo test -p vibeke --test api_docs vibeke_1_freeze

Use VIBEKE_API_FREEZE_ALLOW_BREAK=1 only for a deliberate compatibility change before version 1.0.

After a method, command, or configuration change, regenerate the reference:

VIBEKE_UPDATE_DOCS=1 cargo test -p vibeke --test api_docs

Commit the generated files in docs/api/ and docs/site/src/reference/.

mise run docs builds the mdBook site when mdbook is installed.

Fuzz tests, performance limits, and reproducible builds

This page describes the checks from spec 11 M6 and spec 10, sections 1 and 6. The bounded fuzz tests in crates/vk-fuzz run with the normal test suite. The other checks are separate from mise run ci.

Fuzz tests

Each target is a function with the type fn(&[u8]) in crates/vk-fuzz/src/targets.rs. The functions must not panic, stop responding, or allocate memory without a limit. Two test systems use these functions.

Test systemLocationToolchainRuns in CI
Deterministic tests with random bytes and modified seedscrates/vk-fuzz/tests/random.rsStable RustYes. Default: 300 cases per target. The vt_parse and mux_frame_decode targets run 30 cases each.
cargo-fuzz with libFuzzerfuzz/Nightly Rust and cargo-fuzzNo

The stable tests use the generator and mutator in crates/vk-fuzz/src/rng.rs. They do not add proptest, arbitrary, or bolero to Cargo.lock. The libfuzzer-sys dependency belongs to the separate workspace in fuzz/Cargo.toml. It does not change the results of cargo deny check for the root workspace.

Targets

TargetInputRequired result
holder_proto_decodeFrameBuf chunks and raw postcard data as ToHolder / FromHolderNo panic. Reject oversized input.
render_frame_decodeFrameBuf chunks and raw postcard data as ServerFrame / ClientFrameNo panic
jsonrpc_decodeLine-based parsing of rpc::Request / Response / NotificationNo panic
key_grammarparse_key, parse_binding, expand_rangeformat_key followed by parse_key returns the original value
vt_parseRandom bytes and resizes through vk_term::Engine::feedNo panic or abort. Complete within the time limit.
socks5_handshakeBytes passed to vk-preview socks::handshakeNo panic
mux_frame_decodeBytes passed to the vk-remote frame decoder and a live MuxNo panic or blocked response
hook_payloadsArbitrary JSON passed to interaction_from_hook for each harness and eventNo panic
policy_matchURLs, allow rules, and IP addresses passed to vk-browser::policyNo panic. Block metadata and link-local addresses unless a rule permits them.
kitty_probeTerminal replies and SGR mouse reports passed to vk-browser::probeNo panic. Consumed length stays within bounds.
compat_importHerdr config TOML and session JSONNo panic
manifest_tomlHarness manifests passed to parse_raw, Loaded::new, its methods, and VersionReqNo panic

Seed files in fuzz/corpus/<target>/ include valid messages, test fixtures, and boundary cases. To regenerate them, run:

cargo test -p vk-fuzz --test random export_seed_corpus -- --ignored

Run the tests

For a bounded run with stable Rust, use:

mise run fuzz-smoke

For more cases with arithmetic overflow checks, use a debug build:

VK_FUZZ_CASES=100000 VK_FUZZ_SEED=3 cargo test -p vk-fuzz --test random

For a libFuzzer run, install nightly Rust and cargo-fuzz first. Then run:

FUZZ_TIME=300 mise run fuzz

To test one target, set FUZZ_TARGET:

FUZZ_TARGET=policy_match mise run fuzz

If nightly Rust or cargo-fuzz is missing, mise run fuzz prints installation instructions. It then exits with status 0 without running the tests.

A failed test prints the exact input. Add that input to a regression test beside the affected code. Also add it to the seed files for that target.

Fixed defects

The policy tests found an IPv4-mapped IPv6 prefix error. A rule such as ::ffff:10.0.0.7/128 became an IPv4 address but kept its IPv6 prefix length. The expression u32::MAX << (32 - bits) then overflowed. This caused a panic in debug builds and an incorrect mask in release builds.

The prefix now starts at bit 96. A mapped rule with a prefix below 96 matches no addresses. The regression test is mapped_v6_rule_prefix_is_relative_to_bit_96.

Planned OSS-Fuzz integration

The target functions and cargo-fuzz directory already support separate builds. The remaining work is:

  1. Add input dictionaries where necessary. Examples include terminal escape sequences, JSON-RPC method names, and key names.
  2. Submit projects/vibeke/ to google/oss-fuzz with the files below.
  3. Build the native VT engine with the OSS-Fuzz $CC and $CFLAGS variables. This lets ASan check the C code.
  4. Set -rss_limit_mb to 256 for vt_parse.
  5. Add a regression test and a seed for each reported defect.
  6. Run nightly tests in project CI for one CPU-hour per target.
  7. Before version 1.0, run all targets under OSS-Fuzz for seven days without a new crash.
FileContents
project.yamlRust language, repository, contact, libFuzzer engine, address sanitizer, and x86_64 architecture. Add the undefined behavior sanitizer after the native VT engine passes its checks.
DockerfileBase image gcr.io/oss-fuzz-base/base-builder-rust, Zig 0.16, and the repository. Zig builds the vendored libghostty-vt library.
build.shBuild with cargo fuzz build --fuzz-dir fuzz -O. Copy binaries to $OUT. Create $OUT/<target>_seed_corpus.zip from each seed directory.

Some planned targets still need test support:

  • vt_resize_interleave and serialize need an API to compare snapshots.
  • compat_socket needs a socket test driver.
  • transcript_parse needs a target.
  • osc_image needs tests for decoder limits.

Performance limits

mise run perf-budgets runs scripts/perf-budgets.sh. It compares measurements with the limits in spec 10, section 1. Results go to target/perf-budgets/. By default, the command reports results and exits with status 0. Set PERF_STRICT=1 to fail the command when a checked limit is exceeded.

MeasurementCommand or sourceLimit
VT parser throughputcargo test --release -p vk-term --test recovery throughput -- --ignoredAt least 300 MB/s
Added keystroke latencyvibeke debug latencyp50 at most 1 ms. p99 at most 3 ms.
Remote bandwidthvibeke debug bandwidth, with PERF_MACHINE=<label>Idle: 0 B/s. Unfocused spinner: at most 2 KiB/s. Focused spinner: at most 8 KiB/s. Results require manual review.
Idle CPU, memory, and wakeupsvibeke debug idle. Set PERF_IDLE=0 to skip this check.Server: at most 0.3% CPU, 2 wakeups/s, and 25 MiB. Holder: at most 2 MiB. TUI: at most 30 MiB.
VT conformancecargo test -p vk-term --test conformance, also in mise run testNo unexpected failures. Set VK_CONFORMANCE_REPORT=1 to print category counts and expected failures.
Browser framescargo run -p vk-browser --example bench, with PERF_BROWSER=1Manual review against spec 10, section 1.6

The idle test uses a separate server with 30 idle panes and an attached TUI without a display. It records the system load. On a busy host, it marks results with “(loaded)”.

These limits apply to the two reference machines in spec 10, section 2.1. Results from other hardware give an estimate. CI does not yet reject a pull request when these measurements exceed a limit. That check needs a store of past results. The planned check also rejects regressions above 10% against the median of the last five main runs.

Reproducible builds

mise run repro-check runs scripts/repro-check.sh. The script builds the Linux musl release binary twice in separate target directories. It uses cargo zigbuild --release --locked and compares the SHA-256 hashes. Set REPRO_TARGETS to select targets. The default is x86_64-unknown-linux-musl.

Both builds use these settings:

  • SOURCE_DATE_EPOCH is the HEAD commit time unless explicitly set. Releases use the tag commit time.
  • --remap-path-prefix covers the workspace, CARGO_HOME, RUSTUP_HOME, and target directory.
  • Compiler settings include -C strip=symbols and CARGO_INCREMENTAL=0.
  • Environment settings include TZ=UTC and LC_ALL=C.

Recorded result

On October 6, 2026, two clean builds on one macOS arm64 host produced the same binary. The source was commit 858036a with the M6 documentation changes. The tools were Rust 1.99.0 and Zig 0.16.0. The target was x86_64-unknown-linux-musl. Each cold build took approximately 14 minutes. Both builds produced this SHA-256 hash:

40e33616587d25f66ba32e9665da2eebec01bd3d9ce7f008aa8f9017d91257a2

This result covers two builds on one machine. It does not verify aarch64-unknown-linux-musl, separate machines, different checkout paths, or different host operating systems. It also does not use a fixed container image.

The check does not calculate a separate hash for the vendored libghostty-vt Zig cache. The final binary comparison includes the output of the Zig build.

dist.sh does not yet use these build settings. Thus, the result does not cover artifacts from mise run dist. CI does not run this check yet. Separate CI runners and published release digests remain planned work.

View page source Documentation from this build