docs/internal/trust-env-devcontainer-ux-plan.md
Status: design plan. Specifies the user-facing flow that re-enables the
workspace-trust prompt (currently a no-op, see
crates/fresh-editor/src/app/popup_dialogs.rs:977) and brings env activation
to parity with the devcontainer "reopen?" prompt.
Threat model and the trust levels themselves are out of scope here — they
live in workspace-trust-sandbox-design.md. This doc only specifies
when prompts surface, what they say, and how the three features
(trust / env / devcontainer) interact so the common case is 0–1 popup.
Open a folder. Things work. You start coding.
For the overwhelming majority of opens, that is the entire user-facing experience: no popup, no chip nagging, no "trust this folder?" The editor figures out the right thing from context, does it, and reflects what it did via the status bar. Modals are not the cost of admission for opening a project.
The rules below are stepping stones toward this state. They're chosen because each is implementable on the architecture we have today — but the direction we want every change to push is fewer interactions, more implicit defaults, clearer state at a glance. If a proposed change would add a prompt to a happy path that today has none, reject it.
/tmp/from-email/ is not. The editor
infers from context instead of asking.git pull.Familiar project, second open onward:
~/code/my-project · direnv ✓ · rust-analyzer ✓ · trusted
Status bar only. The editor recognized the folder, restored every decision, started every server. Zero clicks. The user starts coding.
New project from a familiar place (git clone into ~/code/):
~/code/new-repo · .envrc detected · ts-server starting
[Popup] This folder has a `.envrc`. Activate direnv?
[Activate] [Always here] [Not now] [Never here]
Trust is implicit from provenance. The TS server (unambiguous from
package.json) starts. The .envrc is a genuinely new decision and
gets a modal popup. Picking "Not now" leaves a clickable chip in the
status bar so the user has a visible way back to the decision.
Suspicious folder (/tmp/from-zip):
/tmp/x · restricted · 3 things would run here · LSPs off
[Popup] This folder is outside your usual workspaces. It contains
`.envrc`, `Cargo.toml`, `build.rs` — code that runs at
project load. Review or trust?
[Review what runs] [Trust this folder] [Read-only mode]
Framing is "outside your usual workspaces", not "MALWARE WARNING." "Read-only mode" is first-class — many users open random folders just to read code, and that path should require zero further decisions.
Devcontainer project:
[Popup] This project has a dev container (`api-service`).
Use it? Building takes ~2 min.
[Use container] [Stay local for now]
One question, one time per folder, ever. The decision persists. To revisit: click the authority indicator in the status bar.
Trust required for an LSP, on a restricted folder:
[Popup] rust-analyzer can't run here (trust required).
[Trust this folder] [Show what it would run] [Dismiss]
Contextual — names the concrete tool. Dismissing it leaves an
LSP: held chip in the status bar; clicking that chip re-opens the
same prompt.
"What is the editor doing?" panel (one keystroke, e.g. Alt+?):
Project actions
• Activated direnv (matched .envrc hash, allowed once)
• Started rust-analyzer (Cargo.toml found)
• Skipped: .devcontainer (you chose "stay local")
[Revisit decisions] [Restrict this folder]
Every decision is visible and revisitable. Discoverability without nagging.
(locked) pill that doesn't tell you what to do. Replaced
by a clickable chip that re-opens the relevant prompt.PopupResolver::WorkspaceTrust short-circuit at
popup_dialogs.rs:1019-1025) becomes the standard pattern for every
project-lifecycle popup.Not implementation detail, but capabilities that have to exist. After auditing what's already in the codebase, most of these are extensions of existing infrastructure, not new primitives:
TrustStore schema (or add a sibling decisions.json) to
carry per-question outcomes alongside the trust level. Cross-
machine sync is a dotfiles/external-config-service problem; the
editor's job is to keep the on-disk format stable and
merge-friendly..envrc re-asks. Re-using
the same .envrc across folders only asks once if the hash matches.status_bar_lsp_area, status_bar_remote_area,
status_bar_warning_area, …) wired through
handle_click_status_bar (mouse_input.rs:2146). Plugin
RegisterStatusBarElement lacks an area-tracking + click-dispatch
hook; adding one is a small extension to StatusBarLayout plus an
optional on_click field on the existing register command.show_workspace_trust_popup in
popup_dialogs.rs); it's currently dormant (the open-time call is
stubbed at line 977). Re-enable it with a body that names the
actual markers (executable_content_markers already returns them).Things we considered building but determined the existing infrastructure already covers:
action_popup_result broadcast + per-plugin dedup checks (mirror
of the trust-modal WorkspaceTrust resolver short-circuit at
popup_dialogs.rs:1019-1025) already handle ordering and
invalidation. The manager pays off at 10+ plugins, not now.trust.json and per-plugin global state
is fine in practice — different things stored in different places,
no actual disagreement. Standardizing the naming convention
(<plugin>:<question> ids) costs nothing and gives us coordination
without unification.PATH. Mitigation:
status bar visibly changes; action log shows what happened; one
keystroke undo.Never here) + content-hash short-circuit
(no re-ask when the file is unchanged) means each unique question
is asked at most once.The North Star is the target. The rules below are what we can ship on
the current architecture in this PR + the next couple. They
consciously trade some of the ideal (real provenance heuristics,
content-hash fingerprinting, sync-across-machines memory, an action
log, clickable plugin-token status-bar elements) for things that are
tractable today (one-popup-at-a-time within a single open, per-folder
memory in trust.json, the existing action-popup mechanism).
What the rules below do not require is a new visual primitive. We considered a "non-modal banner" abstraction and rejected it after an audit: the cleaner answer is concrete framing in the existing modal popup + a clickable status-bar chip for "Not now" follow-up. See §"Capabilities the editor needs to deliver this" and §"Path from here to the North Star."
When a future PR moves us closer to the ideal — e.g., wiring plugin status-bar tokens to dispatch click handlers — that PR should reference this section and explain which principle it advances.
Implementing all eight rules end-to-end touches the trust gate, persistence schema, status bar, plugin API, and at least three plugins. The first PR delivers the visible UX shift on the plugin side; the remaining Rust-core changes are tracked as Phase 2.
| Rule | Phase 1 (this PR) | Phase 2 |
|---|---|---|
1. .venv auto-activate silently | done — env-manager fires maybeAutoActivate on plugins_loaded and activates path-only envs without a popup | — |
2. .envrc/mise.toml combined trust+activate popup | done — env-manager surfaces the combined popup when trust is Restricted; Trust & activate dispatches workspace_trust_trust and applies the env in one step | — |
| 3. Devcontainer takes precedence, env defers | done — env-manager skips its popup when a devcontainer.json is present and authority is local; the post-attach plugins_loaded re-runs inside the container | — |
| 4. Deferred trust on first denied spawn | concrete trust-elevation popup from the env flow is wired (the user who runs Env: Activate against a restricted folder gets a concrete prompt instead of a dead-end status message) | replaced with a queue-and-drain model (see Rule 4 spec below): subscribers re-trigger their work on a TrustLevel broadcast — no new denial variant, no parked spawners, no synchronous block-and-wait inside gate |
| 5. Content-hash persistence | done in part — env decision is persisted per-cwd via plugin global state (env-decision:<cwd> → "activated" / "dismissed") so the popup doesn't re-fire after a decision | extend TrustStore JSON schema with per-marker SHA-256 so re-prompts fire only when .envrc / mise.toml content actually changes |
| 6. Restricted-mode chip in status bar | done — env-manager registers a trust status-bar element that shows nothing when Trusted, restricted / blocked otherwise | wire chip clicks to open the trust popup directly (today plugins can't register click handlers on status-bar elements) |
| 7. Never stack popups | done in part — env-manager defers entirely to the devcontainer plugin when both apply | core arbitration so any future plugin popup competing with the trust modal queues instead of stacks |
| 8. Trust parent folder setting | — | new setting workspace.trust.inheritFromParent + parent-traversal in workspace_trust.rs; off by default |
The rest of this document describes the full design. Items not yet wired in Phase 1 are called out inline.
Trust once, activate silently where safe, ask only when running shell — and make the non-trusted state visible.
This is the near-term goal — what the rules below collectively achieve on the current architecture. It is not the North Star; it is the shortest path toward the North Star that fits the existing popup, status-bar, and persistence primitives. The differences are deliberate:
.envrc on first open instead of
inferring from provenance, because provenance heuristics don't
exist. Once they do, the combined trust+activate popup goes
silent for clones into trusted parents.StatusBarLayout + handle_click_status_bar),
clicking the pill opens the same trust-elevation popup.popup_dialogs.rs:977 is
stubbed out as a WIP. Once re-enabled with concrete framing, the
abstract "this project can run code" question becomes "rust-
analyzer wants to run cargo here" — same modal, much better UX.Every "1" in the right column below is shorthand for "until provenance heuristics exist and the trust modal re-enables with concrete framing." When they do, the column drops toward "0."
| Folder contents | Popups today | Popups after this plan |
|---|---|---|
| Plain | 0 | 0 |
.venv / venv | 1 (trust) | 0 |
.envrc / mise.toml / .tool-versions | 1 (trust), then user must run command | 1 (trust + activate, combined) |
.devcontainer.json only | 2 (trust, then reopen) | 1 (reopen — trust folded in) |
| Both env + devcontainer | 2 (trust, then reopen) | 1 (reopen); env asks post-restart inside container |
.csproj / Cargo.toml only | 1 (abstract trust on open) | 1 (concrete, deferred to first spawn) |
.venv / venv auto-activates. No popup. Activation is a PATH
prepend; not arbitrary code execution. Status pill is the undo affordance..envrc / mise.toml / .tool-versions get a single combined popup.
"Trust this folder and activate direnv?" with [Trust & activate] / [Restricted] / [Block]. Trust + activate are one decision..csproj, Cargo.toml, …) and no env or devcontainer config opens silently in restricted mode. When a plugin or LSP first tries to spawn, the gate denies normally — the caller surfaces a popup naming the actual command ("rust-analyzer wants to run cargo"). Picking Trust broadcasts a TrustLevelChanged event; every subscriber re-triggers the work that was denied. The gate stays sync, the spawn stays a normal Allow/Deny, and no thread is parked waiting on a UI decision. See "Rule 4 spec" below..envrc / mise.toml / devcontainer.json are persisted
keyed by content hash. Unchanged file → silent re-activate next open.
Edited file → re-prompt with "this file changed since you trusted it".restricted: LSPs off) clickable to elevate. Env pill env: .venv (locked) clickable to trust-and-activate.| File | Change |
|---|---|
crates/fresh-editor/src/app/popup_dialogs.rs:977 | Replace the WIP no-op maybe_prompt_workspace_trust with the deferred-trust scheduler (rule 4) and the combined env popup (rule 2). |
crates/fresh-editor/src/services/workspace_trust.rs (around gate and set_level) | gate stays a sync Allow/Deny — no new denial variant. Add a tokio::sync::broadcast::Sender<TrustLevel> on WorkspaceTrust; set_level publishes the new level so subscribers (LSP manager, env-manager, devcontainer plugin) can re-trigger their denied work. See "Rule 4 spec" below. |
crates/fresh-editor/src/services/workspace_trust.rs:389-461 | Add content-hash recording per marker file alongside the path-keyed decision (rule 5). Split markers into "env-shell" (.envrc, mise.toml, .tool-versions, Pipfile, poetry.lock), "env-path-only" (.venv, venv), "devcontainer", and "project-manifest" — the four rules treat them differently. |
crates/fresh-editor/plugins/env-manager.ts:48-74 | Split detect() by category. .venv/venv → return a kind: "path-only" result that the plugin auto-activates without checking trust (rule 1, since no shell runs). .envrc/mise.toml → kind: "shell", gated on trust, surfaces the combined popup if undecided. |
crates/fresh-editor/plugins/env-manager.ts:84-87 | Replace the dead-end "not trusted" status message with the trust-elevation flow: untrusted user clicks Activate → combined [Trust & activate] popup. |
crates/fresh-editor/plugins/env-manager.ts:130-158 (status pill) | Pill (locked) becomes a clickable affordance — click fires the combined popup. |
crates/fresh-editor/plugins/devcontainer.ts:2376-2410 | Add a guard: if env-shell markers also exist and authority is local, the env-manager defers; nothing to change here, but document the contract. After successful attach + restart, env-manager re-runs inside the container — no change, this already works via plugins_loaded. |
crates/fresh-editor/plugins/csharp_support.ts:140-163 | On spawn Deny, surface a showActionPopup naming the command and offering [Trust & retry] / [Keep restricted]. Pick "Trust & retry" → executeActions(workspace_trust_trust). The trust_changed hook (fired by the broadcast subscriber on the JS side) re-invokes the spawn — the plugin doesn't have to remember to retry. Same shape as the env-manager's existing trust-elevation popup, just from a different trigger. |
crates/fresh-editor/plugins/lib/fresh.d.ts | Add a new trust_changed event (HookEventMap.trust_changed: { level: "trusted" | "restricted" | "blocked" }) bridged from the core broadcast channel. Plugins subscribe with editor.on("trust_changed", …) to re-trigger denied work after elevation. Not needed: a requestTrustElevation API — popups are normal showActionPopups wired to executeActions(workspace_trust_trust). |
crates/fresh-editor/src/services/lsp/manager.rs (LSP server retry) | Subscribe to the trust broadcast at LspManager construction. On Trusted, re-issue server starts that failed under Restricted (track per-language denial state). This is what makes LSP "come back online" after the user trusts, with no human-visible "retry" button anywhere. |
| (new) status-bar chip for restricted mode | Persistent indicator when workspaceTrustLevel() === "restricted", clickable to open the trust popup. Lives alongside the env pill. |
on_workspace_open(cwd):
markers = classify(executable_content_markers(cwd))
prior = load_decisions(cwd) # path + content-hash keyed
# rule 1 — silent
if "path-only" in markers and (no prior dismissal):
env_manager.activate_silently(".venv") # no popup, sets PATH
# rule 3 — devcontainer wins if present
if "devcontainer" in markers and prior.devcontainer is undecided_or_stale_hash:
show_devcontainer_popup() # existing flow
return # env defers to post-restart re-run
# rule 2 — combined env+trust popup
if "env-shell" in markers and prior.env is undecided_or_stale_hash:
show_combined_env_trust_popup(detected_name, marker_file)
return
# rule 4 — silent open, concrete prompt at first spawn
# no proactive popup. Restricted-mode chip is visible. The next spawn
# that hits Deny(Restricted) → its caller surfaces a popup naming the
# actual command. "Trust & retry" elevates the level; subscribers to
# the TrustLevelChanged broadcast re-trigger their work.
┌─────────────────────────────────────────────────┐
│ Environment detected │
│ │
│ This folder has a direnv environment (.envrc). │
│ Activating it runs shell from the folder. │
│ │
│ [ Trust & activate ] │
│ [ Restricted (no env, no LSPs run repo code) ] │
│ [ Block all execution ] │
└─────────────────────────────────────────────────┘
Trust & activate → trust level set to Trusted, env activates, hash recorded.Restricted → trust level set to Restricted, hash recorded, chip visible.Block → trust level set to Blocked, hash recorded, chip visible.This is the part of the plan that replaces the earlier "deferred denial / third-state" idea after a research pass (see "Why not block-and-wait" below).
A folder with only project manifests opens silently in Restricted. The restricted-mode chip from rule 6 is visible — that's the "something is gated here" signal. The user does not see a trust prompt on open.
The moment a piece of tooling actually tries to run, its caller shows a contextual popup naming the actual command:
┌─────────────────────────────────────────────────┐
│ Trust this folder? │
│ │
│ rust-analyzer wants to run `cargo` to load │
│ this project. Trust this folder? │
│ │
│ [ Trust & retry ] │
│ [ Keep restricted ] │
│ [ Block ] │
└─────────────────────────────────────────────────┘
The concrete command is the entire UX win — it answers "why is this prompt
on screen?" in the prompt itself, instead of the abstract "this project can
run code on your machine" that VS Code is criticized for. Picking
Trust & retry elevates and the tool starts; nothing the user has to
re-click.
gate stays sync Allow/Deny. No new denial variant, no
Undecided third state. A spawn that hits Restricted denies normally.SpawnError::Process(...)
from gate, sees the workspace is Restricted, and calls showActionPopup
with the command name baked into the message. For plugins this is
editor.showActionPopup({...}) with two actions wired to
executeActions("workspace_trust_trust" | "workspace_trust_restrict").set_level broadcasts. WorkspaceTrust gains a
tokio::sync::broadcast::Sender<TrustLevel>. set_level publishes the
new level on every transition (including Restricted → Trusted, which is
the case rule 4 cares about).Trusted. Each subsystem that holds
denied-spawn state subscribes:
trust_changed hook so plugin-side spawnProcess callers can retry.plugins_loaded and on user
command; the trust_changed subscription re-runs maybeAutoActivate
so a shell-env folder activates as soon as the user trusts (today the
user has to re-open or run Env: Activate manually).popup_dialogs.rs:1014-1025). Trust elevation drains the lot.gateThe block-and-wait shape (gate parks the spawner on a oneshot, popup
unblocks it) is technically possible in fresh — every spawner is async
and runs on the Tokio runtime (editor_init.rs:597), and no spawn site is
on the UI event loop (main.rs:4008), so blocking inside a spawner
wouldn't freeze the UI. But:
ActivityResultLauncher is callback / suspend.
Modeling our trust prompt on a pattern browsers spent five years
retreating from imports their UX baggage.zed.dev/docs/worktree-trust). JetBrains Safe Mode
disables Gradle/Maven/sbt import and replays the deferred startup
activities when the user trusts. Both ship as the de facto convention for
IDE workspace trust today.requestWorkspaceTrust({ modal: true }) blocks
via await; onDidGrantWorkspaceTrust is the deferred event), but the
ecosystem treats the modal API as a niche escape hatch for explicit user
actions; the recommended capabilities.untrustedWorkspaces extension
manifest is exactly queue-and-drain.WorkspaceTrust, a Notify or oneshot from
the UI side back to the parked spawner, popup coalescing logic ("first
spawn wins the popup, subsequent ones wait silently"), cancellation
paths so killing the originating command also drops the waiter. Each is
a real failure mode (leaked queues on shutdown, popup deduplication
bugs, parked waiters surviving workspace switch). Queue-and-drain needs
one broadcast channel and a per-caller subscription — substantially
less surface area.gate deadlocks. The sync-Allow/Deny
contract is harder to misuse.gate to the caller. Mitigated by routing
through LspManager / plugin runtime, which already own retry logic for
unrelated reasons (server crashes, plugin reloads).Existing trust decisions persist at <data_dir>/workspaces/<encoded-path>/trust.json
(see workspace_trust.rs:322-376). Extend the schema:
{
"level": "trusted",
"markers": {
".envrc": { "sha256": "abc…", "decided_at": "..." },
"devcontainer.json":{ "sha256": "def…", "decided_at": "..." }
}
}
On re-open, if the file is still present and hash matches, skip the popup and re-activate silently. If the hash differs, re-prompt with "this file changed since you trusted it" in the message — same buttons.
Off by default. A user setting workspace.trust.inheritFromParent: bool or
similar. When true, on open, walk the parent chain looking for a recorded
trust decision — if any ancestor is trusted, inherit. Power users who keep
all their code under ~/code flip this on and never re-prompt for fresh
clones. The setting must be off by default because the entire point of
trust is to gate cloning hostile content into trusted-ancestor directories
(the documented VS Code attack pattern).
workspace-trust-sandbox-design.md..envrc for live reload during a session. Reload remains
a manual Env: Reload command, as today.These are non-goals for this plan, not for the North Star. The ideal UX described at the top eventually subsumes some of these (file-watching for hash changes folds naturally into the content-fingerprint capability in §"Capabilities the editor needs to deliver this"). But the stepping stones don't need to ship them.
After auditing the existing codebase, the gap between today and the North Star is smaller than it looked, because most of the supporting infrastructure is already there. The rules below land roughly 60–70% of the user-visible North Star UX on the existing popup primitive. The remaining 30–40% is a set of extensions to existing systems, not new primitives:
show_workspace_trust_popup is fully implemented; the on-open
call is stubbed at popup_dialogs.rs:977. Replace the stub with
a real call whose title/body name the actual markers
(executable_content_markers already returns them) — "rust-
analyzer would run cargo here" rather than "this project can
run code." This is a code change, not new infrastructure.plugin_token_areas: HashMap<String, (u16,u16,u16)>
to StatusBarLayout, have the renderer record each rendered
plugin token's area, extend handle_click_status_bar to walk
that map, and add an optional on_click field to the existing
RegisterStatusBarElement command. With this, the trust chip,
env pill, and authority indicator all become first-class
affordances back to their decisions.TrustStore JSON schema (or a sibling decisions.json) to
carry per-marker SHA-256 alongside the trust level. Editing
.envrc re-asks; unchanged file means no re-ask.trust.json / decisions.json stable and merge-friendly.A reasonable sequencing: (1) re-enable trust modal with concrete framing, (2) clickable status-bar tokens, (5) action log — these three together cover the majority of the user-visible North Star. (3) and (4) are background-only changes that make the experience feel magical without changing what's on screen. (6) and (7) are stretch goals.
Things explicitly not on this list, after the audit:
action_popup_result broadcast plus per-plugin dedup
(mirror the trust-modal short-circuit at popup_dialogs.rs:1019)
handle ordering and coordination at the current scale. Worth it
at 10+ coordinating plugins; not at 3.<plugin>:<question>) and the persistence
story is already coherent.Each subsequent PR that delivers one of the seven capabilities above should reference this section and identify which capability it advances.
E2E coverage to add under crates/fresh-editor/tests/e2e/:
.venv-only folder → no popup, env pill shows .venv, terminal has the
activated PATH..envrc-only folder, first open → combined popup; pick Trust & activate
→ env activates, hash recorded..envrc-only folder, second open, file unchanged → no popup, silent
activation..envrc-only folder, second open, file edited → re-prompt with "changed"
message.devcontainer.json + .envrc → devcontainer popup only; dismiss
"Reopen" → env popup appears.devcontainer.json + .envrc → devcontainer popup; accept "Reopen" →
no env popup on host; after restart inside container, env popup appears..csproj-only folder → no popup on open; open a .cs file → C# plugin
tries dotnet restore, gate denies, plugin surfaces a popup naming
the command; pick Trust & retry → dotnet restore runs and the LSP
starts (driven by the LSP manager's broadcast subscriber, not a manual
re-invoke from the plugin).workspace.trust.inheritFromParent = true — fresh clone under a
trusted parent opens silently.mise.toml: include .tool-versions siblings, or
per-file? Decision: per-file. Editing .tool-versions should re-prompt
independently of mise.toml.TrustStore::is_decided() && level == Restricted, the spawn
caller does not surface the "Trust & retry" popup. The user made a
deliberate choice; re-asking on every denied spawn is the nag-screen
failure mode. They can still flip via the status chip / palette
command. The popup is reserved for the Undecided-default case.devcontainer.json is itself
repo-controlled content; today we treat it as such (it's in the trust
markers list). The combined popup for env does not extend to
devcontainer because the reopen flow has its own explicit prompt. Keep
separate.