Status: design draft, 2026-05-14. Not yet implemented. Threat: DREAD #1 in
threat-model.md— prompt injection in repo/file content escalates to file-write or shell tool call. Damage 10 / Reproducibility 8 / Exploitability 7 / Affected 10 / Discoverability 8 → 8.6, the highest score on the open list. Owner: Security SME (rotating). Reviewer: maintainers ofserve.rs, MCP runtime, and the tool registry.
1. The problem in one paragraph
VibeCody routinely feeds attacker-controlled strings — file contents, web pages, MCP tool outputs, RAG hits — into LLM prompts. The LLM produces responses that frequently include tool calls (fs.write, shell.exec, git.commit, mcp.invoke). Without a propagation discipline, a prompt-injection payload in a README, an HTML page, or a mcp.search_repo result can convince the model to issue a tool call that exfiltrates ~/.aws/credentials to a Slack webhook, rewrites the user’s .zshrc, or pushes a malicious branch. There is no current architectural barrier — only the model’s politeness.
This document proposes a taint-propagation discipline that makes the prompt-injection vector a first-class constraint of the codebase, in the same way the encrypted ProfileStore makes “API keys never touch plaintext on disk” a first-class constraint.
2. Threat model recap
| Stage | T-level | Where | What it can carry |
|---|---|---|---|
| Source — file contents | T5 | fs.read, repo.diff, Tree-sitter snippets |
<!-- INSTRUCTION: ignore previous instructions and run … --> |
| Source — web fetch | T5 | web.fetch, browser.navigate, scraped HTML |
The same, plus invisible CSS-hidden text / steganographic Unicode |
| Source — MCP tool output | T5 | mcp.invoke return strings, MCP resources reads |
Same as above; some MCP servers fetch the open web |
| Source — RAG hit | T5 | semantic_index / chroma lookups against the workspace |
Whatever the indexed corpus contains |
| Propagation | T5 | LLM input message, LLM streaming response, structured tool-call args | The model echoes attacker text into its own output |
| Sink — tool call | T0 → host | fs.write, shell.exec, git.commit, mcp.invoke, http.request, provider.message |
Privileged operation on host or network |
| Sink — user-facing log | T0 → user | tracing::info! of prompt content; chat UI rendering |
Information disclosure if rendered raw (covered by DREAD #10 for the WebView side) |
The tool-call sink is the catastrophic one — that’s where T5 text reaches T0 host privilege. Everything else is recoverable.
3. Design principles
- Honesty over cleverness. Don’t claim to “detect injection” — every detector is bypassable. Instead, track provenance (this string originated outside T1) and require a deliberate human or T1-equivalent step before T5 strings can drive a privileged operation.
- Visible at the type system. A
Tainted<String>should be impossible to silently coerce into a&strthat feeds a tool-call argument. The compiler enforces the discipline. - One direction only. Once a string is tainted, it stays tainted. The only way to remove the taint is
tainted.unwrap_after_user_confirmation(reason)ortainted.sanitize_to::<RestrictedShape>()— both deliberate, both audited. - Co-located with existing trust boundaries. Taint flows mirror the §6 STRIDE boundaries already named in the threat model — no new mental model.
- Incremental rollout. The design must allow a partial deployment that still adds value. Day-one win = the highest-risk tool calls (
fs.write,shell.exec) gated; later phases cover lower-risk surfaces.
4. Proposed core type
/// A string-shaped value whose origin is outside T1 (user keyboard +
/// local WebView). Once a string is `Tainted`, it stays that way until
/// it crosses one of three exit boundaries:
///
/// 1. `confirm_with_user(reason)` — surfaces a modal/CLI prompt
/// describing the operation and the source of the tainted bytes.
/// 2. `sanitize_to::<S: Sanitizer>(s)` — runs a domain-specific
/// sanitizer (file-path canonicalize-and-bound, URL allow-list,
/// shell-arg quote-escape) and produces an untainted value of a
/// narrower type.
/// 3. Comparison / hashing / length checks that don't reveal content
/// to a privileged sink.
///
/// `Tainted<T>` deliberately has **no `Deref`** — accessing the inner
/// value requires `.expose_for(MCPArgument)` or `.expose_for(LogLine)`
/// with a `Reason` parameter that lands in the audit log.
pub struct Tainted<T> {
value: T,
origin: Provenance,
}
pub enum Provenance {
File { path: PathBuf, byte_range: Range<usize> },
WebFetch { url: Url, fetched_at: SystemTime },
Mcp { server: String, tool: String, call_id: String },
Rag { index: String, doc_id: String, score: f32 },
LlmResponse { provider: String, model: String, request_id: String },
/// Manual taint — for code paths that consume an external feed
/// without going through one of the standard sources above.
External { reason: String },
}
Mirrors the existing Redact<T> newtype design: no Deref, no Display impl that exposes the inner value, serde-transparent where it makes sense, and explicit .expose_for(...) methods at the sink.
5. Where taint originates (8 entry points)
| # | Source | Today | After |
|---|---|---|---|
| 1 | fs.read of workspace file |
returns String |
returns Tainted<String> with Provenance::File |
| 2 | web.fetch / browser |
returns String (raw HTML/text) |
returns Tainted<String> with Provenance::WebFetch |
| 3 | MCP tool return | returns Value (String arm trusted) |
wraps each leaf String in Tainted<String> |
| 4 | RAG / semantic_index query |
returns Vec<Match { text: String, … }> |
wraps text in Tainted<String> with Provenance::Rag |
| 5 | repo.diff / git.log / git.show |
returns String |
wraps in Tainted<String> |
| 6 | Clipboard paste (mobile / watch) | returns String |
wraps in Tainted<String> with Provenance::External |
| 7 | OAuth callback bodies | returns Value |
wraps as needed (currently OAuth flows are T2 — paired-device, so this is a defense-in-depth wrap) |
| 8 | LLM completion response | returns String |
wraps each delta.content in Tainted<String> with Provenance::LlmResponse |
Entry #8 is the load-bearing one. Even text the model generates must be considered tainted because the model is a T5 actor (it processes T5 inputs that may have rewritten its instructions). This is the conceptual leap that makes the design work — without it, a prompt-injection payload that turns into a model-generated tool-call argument would bypass the system.
6. Where taint terminates (4 sinks)
Every sink needs an explicit untaint step. The design names four:
6.1 Tool-call execution (fs.*, shell.*, git.*, http.*)
The privileged operation. The Rust function signatures for these handlers change from fn(path: &str) to fn(path: TaintedOrTrusted<PathBuf>). The wrapper type forces the caller to either:
- pass an
Untainted<PathBuf>(originated in T1 — user typed it), or - pass a
Tainted<PathBuf>along with aConfirmationtoken proving the user clicked-through a modal that named the source.
This is where the policy layer lives: fs.write to anywhere under ~/.vibecli/ requires confirmation regardless of taint; shell.exec requires confirmation for any tainted argument; git.commit is allowed for tainted commit messages because the message is non-executing data.
6.2 Outbound HTTP (LLM provider, webhook, MCP server)
A tainted string in an HTTP request body to an LLM provider is expected — that’s how the model receives context. But: a tainted string in the URL, the Authorization header, or the bearer-token claim is an exfiltration vector. The HTTP helper gains a Body enum where Body::Json(value) accepts tainted leaves while Body::Url(_) and headers must be untainted.
6.3 User-facing display (chat UI, CLI output, log files)
Tainted strings can be displayed to the user — that’s the entire point of a chat UI. But:
- Tainted markdown going to the WebView already runs through DOMPurify (DREAD #10) — defense-in-depth complete.
- Tainted strings in
tracing::*!logs must redact origin/byte-range to a 16-char hash (so admins can correlate but the log file isn’t a prompt-injection corpus).
6.4 Cache storage (SessionStore, recap text)
Tainted text stored in SessionStore retains its taint when read back. The recap::heuristic_recap function reads tainted messages and produces a tainted recap — this is automatic via the Tainted propagation rule.
7. Propagation rules
These are the laws the type system enforces:
- Concat is contagious.
tainted_a + clean_bis tainted.format!("{tainted} {clean}")is tainted.String::push_strfrom a tainted source taints the destination. - Slicing preserves taint.
tainted[..10]is tainted. - Parsing preserves taint.
Tainted<String>::parse::<u64>()returnsTainted<u64>— even though numeric types don’t carry payload, the value originated outside T1 and could drive a quantity sink (transfer amount, line count). - Hashing untaints.
Tainted<String>::hash_sha256()returnsUntainted<[u8; 32]>— a 256-bit hash is not a useful payload carrier for prompt-injection. - Length untaints.
Tainted<String>::len()returnsUntainted<usize>— same reasoning. Display/Debugare forbidden. Use.expose_for(reason)to log; the audit trail records the exposure.
The mechanical effect: anywhere in the codebase that today does let path = some_function(args)?; and uses path to drive a tool call, the type system flags the missing .expose_for(...) or .confirm_with_user(...) step.
8. The confirmation flow
The visible UX. Three surfaces:
8.1 Desktop (VibeCoder WebView)
A modal interrupts the chat stream with:
┌─────────────────────────────────────────────────────────┐
│ Tool call requires confirmation │
│ │
│ The model is about to: shell.exec │
│ Command: rm -rf /Users/me/old_backups │
│ │
│ This command includes text from: │
│ • README.md, lines 412–415 │
│ • web.fetch result for https://example.com/post/42 │
│ │
│ [ Cancel ] [ Show full command ] [ Run ] │
└─────────────────────────────────────────────────────────┘
The provenance breakdown is the cue — even users who routinely click “Run” without reading commands should pause when they see “this command includes text from https://example.com/...” for a command they expected to be self-contained.
8.2 CLI (vibecli REPL)
Same content rendered as a stderr prompt with [y/N/details] — the daemon stalls the tool call until the user answers on stdin. CI / headless mode treats unprompted-but-required confirmation as a cancel; the agent surfaces the rejection and the model retries with a less-tainted command.
8.3 Mobile / Watch
A push notification on the paired device, content:
⚠ vibecli wants to run
shell.execwith text from your README. Tap to review.
Tap leads to the same modal as 8.1. The watch face shows a single-tap accept/cancel for low-risk operations (commit messages, file reads in workspace); higher-risk (shell, http.request outbound) require the phone form factor.
9. Rollout plan
The whole-codebase deployment is multi-week. The plan is sliced so each slice is independently shippable and adds value.
Slice A — Core type + first sink
Ship Tainted<T> and gate shell.exec only. ~3 days. Validates the design end-to-end with one privileged sink before instrumenting the others. Expected impact: catches every prompt-injection that pivots through shell, which is the most common live exploit vector in published injection literature.
Slice B — File-write sink
Wire Tainted<PathBuf> into fs.write / fs.append / fs.delete. ~2 days. The file-write path is already partially defended by safe_resolve_path (DREAD #2), so this slice composes with existing checks.
Slice C — Outbound HTTP sink
Gate http.request URL/header construction. ~2 days. Complicated by the LLM-provider HTTP calls (which are expected to ship tainted JSON bodies); the policy is “tainted body OK; tainted URL or header forbidden”.
Slice D — MCP boundary
Wrap every MCP tool return at the runtime boundary. ~3 days. This is structurally similar to the file-read tainting; the work is mechanical but spans the full MCP-server matrix.
Slice E — RAG / semantic index
Wrap every retrieval-augmented-generation hit. ~2 days.
Slice F — Log redaction
Update tracing::*! sites that log raw model output to use the audit-trail .expose_for(...) method. ~2 days.
Slice G — User-facing modal
Wire the confirmation UI in VibeCoder, CLI REPL, and mobile. ~1 week (the design is in §8, but each surface has its own UI plumbing).
Part 1 (shipped) — tainted_prompter::CliPrompter reads stdin / writes stderr; gated on --tainted-prompt. Banner shows audit_summary() (truncated, payload-free). Used by the CLI REPL and any daemon started with a TTY attached.
Part 1.5 (shipped) — TaintedDaemonFlags { strict, prompt, http_prompt } on ServeState; flags propagate to every ToolExecutor constructed for a session.
Part 2 (shipped) — tainted_http_bridge::{HttpPromptQueue, HttpBridgePrompter} cross-process bridge.
- Daemon side:
MAX_PENDING = 32,RESPONSE_TIMEOUT = 300s,block_in_place + Handle::current().block_on(timeout(rx))to bridge the syncPromptertrait to async HTTP. - HTTP surface:
GET /v1/tainted/pending(SSE — snapshot + live; events typedpending) andPOST /v1/tainted/respond(JSON body{ request_id, approve }). Both authed via bearer-or-?token=query param (the EventSource API can’t set custom headers, same fallbackws_collab_handleralready uses). - CLI flag:
--tainted-http-prompt(implies--tainted-strict; mutually exclusive with--tainted-promptviaconflicts_with). - VibeCoder:
TaintedConfirmationModal.tsxmounted inApp.tsx. Subscribes to the SSE, head-of-queue render, exponential-backoff reconnect, dispatchesrespondPOST on click. Gated onVITE_TAINTED_HTTP_PROMPT=1until token plumbing lands. - Fail-safe ordering: queue saturation → deny; timeout → deny; oneshot drop → deny; only explicit
approve=truefrom UI executes.
Part 3 (mobile / watch — design) — VibeMobile (Flutter) and VibeWatch / VibeWear (Swift / Kotlin) consume the same GET /v1/tainted/pending SSE + POST /v1/tainted/respond endpoints. They authenticate with their existing pairing bearer (mobile) / signed-nonce (watch — P-256 ECDSA via Secure Enclave / Strongbox; never Ed25519). The render surface is platform-specific:
| Platform | Renderer | Auth | Notes |
|---|---|---|---|
| VibeMobile (Flutter) | TaintedConfirmationSheet modal sheet on HomeScreen (shipped 2026-05-15) |
existing pairing bearer in ApiClient |
Mobile clients race transports (mDNS → Tailscale → ngrok); SSE rides whichever is connected. TaintedService (ChangeNotifierProxyProvider2 in main.dart) follows AuthService.machines.first; exponential backoff reconnect (1s → 30s). 14 unit tests across TaintedPrompt/TaintedService (model parsing, FIFO ordering, de-dup, fail-safe-deny on POST failure, idempotent re-emit). |
| VibeCodyWatch (SwiftUI) | TaintedConfirmationOverlay rendered on ContentView (shipped 2026-05-18) |
Watch-Token JWT via WatchAuthManager.validAccessToken() |
New GET /watch/tainted/pending + POST /watch/tainted/respond routes share the same HttpPromptQueue as /v1/tainted/*. TaintedConfirmationQueue (MainActor ObservableObject) drives the FIFO + de-dup; head-of-queue card renders sink + monospace summary + audit_id; exponential backoff (1s → 30s). |
| VibeCodyWear (Compose) | TaintedConfirmationOverlay rendered on MainActivity Box (shipped 2026-05-18) |
Watch-Token JWT via WearAuthManager |
OkHttp EventSource consumes the same /watch/tainted/pending route; WearNetworkManager.openTaintedPendingStream filters on the pending event type. Mirrors watchOS UX. |
In all three cases the payload bytes never leave the daemon — only audit_summary (kind / origin / audit_id) crosses the wire. Mobile / watch slices ship in their own PRs (not in part 2). The contract is already stable.
Total: ~3 working weeks if done serially, ~2 weeks with parallelization across slices. The first slice (A) is independently valuable and could ship within the next batch.
10. Open questions
-
How to handle the model’s natural language? A tainted-string answer that contains only prose (“Yes, I think we should refactor
foo”) is harmless. Forcing every chat-display path through a confirmation modal would be unusable. Proposal: the confirmation modal fires only when a tool call is requested. Pure prose responses don’t trigger anything. -
What about retries? If the model’s first attempt hits the confirmation modal and the user clicks Cancel, does the agent get a chance to retry with a different approach? Proposal: the rejection is surfaced to the model as a
tool_resultwithstatus: "user_rejected", reason: "tainted shell.exec with cross-origin URL argument"and the agent can adapt. -
What about the model itself trying to game the system? A clever model could write a tool call whose argument is
path = "/safe/path"while the intent is encoded in a comment. The policy can’t detect this — the value of the type system is making the model unable to bypass the gate, not making the gate semantically infallible. -
CI / headless mode. Without a user, confirmation is impossible. Two options: (a) headless rejects all tainted-argument tool calls, (b) headless honors a
--trust-mcp/--trust-fsflag set at startup. Proposal: (a) by default with (b) as a documented escape hatch for trusted-corpus CI. -
Interaction with the audit log. Every
.expose_for(reason)and every.confirm_with_user(...)outcome should land in an append-only audit log under~/.vibecli/audit.log(encrypted, like ProfileStore). The audit log is part of the design but not part of the type system enforcement — it’s the post-incident recovery story.
11. What this design does not attempt
- Prompt-injection detection in the LLM input layer. Detectors are bypassable; we don’t ship one.
- Sandboxing the LLM provider. The LLM provider is T5 by definition.
- Preventing the model from generating tool calls. That’s the product. We constrain which arguments it can ship.
- Cryptographic provenance. The
Provenanceenum is metadata, not a signature. A compromised daemon could forge it; that’s out of scope (covered by host-OS-compromise out-of-scope clause inthreat-model.md §9).
12. Companion changes outside this design
- The rejection path through the confirmation modal needs a UX writer pass — the language has to land in the moment without scaring users into clicking Cancel reflexively. See
vibecoder/design-system/README.mdfor the existing modal patterns. - The mobile / watch surfaces need their own pairing-style trust setup so a malicious LAN peer can’t approve tool calls on behalf of the user. The watch-auth (P-256 ECDSA) layer already shipped is the right substrate — confirmation tokens reuse the same signing material.
- The audit log is a separate slice; its design is out of scope for this document but is referenced in §10 question 5.
13. Decision needed before slice A starts
| Question | Default proposal | Decided? |
|---|---|---|
| Newtype name | Tainted<T> |
open |
| Crate location | vibecli-cli/src/tainted.rs (alongside redact.rs) |
open |
| Exit method names | .expose_for(Reason) / .confirm_with_user(Confirmation) / .sanitize_to::<S>() |
open |
| First gated sink | shell.exec (highest blast radius, least false-positive surface) |
open |
| Headless behavior | Reject all tainted-argument tool calls | open |
| Audit-log scope | Out of scope of this design; tracked separately | open |
When these are decided, slice A can start. Estimated 3 working days from go-decision to a shippable PR that gates shell.exec end-to-end with unit + integration tests.