korvid v0.4.1¶
v0.4.1 is the first published 0.4 release. It ships the named AI profiles, searchable model selection, and local MCP stdio connections prepared for the v0.4.0 tag, and adds security updates for the optional MCP stack and development tooling.
The v0.4.0 tag was blocked before publication during release verification. It remains an immutable audit record, not an installable release. See korvid v0.4.0 for the blocked tag's original notes.
Read the migration sections before upgrading: agent configuration and provider extension points changed, and direct HTTP MCP clients now require internal transport authentication. OAuth, remote MCP, and headless mode are not part of this release.
Security fixes¶
The optional [mcp] extra (and therefore [all]) now requires
httpx2>=2.12.0,<3. Locked httpx2 2.12.0 requires httpcore2 2.12.0
transitively. All three CVEs are httpx2 advisories: httpx2 2.11 fixes
CVE-2026-84379 and CVE-2026-84380, and httpx2 2.12 fixes CVE-2026-84382, so
v0.4.1 ships the first dependency combination that resolves all three.
The development-only dependency group also requires GitPython>=3.1.59,
fixing CVE-2026-78675, CVE-2026-78676, CVE-2026-78677, and CVE-2026-78678.
GitPython supports the development tooling; it is not added to the application's
runtime extras.
Install or upgrade¶
Install the full feature set:
For an existing install, reinstall with the complete desired extra set:
Python 3.11, 3.12, and 3.13 are qualified on Linux, macOS, and Windows. Base TUI and MCP-only installs remain independent of the optional agent stack.
Named profiles and model search (user-visible)¶
korvid now ships a named-profile configuration model and a searchable model catalog with over 2,000 entries.
- Named profiles.
agent.profiles.<name>holds each connection;agent.activepicks the one in use. A profile'smodelis always a<prefix>/<tag>reference — the prefix routes the call, the tag identifies the model. Example:
agent:
active: local
profiles:
local:
model: ollama/qwen3:8b
endpoint: http://localhost:11434
auth: {method: none}
gpt:
model: openai/gpt-4o
auth: {method: environment, key: OPENAI_API_KEY}
auth.method: none requires an endpoint: a keyless request without one
would go to whichever default host the SDK picks, so korvid refuses it.
Inside the TUI, :ai opens the profile manager once profiles exist and
Enter activates the highlighted one; :model <name> switches the active
profile's model.
-
Model catalog. The
:aiwizard's model step is a fuzzy search over LiteLLM's bundled table (2,000+ models, no internet required). An optional enrichment layer frommodels.devadds context lengths and quantization info; it is fetched only when you press Ctrl+R on that screen, which always revalidates — conditionally, so an unchanged document is a round trip and not a download — while korvid's own reads stay behind a 24-hour cache. The fetch is verified withnetwork.ca_bundlelike every other korvid HTTPS call.agent.model_search.models_devdefaults totrueand setting itfalsedisables the layer permanently: no source, no client, no socket. Onlytrueandfalseparse; anything else warns at startup and fails closed tofalse. See Model search and the airgap guide. -
Model-first selection. The catalog is the starting point for choosing a model: search for what you want, select it, and the profile is saved. The routing prefix is not something an operator needs to know in advance.
-
Endpoint discovery. For a profile with its own endpoint, the search screen can also list what that endpoint actually serves. The listing now uses
network.ca_bundlelike every other korvid HTTPS call — an endpoint the running agent trusts is one the setup screen trusts, with no insecure fallback — and the whole attempt is bounded by a single 5-second deadline rather than a per-read one. The profile's key is sent as a bearer token to that endpoint only: no redirect is followed, and it is never logged or stored. Failures stay best-effort: an empty listing, never an error dialog.
Local MCP: stdio with internal transport authentication¶
Configure external hosts to run korvid mcp stdio after starting the TUI
with MCP enabled. The adapter discovers the running TUI, or requires
--instance PID when more than one is available. It shares that TUI's
cluster context, UI, proposal queue, and approval/audit boundary rather
than creating a headless Kubernetes client.
Migration required for direct HTTP clients: every MCP request now
requires the per-run capability in an HTTP authorization header, including
reads and UI actions. The stdio adapter handles this credential privately;
do not copy it into host configuration or prompts. The old capability
tool argument is rejected. Restart the host's MCP connection after a
kube-context switch or an MCP/TUI restart; existing connections do not
silently follow a new server run. OAuth and remote/headless MCP remain deferred.
Kubernetes JSON request failures are also normalized into actionable errors, so connectivity, TLS, and malformed-response failures do not escape describe and hierarchy workflows as raw transport exceptions. See MCP setup and migration for host configuration examples.
Breaking: the agent's configuration and plugin APIs¶
The embedded agent was rebuilt as one interaction harness — a single
AgentSession over one AgentEngine, with the provider boundary, tool
dispatch, prompt composition, durable history and evidence ledger each owned
by exactly one component. Two public surfaces changed with it.
agent.profile and agent.prompts were removed¶
korvid refuses to start when either key is present and prints the replacement. Silently ignoring a prompt override would leave a deployment believing its wording was still in effect.
| removed | replacement |
|---|---|
agent.profile: small |
agent.model_tier: low — or omit the key |
agent.profile: full |
agent.model_tier: high — or omit the key |
agent.prompts.append |
agent.rules (a list of short house rules) |
agent.prompts.system / system_file |
(none) — grind the tier pack in the eval harness |
agent.prompts.tool_descriptions |
(none) — deployment overrides removed: the low tier ships its own versioned, bounded wording by exact tool name; the high tier and the MCP server still use the registry's wording |
# before
agent:
profile: small
prompts:
append: "House rule: never include node names in an answer."
# after
agent:
model_tier: low
rules:
- "Never include node names in an answer."
Omitting agent.model_tier is now the recommended setting: routing resolves
the tier from what the provider reports, then korvid's shipped model catalog,
then a conservative low fallback. The agent panel header shows the resolved
route as tier (source) — for example low (catalog) — so both the tier and
the reason for it are visible without re-reading configuration.
Provider scalar keys removed¶
The flat agent.provider/agent.model/agent.base_url scalar shape was the
old way to configure a single connection. These keys are no longer written on
save, but an existing config that still has them is migrated automatically
into a named profile called default on first load, and the scalars are
removed from the file on next save. No manual editing is required.
# before (migrated automatically)
agent:
provider: ollama
base_url: http://localhost:11434
model: qwen3:8b
# after (written back on save)
agent:
active: default
profiles:
default:
model: ollama/qwen3:8b
endpoint: http://localhost:11434
auth: {method: none}
agent.model_tier, agent.rules, agent.follow and
agent.disable_in_protected all keep their meaning. The agent.ollama.*
tuning block does not: it is read only by this migration, which folds its
six knobs into the new profile's options. Once agent.profiles is present in
the file the block is ignored entirely, and the next save removes it along with
agent.enabled and the other legacy scalars — active: null is the new off
switch. Put those knobs in a profile's options instead.
Migration warning: a large agent.rules block can now fail to start¶
The low tier's static prompt — the safety contract, the common role, the
tier pack, and the armed-capability clauses, fully armed (writes and both
screen tools) — is exactly 4,871 characters of the 6,000-character
budget PromptHarness enforces for it (25% of the tier's 24,000-character
history budget), leaving 1,129 characters of headroom for
agent.rules and any provider/exact-model overlay. This budget is new:
the retired agent.prompts.append mechanism enforced no such share of the
history budget, so a rule set (or system/append text migrated
verbatim into agent.rules) that the old profile-based agent accepted
without complaint can now be too large. Agent composition raises
StaticPromptTooLargeError instead of silently crowding out the conversation;
initial application startup catches that error, warns, and starts with the
agent disabled. Shorten the rule set (or move guidance the tool descriptions
and safety contract already cover) before reconnecting the agent.
Provider extensions now declare special flows¶
The korvid.provider entry-point group now loads SpecialFlow declarations;
it no longer loads the former provider factories. Most OpenAI-compatible
backends need only a named profile with an endpoint, not a plugin. Custom
authentication for a standard transport belongs in a korvid.credential
chain.
A special flow claims a reference prefix and constructs an LLMProvider
from its profile. The provider exposes descriptor -> ModelDescriptor and
capabilities -> ModelCapabilities, rather than the retired name property.
Unknown capabilities fall back to the catalog and then to the low tier;
supports_tools=False refuses agent startup. The declaration's prefix must
match its normalized entry-point name. See
provider plugins for the current declaration,
credential-chain, and provider contracts.
What tiers actually change¶
| low | high | |
|---|---|---|
| iterations per turn | 6 | 15 |
| retained history | 24,000 chars (hard bound) | 120,000 chars |
| per tool result | 3,000 chars | 8,000 chars |
| tool calls per response | 1 | provider-confirmed parallel, else 1 |
| screen tools armed | open_logs, open_describe |
all five |
The safety perimeter is unchanged¶
Nothing in this rebuild moved a security boundary. Every write tool the
environment arms still opens the same approval dialog, at every tier, and
only a user keystroke executes it; a write still carries the preconditions
for the exact target UID established before approval; audit logging is still
fail-closed, so a write whose audit record cannot be written does not run;
read-only mode still means no write schema is offered at all; there is
still no shell or free-form kubectl tool at either tier, so the agent's
whole cluster surface remains the structured tool registry — the resolved
policy arms only the registry's own exact tool names, the registry
validates every dispatch target against its import-time metadata, and the
ToolExecutor rejects any name outside that registry as an unknown tool
and performs its own explicit, typed argument validation before a write
reaches the cluster (the tool's declared JSON schema is model-facing
wording, not the runtime check); and every tool result still passes the
masking pipeline before it reaches the model or the provider. House rules
are composed after the immutable safety contract and cannot widen it.
Other agent-visible changes¶
- Device-login prefixes are refused, not resolved. LiteLLM 1.98 resolves
both
github_copilot/…andchatgpt/…by starting an interactive device-code sign-in inside its routing call — writing a credential file under~/.config/litellm/and ignoring the profile's own key (and, forchatgpt, its endpoint). korvid claims both prefixes before it routes, so such a profile is served by a flow korvid ships or refused with a logged reason. Neither appears in model search unless a flow serves it. - Evidence citations. Each successful cluster read mints a numbered reference the answer cites; opening a citation navigates to the exact object the read looked at. Screen actions and writes never mint evidence. Switching Kubernetes context clears the ledger.
- Direct control. The agent drives the same panes your keys drive. Pressing a normal key mid-turn keeps working, and the next turn starts from wherever you left the screen.
- Optional install unchanged in shape, stricter in behaviour. The agent
still ships in the
[agent]extra; a base or MCP-only start now provably loads no engine, gateway or provider module, and an enabled agent with the extra missing fails with the exact install command instead of degrading silently. - Eval artifacts. Journey artifacts publish
successful_journeys(whole conversations whose every turn succeeded) and the markdown tables label their verdict columnsall-turn journeysandcorrect diagnosis, so the two different measurements are no longer both called "success". A run's per-turnsuccessis derived from its outcome and can no longer contradict it. - Entra ID actually resolves for an
azureprofile.auth.method: provider-defaultused to mean only "pass no API key and let the SDK look", and the SDK's own Entra chain is behind a process-wide opt-in that is off by default — so anazureprofile relying onaz loginor a managed identity authenticated with nothing. korvid now declares the credential chain, per reference prefix, and resolves it while the profile is being built. A missingentraextra is refused there, naming the install command, instead of surfacing as anImportErrorfrom inside the provider SDK on the first message. Third parties can declare a chain the same way, on thekorvid.credentialentry-point group — see Provider plugins. - A malformed profile block disables its profile instead of being
ignored.
auth:oroptions:written as anything other than a block —auth: environment,options: 8192— used to be replaced by an empty one, so korvid connected with no credential, or without the settings the file named, and said nothing. Such a profile is now reported and refused until it is fixed, and the line is preserved verbatim by the next save instead of being overwritten.:modelis refused on such a profile as well: the block is repaired in:ai, never dropped by a model change. An absent block, andauth:with nothing after it, still mean "not set". Entries filed under a key that is not a name (1:) keep that exact key through a save, so they can no longer collide with, or be promoted into, the profile named"1". - Truncation marker. A tool result too large for the tier's budget is
still shortened from the middle, and the marker the model reads there now
names the budget it hit:
… [middle truncated — tier result budget]. It is a small change to what the model sees, so a campaign comparing scores across this release is comparing two slightly different prompts. - A cut-off answer is refused instead of reported as a whole one. Each
built-in transport now needs its protocol's own end-of-answer signal
before it will report success: the wire's
[DONE]for the Copilot dialect,done: truefor Ollama's native API, and — measured against LiteLLM 1.98, which puts afinish_reasonon the last chunk of every stream — afinish_reasonthe provider itself sent for the shared transport. Without it the turn ends with an error: text that really streamed stays on screen, but the tool calls, the token counts and the answer are not accepted. Reading also stops at that signal, so a host that keeps writing cannot append to a turn korvid has closed. A whole non-streaming response is unaffected; it is complete on its own terms. Self-hosted and proxied endpoints: a clean end of the response body is not the signal, and for an OpenAI-compatible endpoint neither is a baredata: [DONE]— some gateways append one of their own. If:ai's connection test now fails against a gateway that used to work, stream a request through it and countfinish_reasonin the raw frames; none means the field is dropped or rewritten in transit, and the proxy needs upgrading or its chunk-rewriting middleware turning off. The agent guide has the command. - Hidden buffers are bounded. One tool call's arguments (64 KiB), the
number of calls one response may open (64), the reasoning an adapter
holds for the next request (64 KiB) and the wizard's connection-test
reply (4,096 characters) all have limits now — they used to grow with
whatever the provider sent, out of reach of the per-response budget.
An answer past the connection-test bound fails the test rather than
being shown cut short: trimming it would end the read before the
adapter could tell whether the stream had finished at all.
Reasoning that streams through is charged to that budget by its real
length rather than as one character. Every provider failure is reported
in korvid's own written words: a refused status, an unreadable frame or
a dropped connection no longer quotes the host's response body, which is
where a provider echoes the credential it just refused.
:ai's connection test holds the same line for the failures that happen before and around the stream — a locked keyring, a credential store that refuses, a request the outbound policy blocks — which used to be printed in the wizard exactly as the library phrased them, quoting the key and the endpoint. The wizard now shows one written sentence and the real reason goes to the log. Its interactive sign-in answers the same way: a device login that cannot start, or that never completes, says which of the two halves failed and nothing else, because an authorization exchange quotes the very token it is minting. - The models.dev cache is staged under a name nobody can pre-empt.
The envelope was written to a fixed
models-dev.tmpbeside it, so a file already sitting at that name was truncated and then renamed into place — keeping whatever permissions it had been created with — and two korvid processes refreshing at once wrote through each other. Each write now takes its own staging file in the same directory, created exclusively and set to0600on POSIX, and removes it on every failure path. On Windows the confidentiality is still the per-user cache directory's inherited ACL, as it always was.
Dependency note: [agent] grew¶
The [agent] extra now transitively installs approximately 55
distributions, up from a handful, because it includes litellm, which
brings boto3, openai, tiktoken, tokenizers, and their dependencies.
This is a real increase.
A base install (pip install korvid, no extra) is unchanged — it reaches no
AI library and starts no HTTP client. The import-graph test in
tests/test_optional_extras.py pins that boundary.
The alternative to LiteLLM — maintaining vendor adapters by hand — is a correctness risk: stale routing tables silently send credentials to the wrong host. The increased dependency count is the tradeoff for a maintained catalog.
Local safety and usability fixes¶
- MCP log and event output now passes credential-pattern masking, and outbound masking also recognizes structural private-key material and supported PEM formats. Masking is defense in depth, not a guarantee that arbitrary log content contains no sensitive information.
- Switching Kubernetes context resets the namespace safely. An inline input that owns focus cannot accidentally confirm a write approval.
- Pod transfer/debug refuses to proceed when the target UID cannot be revalidated. Helm mutations revalidate the release incarnation so a replaced release cannot inherit an earlier approval.
- Uploads close the exec stream's stdin correctly, including empty files. Describe search, filtered resource hints, and Secret reveal/remask column sizing were corrected through actual TUI testing.
Verify¶
The GitHub Release provides the wheel, source distribution, checksums, SBOM, offline bundles, and provenance from the tagged commit: