v2.8.3

Gerstep/cybosv2.8.3Aug 16, 2026by github-actions[bot]

AI Summary

This release focuses on security improvements by preventing unauthorized command execution from project-local configurations and mitigating path traversal vulnerabilities. It introduces new trust management commands and updates the MCP server protocol to a newer revision.

Key Highlights

  • Added `qmd trust`, `qmd trust list`, and `qmd trust revoke` commands to manage approvals for external paths and model URIs.
  • Enhanced security by blocking file symlinks and glob patterns that attempt to resolve outside the collection directory.
  • Updated MCP server to protocol revision 2026-07-28, making HTTP sessions stateless.
  • Fixed `qmd collection add --glob` to properly handle glob patterns instead of silently falling back to defaults.

Breaking Changes

  • MCP server protocol bumped to revision 2026-07-28, making HTTP sessions stateless (no `Mcp-Session-Id`).
  • `qmd pull` no longer prints download progress bars by default.
  • `search`/`query` behavior changed with `--full-path`: unresolved results now retain their `qmd://` URI and docid, whereas previously the docid was dropped.

New Features

  • New `qmd trust`, `qmd trust list`, and `qmd trust revoke` commands to manage approvals.
  • Environment variables `QMD_TRUST_UPDATE_HOOKS` and `QMD_TRUST_LOCAL_CONFIG` to opt into unattended execution.
  • Environment variables `QMD_ALLOWED_ORIGINS` and `QMD_ALLOWED_HOSTS` to configure MCP server host validation.

Full Release Notes

## [2.8.3] - 2026-08-16

### Security

- `qmd update` no longer runs a project-local `.qmd/index.yml`'s `update:`
  commands without approval (#886). That file arrives with a `git clone` and is
  adopted automatically for any command run inside the tree, so cloning a
  repository and running `qmd update` executed shell commands chosen by whoever
  wrote it. On a terminal QMD now lists the commands and asks; with nobody to
  ask it skips them and keeps indexing. Approvals are recorded per config file
  and per command set in `<config dir>/trusted.json`, so editing a command — or
  a `git pull` that rewrites one — asks again. New `qmd trust`,
  `qmd trust list` and `qmd trust revoke` manage approvals, and
  `QMD_TRUST_UPDATE_HOOKS=1` opts unattended runs back in. Commands in your own
  `~/.config/qmd/*.yml`, including anything `qmd collection update-cmd` writes,
  are unaffected.

- The same project-local trust gate now covers collection `path` values that
  resolve outside the project and non-default `models.embed` / `models.rerank`
  / `models.generate` URIs (#889). In-project paths still index unattended;
  out-of-project directories are skipped until `qmd trust`, and custom model
  URIs are not loaded or downloaded. `QMD_TRUST_LOCAL_CONFIG=1` opts unattended
  runs back in (and `QMD_TRUST_UPDATE_HOOKS=1` still does). `qmd collection
  add` records trust as it writes, the same way `update-cmd` does.

- Indexing no longer follows file symlinks or glob `../` / absolute patterns
  out of the collection directory. `fast-glob` already skipped symlinked
  directories, but a file symlink (or a mask like `../**/*.md`) still resolved
  via `realpath` and ingested the target. `qmd://` filesystem resolution uses
  the same containment check, so `qmd://collection/../../../etc/passwd` no
  longer produces a path outside the collection.

- `qmd mcp --http` now validates the `Origin` and `Host` headers on every
  request and answers `403` when they name anything but a loopback address
  (#881). Binding to localhost is no defence against the user's own browser:
  a page can re-point its hostname at `127.0.0.1` (DNS rebinding) and read
  the indexed corpus through `POST /query` or `POST /mcp`. Requests with no
  `Origin` (curl, MCP clients, editors) are unaffected. Extend the allowlists
  with `QMD_ALLOWED_ORIGINS` / `QMD_ALLOWED_HOSTS`, or set
  `QMD_ALLOWED_ORIGINS=*` behind your own authenticating proxy. A wildcard
  bind (`--host 0.0.0.0`) skips the host check and warns at startup.

### Changed

- Dependencies: `node-llama-cpp` 3.18.1 → **3.20.0** (llama.cpp b8390 → b10361, 2026-08-11). Also safe patch/minors: `picomatch` 4.0.4 → 4.0.5, `web-tree-sitter` 0.26.8 → 0.26.12, `tsx` 4.21.0 → 4.23.12, `vitest` 3.2.4 → 3.2.7. No zod/vitest major; `@modelcontextprotocol/server` stays 2.0.0 (no 2.x patch). `flake.nix` FOD hashes are not updated here.
- `generate` and query expansion now await `LlamaContextSequence.dispose()` before disposing the parent context. node-llama-cpp 3.20 made sequence dispose async; the library's context-onDispose path does not wait.

- MCP server now speaks protocol revision **2026-07-28** via the official
  TypeScript SDK 2.x (`@modelcontextprotocol/server`). HTTP is sessionless
  (no `Mcp-Session-Id`, no initialize handshake, no idle-session TTL / #816
  reaper). Clients send version and capabilities in `_meta`; `server/discover`
  is implemented; Streamable HTTP POST requires `Mcp-Method` / `Mcp-Name`
  (mismatch → `-32020`); `tools/list` is deterministic and carries `ttlMs` /
  `cacheScope`. 2025-era stdio clients still work (`serveStdio` dual-speak);
  2025-era HTTP `initialize` is answered per-request without minting a
  session. Existing tools (`query` / `get` / `multi_get` / `status`), stdio
  EOF shutdown, and named-index daemon PIDs are unchanged. No release.

- `qmd pull` (and implicit model downloads in `embed`/`query`) no longer print
  node-llama-cpp's download progress bar. The bar redraws every few kilobytes
  and flooded agent transcripts with thousands of tokens (#776). Pass
  `qmd pull --progress` to show it on an interactive terminal.
- `--full-path` no longer degrades silently when a result cannot be resolved on
  disk (#785). A fallback there means the file moved or was deleted since the
  last index, so `search`, `query`, `get` and `multi-get` now print a notice to
  stderr naming how many results fell back and suggesting `qmd update`; stdout
  stays machine-readable.
- `search`/`query` now decide per result whether to show the docid under
  `--full-path`, matching `multi-get` and `get`: a result that resolved shows
  its on-disk path and no docid, one that did not keeps its `qmd://` URI *and*
  its docid, so it is still addressable. Previously the docid was dropped for
  every row whenever the flag was set, leaving unresolved rows with neither a
  usable path nor an identifier.
- `search --format csv` always emits the `docid` column, empty for rows that
  resolved to an on-disk path. Under `--full-path` the header previously
  dropped the column entirely — which also disagreed with the empty-result
  header, always printed with `docid`. Column positions are now stable across
  runs and formats.

### Fixed

- Concurrent first-open of a cold index no longer fails with
  `table documents_fts already exists` on Bun/macOS. FTS5
  `CREATE VIRTUAL TABLE IF NOT EXISTS` is not atomic across WAL
  connections: two processes can both see a missing table on their
  schema snapshot and the loser throws. Table create and legacy-schema
  repair now use the same `BEGIN IMMEDIATE` + double-check as the FTS
  sync triggers, and treat a concurrent "already exists" as success
  when the table is present.

- Nix flake `qmd-node-modules` FOD hashes updated for x86_64-linux and
  aarch64-darwin after the MCP SDK 2.0 bump. `nix build` / Nix GHA was
  failing with a fixed-output hash mismatch.

- CJK FTS rebuild no longer skips leftover `fts5(name, body, content='documents')`
  tables when `fts_cjk_normalized_version` is already stamped, and schema
  repair now checks live FTS columns (`PRAGMA table_info`) as well as
  `sqlite_master.sql`. The MCP HTTP test helper still seeds that legacy
  table; `startMcpHttpServer` / `createStore` on it must not throw
  `no such column: T.name` (#792 regression).

- `qmd collection add --glob` is no longer silently ignored. parseArgs ran
  with `strict: false`, so OpenClaw's `--glob memory.md` (and any other
  `--glob`) fell through, the default `**/*.md` was used, and a second
  collection on the same path collided as a duplicate instead of indexing
  the requested mask (#536). `--glob` is now an alias for `--mask`.

- The `bin/qmd` trampoline now execs `process.execPath` instead of
  re-resolving `node` from PATH. Native addons (`better-sqlite3`) are
  compiled for the Node that installed qmd; a version manager (nvm, fnm,
  mise) selecting a different major in the working directory used to spawn
  that other binary and fail with `NODE_MODULE_VERSION` / `ERR_DLOPEN_FAILED`
  (#577 leftover; #319). `bun bin/qmd` still resolves `node` from PATH so
  Node-ABI addons are not loaded into bun.

- `qmd update` / `qmd collection add` no longer swallow unreadable files
  silently (#460). `readFileSync` failures (ETIMEDOUT on APFS compressed
  files, EAGAIN, EACCES, …) still skip the file so the rest of the collection
  indexes, but the CLI now warns with the path and error code and reports
  the skip count. The SDK `update()` result includes `skipped`.

- The architecture diagram no longer draws Vec expansions into BM25 search
  (#680). `lex` expansions are FTS-only; `vec` and `hyde` expansions are
  vector-only. The original query still goes to both backends.

- `qmd bench` no longer runs to a wall of 0.00 when the fixture collection is
  missing or empty (#716). It errors up front with the same "Collection not
  found" / index hint as `qmd search -c`, and if every backend still scores
  zero it warns on stderr to check `qmd ls`.

- `qmd cleanup` now reclaims the content and FTS space left behind after a
  wrong-directory `qmd update`. Deactivating files (the next update in the
  right directory) only tombstoned the `documents` rows; cleanup deleted those
  rows and vacuumed, but never dropped the unreferenced `content` hashes and
  never ran FTS5 `optimize`, so `documents_fts_data` kept the old bodies
  (#550). Cleanup now deletes inactive docs, then orphaned content, then
  compact FTS, then vacuum. `--dry-run` reports the content hashes too.

- Concurrent `query` calls with `rerank: true` on a cold MCP server no longer
  race `ensureRerankContexts()`. Embed already serialized context creation;
  rerank did not, so two overlapping first queries both saw an empty pool,
  both created ranking contexts, and the inactivity timer disposed the loser
  (`Object is disposed`, #682). Callers now await the in-flight create.

- Embedding-context pool size no longer assumes every GGUF costs 150 MB of
  VRAM (the nomic-embed figure). Larger models such as Qwen3-Embedding-0.6B
  are ~1190 MB per 2048-token context; opening 8 of those exhausted an 8 GB
  card so `qmd query` failed with `Failed to create any rerank context` even
  though the reranker itself was fine. The pool is now sized from the weight
  file, and 1 GB is reserved for the reranker (#799). Default
  embeddinggemma/nomic throughput is unchanged. `QMD_EMBED_PARALLELISM` still
  overrides.

- Multi-collection `-c A -c B` (and SDK/MCP `collections: [A, B]`) no longer
  searches globally then post-filters. A large unrelated collection could fill
  the FTS/ANN top-k so the requested collections vanished, yielding false-empty
  results even though each collection matched on its own. `searchFTS` /
  `searchVec` now search each requested collection, then merge by score
  (#775). Single-collection exact-scan (#791, #803) is unchanged.

- Query expansion no longer consumes caller `intent`, and a cached expansion
  whose sub-queries all miss is dropped instead of replaying forever (#818).
  Intent still steers reranking and snippet/chunk selection; it just no longer
  enters the expansion prompt or cache key, where the model copied meta-language
  ("so I can compare spend settings") into lex/vec terms that matched nothing.

- Files whose names differ only in the characters the legacy slug collapsed to
  `-` (spaces, underscores) no longer evict each other from the index (#717).
  The handalized-path migration now skips any row whose path is still owned by
  a file in the current scan, so it only adopts genuinely stale pre-2.6 rows.
  `qmd get` and `qmd ls <prefix>` also match `_` and `%` in paths literally
  instead of as SQL `LIKE` wildcards, so `qmd get 2026_06_16.md` no longer
  returns a sibling `2026-06-16.md`.

- CLI `multi-get` and SDK/MCP `multi_get` now share one comma-list resolver.
  Collection-prefixed paths (`qmd/docs/SYNTAX.md`) work in both transports,
  unanchored `LIKE '%name'` no longer silently fetches a different document
  for a filename fragment (`NTAX.md` ≠ `SYNTAX.md`), and ambiguous names
  across collections error with the candidate list instead of `LIMIT 1` (#759).

- The Nix flake wrapper now seeds the same pre-import env as `bin/qmd`.
  Nix installs exec `bun src/cli/qmd.ts` directly, so they previously skipped
  the launcher: `qmd mcp` could leak llama/ggml native logs onto JSON-RPC
  stdio, and Darwin CLI exits dumped a ggml Metal residency-set stack trace
  after an otherwise successful query. The wrapper now quiets those logs for
  `mcp` and sets `GGML_METAL_NO_RESIDENCY=1` on Darwin unless
  `QMD_METAL_KEEP_RESIDENCY=1` (#723).

- Rerank context creation no longer swallows the real failure. A VRAM OOM or
  corrupt model previously produced only `Reranker unavailable — skipping
  reranking` (and a dead identical retry whose comment claimed it disabled
  flash attention, which ranking contexts never supported). The warning now
  includes the underlying message so the two cases are distinguishable (#782).

- The Nix flake package now ships `skills/` next to `src/` in `$out/lib/qmd/`.
  `findPackageRoot()` walks up from the wrapped `src/cli/qmd.ts` looking for a
  sibling `skills/` directory; without it, `qmd skill show` and `qmd skills list`
  always failed with "QMD skill not found" on Nix-installed binaries (#722).

- `/release` step 1 no longer points at a missing script. `skills/release/scripts/release-context.sh`
  now exists: it silently installs git hooks and prints version info, working-tree
  status, commits and files since the last tag, `[Unreleased]`, and the previous
  changelog entry. The skill's process list also drops the duplicate step 7 and
  checks dependency updates before cutting the release (#796).

- `store.searchVec()` (and SDK `searchVector()`) now embed the query with the
  store's pinned embed model instead of the global `QMD_EMBED_MODEL`. A store
  created with a non-default `models.embed` previously failed with
  `Dimension mismatch ... Expected N ... received M` and loaded the wrong
  (often much larger) model at query time. Hybrid/precomputed/session search
  paths were unaffected and stay unchanged (#690).

- `qmd collection add --mask "a.md,*.txt"` now indexes the union of each
  pattern. The comma-separated form was documented and commonly guessed, but
  the joined string was passed to fast-glob as one literal glob, so it
  matched zero files with no error. Brace form `{a.md,*.txt}` is unchanged.
  The same split applies on `qmd update` for stored comma-list masks (#557).

- NixOS / immutable-root installs no longer crash `qmd embed` with EACCES
  when node-llama-cpp tries to compile llama.cpp into a read-only
  `node_modules`. The flake wrapper puts Nix's glibc and libstdc++ on
  `LD_LIBRARY_PATH` so prebuilt binaries can `dlopen` them, and
  `getLlama()` uses `build: "never"` when the llama directory is not
  writable (#574).

- CJK FTS rebuild no longer raises "database is busy" when flushing insert
  batches. The streaming `.iterate()` cursor stayed open across
  `BEGIN` on the same connection; the scan now uses keyset-paginated
  `LIMIT` batches so each SELECT finalizes before the insert transaction
  starts (#797).

- Quoted FTS phrases containing dotted tokens (e.g. `"1.0.21"`) now match the
  indexed document. The porter unicode61 tokenizer stores dotted strings as
  adjacent parts, but phrase sanitization stripped the dots into a single
  token (`1021`) that could never hit. Dotted tokens inside quotes are split
  into adjacent phrase terms, matching the bare-term rewrite from #563 (#757).

- `scripts/build.mjs` no longer passes `shell: true` to `spawnSync` on Windows.
  With the default Node install path (`C:\Program Files\nodejs\node.exe`),
  `cmd.exe` split the unquoted `process.execPath` at the space, the `tsc`
  spawn failed, and `prepare` could still report success with no `dist/` —
  leaving `bin/qmd` at "not built". The helper always receives a real binary
  path plus an args array, so no shell is needed. A spawn error now prints
  the missing binary path instead of failing silently. (#681)

- Case-sensitive collections no longer collapse distinct document identities that
  differ only by path casing. The implicit `COLLATE NOCASE` legacy migration was
  unsafe for filesystems that contain both `README.md` and `readme.md`; case-only
  legacy migrations must now be explicit and operator-reviewed (#801).

- `cleanupOrphanedVectors` now runs its orphan count and both DELETEs in a
  single immediate transaction. An interruption between the two DELETEs
  (crash, `SQLITE_BUSY`) could desync `vectors_vec` from `content_vectors`,
  leaving stale metadata rows that make a later reactivation of the same
  content hash look already-embedded — so `qmd embed` skips it and the
  document becomes silently unsearchable by vector, with no orphan left to
  clean up (#766).

- `qmd embed` no longer splits a UTF-16 surrogate pair (emoji, etc.) across a
  chunk boundary. A chunk ending or starting mid-pair produced an unpaired
  surrogate in the chunk text, which some remote embedding APIs reject as
  invalid JSON — permanently failing that chunk on every retry, since the
  boundary calculation is deterministic. This covers both the character-based
  chunker and `chunkDocumentByTokens`'s recursive re-splitting for
  astral-plane-dense content, which could previously drive the char budget
  low enough to reproduce the same split (#777).

- `insertContext` looks up `store_collections` by name. #754 retargeted the
  query from the dropped `collections` table but left `WHERE id = ?`, and
  `store_collections` has `name TEXT PRIMARY KEY` with no `id` column — the
  call threw `no such column: id`. Matches `deleteContext` /
  `updateStoreContext` (#853).

- `qmd collection add` with no path argument now errors with usage instead of
  silently indexing the current working directory (#684). Pass `.` to index
  CWD, matching the documented examples.

- `qmd status` reports orphaned embedding chunks, `qmd update` hints when they
  exceed 10% of vectors, and `qmd cleanup --dry-run` previews what would be
  removed. Incremental update still does not auto-prune vectors (a transient
  empty mount would otherwise force a full re-embed) (#768).

- `qmd --index <name> mcp --http --daemon` now scopes PID/log files per index
  (`mcp-<name>.pid`) and passes the resolved database path to the child, so a
  named-index daemon no longer collides with the default `mcp.pid` or opens
  the default store (#772).

- Opening a store no longer throws `SQLiteError: no such column: T.name` when
  `documents_fts` is still the legacy `fts5(name, body, content='documents')`
  schema. `CREATE VIRTUAL TABLE IF NOT EXISTS` left that table in place, and
  the CJK FTS rebuild's `DELETE FROM documents_fts` compiled against
  `documents.name`, which does not exist (#792).

- Rerank cache keys now include the resolved `models.rerank` URI, so swapping
  the configured reranker no longer serves the previous model's cached scores
  (#764).

- `vsearch -c <collection>` no longer returns empty results for small
  collections crowded out of the global ANN candidate pool. `searchVec` now
  exact-scans the collection's vectors with `vec_distance_cosine` when the
  set is within 20k rows (ANN + post-filter cannot see collections that never
  enter global top-k, and sqlite-vec caps `k` at 4096 so a larger multiplier
  alone is not enough). Larger collections still use capped ANN over-fetch
  (#791, #803).

- `multi-get --format files` now emits the docid as its own CSV field
  (`#docid,path,...`) instead of prepending it into the path field with a
  space (`#docid path,...`), matching `search --format files` and keeping
  naive comma-splitting usable (#760).

- `qmd embed` now takes an exclusive process lock (`.qmd-embed.lock` next to
  the index DB) so concurrent invocations no longer race on `vectors_vec`
  and fail with `UNIQUE constraint failed: vectors_vec.hash_seq`. A second
  embed exits early with `Another embed process is already running. Skipping.`
  Stale locks from crashed processes are recovered via PID identity checks
  (#825).

- windows prepare fix #778 keeps dist build #824

- `qmd mcp` (stdio) now shuts down gracefully when stdin reaches EOF instead
  of orphaning to PID 1 when the parent MCP client dies (#751): the server
  closes its transport, gives in-flight request handlers a bounded window to
  settle, closes the store (which disposes its llama.cpp instance), and lets
  the process drain via `process.exitCode` (no forced `process.exit()`, which
  has caused exit-time native crashes before).

- `qmd --version` no longer reports an unrelated repository's commit (#787).
  The commit was discovered at runtime with `git -C <installDir> rev-parse`,
  and `git -C` walks *up*, so any install nested inside another checkout
  reported that checkout's HEAD — a global npm install under a git-managed
  prefix such as Homebrew's `/opt/homebrew` claimed Homebrew's commit as
  qmd's. Identical tarballs reported different "commits" depending only on
  where they were installed, which is why one issue can collect three distinct
  hashes for the same published build. `scripts/build.mjs` now stamps the
  commit it built from into `dist/cli/build-info.json` (suffixed `-dirty` when
  built from a modified tree), and the runtime prefers that. Source checkouts
  still resolve their own HEAD, but only after confirming the enclosing
  repository is qmd's; anything else reports no commit rather than a
  misleading one. The lookup also no longer interpolates the install path into
  a shell string, so a path containing a space stops silently dropping the
  commit, and `--version` from a build runs no subprocess at all.

- `qmd doctor` no longer false-positives `.etag` HTTP sidecars (written by
  `qmd pull` next to each download) as invalid GGUF models. The model-cache
  check now only inspects real `.gguf` files, so a sidecar that happens to
  sort before the blob no longer poisons the report. #812

- `qmd mcp stop` and `qmd mcp --http --daemon` now verify that a pidfile PID still belongs to a qmd process before signalling it or refusing to start. Recycled PIDs (common after reboot) are treated as stale: the pidfile is unlinked instead of SIGTERM'ing an unrelated process or blocking daemon start with a false "Already running" error (#806).

- `qmd collection add` now rejects missing paths and regular files before
  creating collection configuration or index state. The error reports both the
  received and resolved path so malformed shell arguments can be corrected.

- Claude Code plugin: scope the plugin `source` to `./skills` so installs
  copy just the skills (~50 KB) instead of the entire repository. Previously a
  canonical install materialized ~230 MB / 9,000+ items into
  `~/.claude/plugins/cache/` — including a full npm dependency install
  triggered by the repo-root `package.json`. Both skills (`qmd` and `release`)
  still ship, unchanged. (#790)

- Claude Code plugin: releases now bump the plugin version in
  `.claude-plugin/marketplace.json` in lockstep with `package.json`. The
  plugin cache is keyed on this version, and it had been stuck at `0.1.0`
  since February — so installed plugins never received skill updates
  (users who installed in February are still being served that snapshot
  today, despite the qmd skill nearly tripling in size since). Also bumps
  the plugin to `2.6.3` as a one-time catch-up so existing installs pick
  up the current skill on their next `claude plugin update`. (#789)

### Changed

- `--full-path` no longer degrades silently when a result cannot be resolved on
  disk (#785). A fallback there means the file moved or was deleted since the
  last index, so `search`, `query`, `get` and `multi-get` now print a notice to
  stderr naming how many results fell back and suggesting `qmd update`; stdout
  stays machine-readable.
- `search`/`query` now decide per result whether to show the docid under
  `--full-path`, matching `multi-get` and `get`: a result that resolved shows
  its on-disk path and no docid, one that did not keeps its `qmd://` URI *and*
  its docid, so it is still addressable. Previously the docid was dropped for
  every row whenever the flag was set, leaving unresolved rows with neither a
  usable path nor an identifier.
- `search --format csv` always emits the `docid` column, empty for rows that
  resolved to an on-disk path. Under `--full-path` the header previously
  dropped the column entirely — which also disagreed with the empty-result
  header, always printed with `docid`. Column positions are now stable across
  runs and formats.