Skip to main content

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}