Threat model: the external AI data boundary¶
What korvid sends to embedded AI providers, what it withholds, where the trust
boundaries are, and the residual risks that are not mitigated. Every claim
here exists in current code — chiefly agent/outbound.py, core/redaction.py
and tools/structured.py. It is not a general security overview:
docs/ops.md has the cluster-write safety model.
flowchart LR
K["Kubernetes/UI"] --> T["ToolExecutor/Runtime"]
T --> O["OutboundPolicy"]
O --> I["Inspector snapshot"]
O --> B["Built-in provider transport"]
O --> P["Trusted provider plugin"]
M["External MCP client"] -->|"no embedded-provider boundary"| T
OutboundPolicy is the one fail-closed choke point in front of an embedded
provider. An external MCP client never crosses it, and therefore owns its own
AI data boundary.
Assets¶
- kubeconfig, and the credentials and contexts it grants access to.
- Cluster reads: manifests, logs, events, listings, and
Secretvalues (data/stringData), decoded or raw. - Logs and events — free-form text that may embed secrets no schema declares.
- Credentials and audit records
(
~/.local/state/korvid/audit.jsonl). - Exported payloads: the sanitized request
:ai payloadexports, and any private log or text export.
Trust boundaries¶
- Kubernetes API — everything korvid reads or writes crosses here first; RBAC on the active context is the only access control.
- TUI and core — in-process and trusted: the store, watch manager, audit log and write path hold cluster data in memory and execute approved mutations.
OutboundPolicy— the fail-closed choke point every message, tool result and tool-call argument passes before an embedded provider request is built. It validates shape, redactsSecretdata and credential-shaped text, strips control characters, enforces a character budget, and produces the immutableOutboundSnapshotthat ships and that the inspector shows.- Model endpoints, remote or local — receive the sanitized canonical
payload over HTTPS; transport headers are built separately from the resolved
credential and never enter the snapshot. For a local runtime korvid trusts
the profile's configured
endpointto be the intended process. Dialect conversion runs before the policy and may only add — reordering history blocks the request instead of misfiling position-bound redaction records. - Special flows and provider plugins — third-party
korvid.providerentry points are trusted in-process code (seedocs/provider-plugins.md), built from a profile and never from conversation data. What they return receives the same sanitized payload; nothing past that handoff is policed. - MCP stdio and internal loopback authentication —
korvid mcp stdio(docs/mcp.md) connects to the running TUI's separate127.0.0.1endpoint. Every read, UI action, and opt-in write proposal requires a per-run capability in an HTTP header, never tool arguments. The adapter reads a private registry, refuses redirects, and bypasses environment HTTP proxies. It does not call throughOutboundPolicyor any embedded provider at all. - Observability connectors — a second outbound boundary. Queries come from
a closed catalogue: the model supplies label values and one log substring,
never a query. TLS verification cannot be disabled, and a token is read at
call time, used in one header, and appears in no result, error or log line.
What comes back is untrusted text, masked in
ToolExecutorbefore either consumer sees it (seedocs/observability.md). - Filesystem exports — payload and log exports are written
0600under$XDG_DATA_HOME/korvid/;$XDG_STATE_HOME/korvid/holdsaudit.jsonland the MCP endpoint registry. These are not sanitized provider payloads: the registry is a private credential file and must not be shared.
Attackers and abuse scenarios¶
- Malicious cluster content / prompt injection — a compromised workload's
logs or events carry text engineered to steer the model.
OutboundPolicytreats tool-derived text as data and neutralizes control characters and credential-shaped substrings, but cannot detect semantic prompt injection. - Compromised provider, plugin or MCP client — any could retain, log or re-transmit what it legitimately receives; korvid controls what crosses each boundary, not what the far side does next.
- Local user or process — another local account reading exported files or reaching the MCP loopback port, or a capture copied out without the exporter realizing what it holds.
The agent extra: dependencies and lockdown¶
Dependency surface. [agent] pulls approximately 55 distributions,
including litellm, boto3, openai, tiktoken and tokenizers. The extra
is optional, tests/test_optional_extras.py pins that import graph, and the
alternative — a hand-maintained vendor routing table — routes credentials to
the wrong host when it drifts.
Lockdown at import. providers/litellm_runtime.py sets eight attributes on
the litellm module before any completion call is possible. Each is a channel
that would otherwise carry prompts, tool arguments, usage records or debug text
to a third party or the terminal:
| Attribute | Value |
|---|---|
telemetry |
False |
turn_off_message_logging |
True |
success_callback |
[] |
failure_callback |
[] |
callbacks |
[] |
_async_success_callback |
[] |
_async_failure_callback |
[] |
suppress_debug_info |
True |
Two are private attributes, set deliberately because they are what the SDK
reads at call time. All eight are checked for existence before assignment, so
a rename upstream raises at import instead of leaving the real sink open; a
test asserts the same list. providers/_litellm_import.py also forces
LITELLM_LOCAL_MODEL_COST_MAP=true before the import — assignment, not a
default, so an ambient false cannot re-arm it — suppressing the SDK's
startup price-table fetch, and strips StreamHandlers from its loggers.
The device-login routing hazard. Given a reference under LiteLLM's own
github_copilot or chatgpt prefix, the SDK starts an interactive
device-code sign-in and writes a credential file (under
~/.config/litellm/) from inside its routing call, even if the intent was
only to resolve the reference — and it replaces the profile's credential,
chatgpt its endpoint too. DEVICE_LOGIN_PREFIXES in
providers/litellm_settings.py claims both ahead of routing, the underscore
spelling folds onto the same claim, and the claim holds whether or not
korvid's Copilot flow is installed. Such a reference is either served by a
flow korvid ships or refused. A test rediscovers the set from the installed
release, so a future one adding a third device-code provider fails there.
models.dev¶
korvid makes at most one conditional GET of https://models.dev/api.json for
optional model metadata, under these bounds:
- Never at startup, on mount, on a keystroke, or during routing. Only Ctrl+R on the model search screen contacts it, forcing a revalidation; otherwise korvid serves its cache unchanged. No HTTP client exists until then.
- No credentials, no korvid state — no API key, cluster context or conversation data.
- Verified TLS, one trust decision — the client comes from the same builder
as every other korvid-owned HTTPS client, so
network.ca_bundleapplies and verification can never be switched off. A bundle that will not load makes the refresh unavailable; it never retries unverified. - Bounded — a 10-second deadline over the whole request, a 12 MiB streaming
ceiling,
application/jsononly, redirects refused, and a strict schema: a document that passes those checks but fails validation is discarded. - Cached
0600, revalidated conditionally on the storedETag, so an unchanged document costs a round trip and no download. The cache is$XDG_CACHE_HOME/korvid/models-dev.jsonwherever that variable is set, and otherwise~/Library/Caches/korvid/models-dev.jsonon macOS,%LOCALAPPDATA%\korvid\models-dev.jsonon Windows,~/.cache/korvid/models-dev.jsonelsewhere. - Disableable —
agent.model_search.models_dev: falsebuilds no source and no client, so there is no socket to open. Onlytrueandfalseare read as themselves; anything else warns and fails closed tofalse, so a quoting slip cannot re-enable the fetch in an air-gapped deployment.
Setup model discovery is the one setup-time request carrying a credential,
and it goes to the operator's own endpoint on request only. Each attempt joins
a path onto the configured URL, keeping its scheme, host and port; anything
naming no http(s) origin is refused before a client exists, and redirects are
refused. The key is borrowed for the call, never stored or logged. One 5-second
deadline covers both attempts and the parse, under a 2 MiB, JSON-only,
500-entry ceiling; every failure is an empty listing.
Residual risk. A network observer can infer that a korvid instance
refreshed its model metadata from models.dev. No cluster payload, user
identity or credential crosses that channel; it is the only outbound connection
the agent component makes that carries no provider payload.
Mitigations (implemented today)¶
- Redaction before reduction — one shared recursive redactor runs where a
manifest is produced (
ToolExecutor, and so the MCP server behind it) and again at the outbound boundary. The producer-side pass is not redundant: the size bound elides mapping entries, so a document reduced first can reach the boundary with its credentials intact. It replaces aSecret'sdata/stringDataat any depth and stripskubectl.kubernetes.io/last-applied-configurationfrom every object. - Credential-key redaction — the key stays and its value goes, for
exact names (
password,token, …) and whole compounds that spell one (AWS_SECRET_ACCESS_KEY). Only a boolean survives; one bit cannot carry a credential. - Declared, single-reading parsing — structural redaction or text masking
is chosen by the tool registry, never defaulted, and an undeclared result is
refused;
load_structured_documentthen rejects a repeated mapping key or any anchor reference, so a secondkind:cannot smuggle credentials past the classifier. Screen context and every string in a tool definition take the same text pass as a result. - Request caps — results and retained history are bounded, and
OutboundPolicyblocks an over-budget request rather than sending it. - Response caps and completion evidence — an adapter accepts a streamed answer only against its protocol's own end-of-answer signal, stops reading there, and drops the tool calls and token counts of a stream that ended without one. What it buffers on the way is bounded too: one call's arguments, the number of calls a response may open, held reasoning, and the wizard's connection-test reply. Provider failures are reported in korvid's written words, never by quoting a response body.
- Protected contexts and CA trust —
agent.disable_in_protectedrefuses prompts on protected contexts, andnetwork.ca_bundlemakes internal TLS verify rather than be disabled (docs/airgap.md). - Private MCP registry — the endpoint credential is protected before writing, with POSIX owner-only permissions (and an atomically supplied non-inheriting ACL on macOS) or a protected Windows DACL restricted to the current user, SYSTEM, and Administrators. Failed private publication prevents MCP startup; unsafe discovery fails closed.
- Write approval gate — every cluster mutation waits for a user keystroke
in a confirmation dialog; the agent
and MCP write-proposal flows can only request a write. Private exports are
created with
O_EXCLand POSIX mode0600. - Fail-closed audit and redaction — a write whose audit entry cannot be
written is blocked, and a result that cannot be redacted stops the turn: the
agent rolls it back and makes no further provider request, and an MCP client
gets a safe error naming the shape rather than the document. The treatment is
stated by the result's producer, so a document cannot skip the structural
pass by opening with
ERROR:.
Residual risks (not mitigated)¶
Explicit, current limitations — not aspirational future work.
- Stable identifiers are not anonymized. Resource names, namespaces, labels, node names and image references cross unchanged; anyone who reads the payload can correlate it with your cluster.
- Arbitrary secrets in free-form logs cannot be guaranteed detectable. The
policy masks known credential-shaped patterns and
Secretfields, not an application-specific token in unstructured text.--token=…is masked;--tokenfollowed by the value as a separateargselement is not. - Local endpoint trust is not verified. For an
ollama/…profile or a self-hostedendpoint, korvid sends the sanitized payload to whatever process is listening at that address. - Plugin post-handoff behavior is out of scope. Trusted in-process code may mutate, retain, log or independently transmit what it received.
- MCP callers own their own AI boundary. The MCP server hands cluster reads
(and, opt-in, write proposals) to external clients without routing them
through
OutboundPolicy. Structured manifests are still redacted producer-side; compound workload diagnoses, logs, and events are credential-pattern masked before their result caps (seedocs/mcp.md). Lists, single-pod diagnoses, and Helm status carry only their tool-specific shaping. - Same-user processes and administrators remain trusted locally. MCP transport authentication separates other unprivileged local accounts; it cannot isolate a malicious process running as the TUI's user or an administrator. OAuth, remote MCP, and headless operation are not provided.
- Raw logs and the audit trail are not provider payloads and are not sanitized like one.
0600does not prove exclusive access on every platform. On Windows theos.openmode argument does not map onto NTFS ACLs, so a private export's confidentiality depends on the enclosing directory's inherited permissions, not the mode korvid requested. The MCP credential registry is separate: it explicitly creates and checks a protected Windows DACL.
What the inspector proves — and what it does not prove¶
:ai payload renders OutboundSnapshot.export_json(): the exact canonical
messages and tools JSON that OutboundPolicy.prepare() produced for the
most recent provider call, the model it was addressed to, and every redaction
applied along the way. The list spans the whole pipeline, because a redaction
that removed its own evidence earlier — a stripped control character, a mapping
elided to fit the size bound — leaves nothing for a later pass to rediscover. A
turn that was blocked or rolled back sent nothing, so it leaves the previous
handoff on display.
It does not show transport headers (Authorization, API keys, tenant
headers), attached separately from the resolved credential; non-message request
fields an adapter sets for itself (Ollama's think, options.num_ctx); or
anything a plugin or remote endpoint does with the payload afterwards. Report a
vulnerability through
SECURITY.md.