lattice_plugin_host/capability.rs
1//! Trust tiers, grant computation, and the per-plugin WASI view.
2//!
3//! Design fragment: `docs/dev/architecture/plugin-host.md` §6. Slice: PH7.2.
4//!
5//! The pipeline is: **manifest (request) + trust tier → grant (effective) →
6//! WASI view (enforcement)**. The manifest ([`crate::manifest`]) is what a
7//! plugin *asks for*; the [`CapabilityGrant`] is what it *gets* after the trust
8//! tier filters the request; the [`wasmtime_wasi::WasiCtx`] is how the grant is
9//! *enforced* — a plugin's `Store` is built with exactly its granted
10//! filesystem preopens, so a path outside the grant is unreachable at the WASI
11//! layer (WASI has no ambient authority: only preopened dirs exist). That is
12//! the "denied at the WASI layer, not by discipline" property the PH7.2 exit
13//! names.
14//!
15//! **Scope of WASI enforcement at PH7.2 = filesystem only.** `net:http` and
16//! `proc:spawn` are carried on the grant as metadata but are *not* wired into
17//! the WASI view here: raw WASI sockets/subprocess would be a *broader* grant
18//! than intended. Network and process access are serviced by capability-gated
19//! `host-services` calls (PH7.3+), which check the grant's allowlist — so the
20//! grant is the single source of truth both layers read. Enabling raw TCP for
21//! a `net:http` grant would leak authority the host-services check exists to
22//! contain, so `build_wasi_ctx` deliberately leaves sockets disabled.
23
24use std::path::{Path, PathBuf};
25
26use lattice_mode::CapabilitySet;
27use wasmtime_wasi::{DirPerms, FilePerms, WasiCtx, WasiCtxBuilder};
28
29use crate::manifest::{Capability, PluginManifest};
30
31/// The guest path the per-plugin data dir is mounted at. A plugin always has a
32/// private, writable scratch dir here regardless of any `fs:*` grant.
33pub const DATA_DIR_GUEST_MOUNT: &str = "/data";
34
35/// How much the editor trusts a plugin — decides which requested capabilities
36/// become grants. A plugin cannot self-declare this (it is not a manifest
37/// field); the host determines it from install provenance.
38#[derive(Debug, Clone, Copy, PartialEq, Eq)]
39pub enum TrustTier {
40 /// Shipped with the editor. Capabilities are pre-granted at build time,
41 /// no consent prompt. `proc:spawn` is bundled-only in v1.
42 Bundled,
43 /// Installed by the user from an external source. Prompts for consent on
44 /// first install (the prompt itself is a host-UI concern, out of this
45 /// crate). `proc:spawn` is never granted in v1.
46 UserInstalled,
47}
48
49/// A single granted filesystem prefix and its write bit.
50#[derive(Debug, Clone, PartialEq, Eq)]
51pub struct FsGrant {
52 /// The host path prefix the plugin may reach.
53 pub prefix: PathBuf,
54 /// Whether the plugin may mutate under the prefix (`fs:write` vs `fs:read`).
55 pub write: bool,
56}
57
58/// The **effective** capabilities a plugin is granted — the request filtered by
59/// its trust tier. This, not the manifest, is what the runtime enforces.
60#[derive(Debug, Clone, PartialEq, Eq, Default)]
61pub struct CapabilityGrant {
62 /// Granted filesystem prefixes (enforced via WASI preopens).
63 pub fs: Vec<FsGrant>,
64 /// Granted outbound-HTTP host allowlist (enforced at the host-services
65 /// http seam, PH7.3+, not the WASI layer — see the module note).
66 pub net_http: Vec<String>,
67 /// Whether the plugin may spawn subprocesses (enforced at the
68 /// host-services proc seam, PH7.3+).
69 pub proc_spawn: bool,
70 /// OR.1: whether the plugin may persist bytes in its own key/value store
71 /// (enforced at the `host-services` `store-*` seam — the store lives in the
72 /// plugin's private data dir, which WASI already mounts, so this gate is
73 /// host-side like `net:http` rather than a preopen).
74 pub state_write: bool,
75 /// CM.2: whether the plugin may bind an operator chord into the universal
76 /// operator-pending grammar. Enforced at the loader's grammar drain — a
77 /// declared chord is skipped (and logged) without this, while the operator
78 /// itself still registers and stays reachable by name.
79 pub grammar_chord: bool,
80 /// The editor capabilities a plugin-declared mode requires (enforced at
81 /// mode activation, PH7.11).
82 pub editor: CapabilitySet,
83}
84
85/// The result of computing a grant: the effective [`CapabilityGrant`] plus the
86/// requested capabilities that were **denied** by the trust tier, so the host
87/// can surface a "loaded with reduced function" notification (fragment §6 UX;
88/// the four-artefact graceful-error clause).
89#[derive(Debug, Clone, PartialEq, Eq)]
90pub struct GrantOutcome {
91 /// What the plugin actually gets.
92 pub grant: CapabilityGrant,
93 /// Requested-but-not-granted capabilities (e.g. `proc:spawn` for a
94 /// user-installed plugin). Never fatal — the plugin loads degraded.
95 pub denied: Vec<Capability>,
96}
97
98/// Compute the effective grant for `manifest` under `tier`.
99///
100/// The only tier-dependent filter in v1 is `proc:spawn` (bundled-only); every
101/// other requested capability is granted as declared. User-consent narrowing
102/// (a user-installed plugin's grant reduced to what the user approved) plugs in
103/// here in a later slice — the [`GrantOutcome::denied`] channel already carries
104/// the "requested but withheld" set that a consent step would populate.
105pub fn grant(manifest: &PluginManifest, tier: TrustTier) -> GrantOutcome {
106 let mut g = CapabilityGrant {
107 editor: manifest.editor_capabilities,
108 ..CapabilityGrant::default()
109 };
110 let mut denied = Vec::new();
111
112 for cap in &manifest.requested {
113 match cap {
114 Capability::FsRead(p) => g.fs.push(FsGrant {
115 prefix: p.clone(),
116 write: false,
117 }),
118 Capability::FsWrite(p) => g.fs.push(FsGrant {
119 prefix: p.clone(),
120 write: true,
121 }),
122 Capability::NetHttp(h) => g.net_http.push(h.clone()),
123 // OR.1: granted at either tier. The store reaches only the
124 // plugin's own data dir, which every plugin already has mounted
125 // writable, so withholding it from a user-installed plugin would
126 // deny nothing it could not already do through WASI — it would
127 // only deny the crash-safe, versioned, cross-instance shape.
128 Capability::StateWrite => g.state_write = true,
129 // CM.2: both tiers. See the variant's doc — the chord is scoped to
130 // the plugin's own minor mode, shown in `:plugins`, and reversed on
131 // unload, so withholding it would deny the feature without
132 // narrowing the risk that matters.
133 Capability::GrammarChord => g.grammar_chord = true,
134 Capability::ProcSpawn => match tier {
135 TrustTier::Bundled => g.proc_spawn = true,
136 // proc:spawn is bundled-only in v1 — a user plugin's request
137 // is withheld (fragment §6). Surfaced, never fatal.
138 TrustTier::UserInstalled => denied.push(Capability::ProcSpawn),
139 },
140 }
141 }
142
143 GrantOutcome { grant: g, denied }
144}
145
146/// A resolved directory preopen: which host dir maps to which guest path, and
147/// whether it is writable. The mapping between a [`CapabilityGrant`] and the
148/// WASI view, exposed as data so it is unit-testable without a live guest
149/// (PH7.2 proves enforcement at this layer; the guest-level end-to-end proof
150/// lands at PH7.4 with the real `wasm32-wasip2` `fuzzy-finder`).
151#[derive(Debug, Clone, PartialEq, Eq)]
152pub struct PreopenSpec {
153 /// The host directory made visible to the guest.
154 pub host_path: PathBuf,
155 /// The path the guest sees it at.
156 pub guest_path: String,
157 /// Whether the guest may mutate it.
158 pub writable: bool,
159}
160
161impl CapabilityGrant {
162 /// The full set of directory preopens for a plugin whose private data dir
163 /// is `data_dir`. The data dir is always mounted writable at
164 /// [`DATA_DIR_GUEST_MOUNT`]; each `fs` grant adds a preopen at a guest path
165 /// equal to its host path (so the guest opens the same absolute path it was
166 /// granted). A plugin with no `fs` grant reaches *only* its data dir.
167 ///
168 /// The guest-path convention (`fs` prefix mounted at its own host path) is
169 /// provisional until `fuzzy-finder` exercises it (PH7.4, open question in
170 /// fragment §13).
171 pub fn preopens(&self, data_dir: &Path) -> Vec<PreopenSpec> {
172 let mut specs = vec![PreopenSpec {
173 host_path: data_dir.to_path_buf(),
174 guest_path: DATA_DIR_GUEST_MOUNT.to_string(),
175 writable: true,
176 }];
177 for fs in &self.fs {
178 specs.push(PreopenSpec {
179 host_path: fs.prefix.clone(),
180 guest_path: fs.prefix.to_string_lossy().into_owned(),
181 writable: fs.write,
182 });
183 }
184 specs
185 }
186}
187
188/// Build the [`WasiCtx`] enforcing `grant` for a plugin whose data dir is
189/// `data_dir`. Only filesystem preopens are wired (see the module note);
190/// sockets and subprocess spawning stay disabled at the WASI layer.
191///
192/// **Graceful degradation:** a granted prefix that cannot be opened (missing
193/// dir, permission error) is skipped with a `warn!`, never a panic or a failed
194/// load — the plugin runs with that one capability degraded (fragment §6). The
195/// caller must have created `data_dir` before this runs (the host does).
196pub fn build_wasi_ctx(grant: &CapabilityGrant, data_dir: &Path) -> WasiCtx {
197 let mut builder = WasiCtxBuilder::new();
198 // Deliberately import-free otherwise: no stdio, no ambient clocks/random
199 // beyond WASI defaults, no sockets, no subprocess.
200 //
201 // **`HOME` is the one exception, and it is deliberate rather than a
202 // loosening that crept in.** A guest reads path options the user wrote —
203 // `org.roam-directory = "~/notes"` — and with no environment it cannot
204 // expand the tilde, so the path reaches the host verbatim, resolves to
205 // nothing, and the feature reports an empty corpus rather than a bad path.
206 // The user's config is then simply not portable: it must name
207 // `/Users/dhruva/...` or `/home/dhruva/...` and stops working on their
208 // other machine.
209 //
210 // What this does NOT concede: the guest still has no `PATH`, no `USER`, no
211 // shell, no ambient credentials, and no ability to reach anything it was
212 // not granted. `HOME` is a *string* here — knowing the path does not mount
213 // it, and every filesystem access still goes through the preopens below.
214 // A plugin that learns the home directory and asks to read it is refused
215 // exactly as before.
216 //
217 // Resolved through `dirs::home_dir` rather than `std::env::var_os("HOME")`
218 // so a Windows host supplies `%USERPROFILE%` — otherwise this would hand
219 // POSIX guests a working tilde and Windows guests nothing, which is the
220 // asymmetry the shared helper exists to end.
221 if let Some(home) = dirs::home_dir() {
222 builder.env("HOME", home.display().to_string());
223 }
224 for spec in grant.preopens(data_dir) {
225 let (dir_perms, file_perms) = if spec.writable {
226 (
227 DirPerms::READ | DirPerms::MUTATE,
228 FilePerms::READ | FilePerms::WRITE,
229 )
230 } else {
231 (DirPerms::READ, FilePerms::READ)
232 };
233 if let Err(err) =
234 builder.preopened_dir(&spec.host_path, &spec.guest_path, dir_perms, file_perms)
235 {
236 // A denied/inaccessible granted prefix degrades to "not mounted".
237 tracing::warn!(
238 host_path = %spec.host_path.display(),
239 guest_path = %spec.guest_path,
240 error = %err,
241 "plugin filesystem preopen skipped (capability degraded)"
242 );
243 }
244 }
245 builder.build()
246}
247
248#[cfg(test)]
249mod tests {
250 #![allow(clippy::unwrap_used, clippy::panic)]
251
252 use super::*;
253 use crate::manifest::Capability;
254
255 fn manifest(caps: Vec<Capability>) -> PluginManifest {
256 PluginManifest::new("test-plugin", caps, CapabilitySet::empty())
257 }
258
259 #[test]
260 fn fs_read_and_write_map_to_grants() {
261 let m = manifest(vec![
262 Capability::FsRead(PathBuf::from("/ro")),
263 Capability::FsWrite(PathBuf::from("/rw")),
264 ]);
265 let out = grant(&m, TrustTier::UserInstalled);
266 assert_eq!(
267 out.grant.fs,
268 vec![
269 FsGrant {
270 prefix: PathBuf::from("/ro"),
271 write: false
272 },
273 FsGrant {
274 prefix: PathBuf::from("/rw"),
275 write: true
276 },
277 ]
278 );
279 assert!(out.denied.is_empty());
280 }
281
282 #[test]
283 fn proc_spawn_is_bundled_only() {
284 let m = manifest(vec![Capability::ProcSpawn]);
285
286 let bundled = grant(&m, TrustTier::Bundled);
287 assert!(bundled.grant.proc_spawn);
288 assert!(bundled.denied.is_empty());
289
290 let user = grant(&m, TrustTier::UserInstalled);
291 assert!(!user.grant.proc_spawn);
292 assert_eq!(user.denied, vec![Capability::ProcSpawn]);
293 }
294
295 #[test]
296 fn net_http_allowlist_is_carried_on_the_grant() {
297 let m = manifest(vec![
298 Capability::NetHttp("crates.io".into()),
299 Capability::NetHttp("docs.rs".into()),
300 ]);
301 let out = grant(&m, TrustTier::UserInstalled);
302 assert_eq!(out.grant.net_http, vec!["crates.io", "docs.rs"]);
303 }
304
305 #[test]
306 fn editor_capabilities_flow_through_unchanged() {
307 let m = PluginManifest::new("x", vec![], CapabilitySet::TREE_SITTER | CapabilitySet::LSP);
308 let out = grant(&m, TrustTier::Bundled);
309 assert_eq!(
310 out.grant.editor,
311 CapabilitySet::TREE_SITTER | CapabilitySet::LSP
312 );
313 }
314
315 #[test]
316 fn empty_grant_preopens_only_the_data_dir() {
317 let g = CapabilityGrant::default();
318 let specs = g.preopens(Path::new("/data/plugins/x/data"));
319 assert_eq!(specs.len(), 1);
320 assert_eq!(specs[0].guest_path, DATA_DIR_GUEST_MOUNT);
321 assert_eq!(specs[0].host_path, PathBuf::from("/data/plugins/x/data"));
322 assert!(specs[0].writable);
323 }
324
325 #[test]
326 fn fs_grants_become_preopens_with_correct_perms() {
327 let out = grant(
328 &manifest(vec![
329 Capability::FsRead(PathBuf::from("/ro")),
330 Capability::FsWrite(PathBuf::from("/rw")),
331 ]),
332 TrustTier::Bundled,
333 );
334 let specs = out.grant.preopens(Path::new("/data"));
335 // data dir + two fs grants.
336 assert_eq!(specs.len(), 3);
337 let ro = specs
338 .iter()
339 .find(|s| s.host_path == Path::new("/ro"))
340 .unwrap();
341 assert!(!ro.writable);
342 assert_eq!(ro.guest_path, "/ro");
343 let rw = specs
344 .iter()
345 .find(|s| s.host_path == Path::new("/rw"))
346 .unwrap();
347 assert!(rw.writable);
348 }
349
350 #[test]
351 fn build_wasi_ctx_skips_missing_prefix_without_panic() {
352 // A granted prefix that does not exist must degrade gracefully: the
353 // context still builds (the data dir mounts), the bad prefix is
354 // skipped. `data_dir` must exist, so use the current dir.
355 let cwd = std::env::current_dir().unwrap();
356 let g = CapabilityGrant {
357 fs: vec![FsGrant {
358 prefix: PathBuf::from("/nonexistent-lattice-plugin-prefix-xyz"),
359 write: false,
360 }],
361 ..CapabilityGrant::default()
362 };
363 // Must not panic; returns a usable WasiCtx.
364 let _ctx = build_wasi_ctx(&g, &cwd);
365 }
366}