Skip to main content

lattice_plugin_host/
effect_authorizer.rs

1//! XF.4 — authorising a guest-returned effect's file paths.
2//!
3//! Design: [`cross-file-writes.md`](../../../docs/dev/architecture/cross-file-writes.md) §6.
4//!
5//! ## Why the check lives at the boundary
6//!
7//! `Effect::WriteToFile` writes into a file the editor may not have open. That
8//! needs `fs:write` authority over the path, and the check has exactly one
9//! place it can run.
10//!
11//! Not at the applier: by the time an effect reaches `Editor::handle_effect`
12//! the host has no idea which plugin produced it, because effects from a
13//! plugin and from a native mode are the same type — deliberately, since that
14//! is what lets a mode drive an edit without the host growing a per-feature
15//! `Action`. Threading a plugin id through the effect would mean putting
16//! provenance inside guest-returned data, which is exactly what
17//! `provenance_ids_are_host_issued_unique_and_stamp_the_plugin_layer` forbids.
18//!
19//! At the boundary the provenance is still known: the trampoline holds the
20//! plugin's `Store<PluginState>`, and `PluginState::grant` is its effective
21//! [`CapabilityGrant`]. So the conversion authorises, and an effect that
22//! reaches the editor has already been checked.
23//!
24//! ## What a denial does
25//!
26//! Replaces the effect with an `Echo`, so the user is told rather than left
27//! wondering why a key did nothing — the same reasoning as
28//! `ProviderViewOutcome::Declined`.
29//!
30//! **And drops what came after it in the batch** (CD.3c). This used to keep
31//! the rest of a `Many`, on the reasoning that "one denied write must not
32//! silently cancel the other things an action did". That holds for independent
33//! effects and is wrong for a commit, where what follows a write presumes it
34//! happened: org's capture returned `[write, close the buffer]`, so a denied
35//! target closed the capture and the user's text went with it. The host's
36//! applier stops a batch after a write that fails for any other reason
37//! (`apply_effect_host`); a denial is the same failure, found earlier, and is
38//! handled the same way. Effects *before* the write are kept.
39
40use std::path::{Path, PathBuf};
41
42use lattice_grammar::{EchoLevel, Effect as NativeEffect};
43
44use crate::capability::CapabilityGrant;
45
46/// Authorises the file paths in a plugin's returned effects against its grant.
47///
48/// Built once per plugin at load — a grant never changes for a plugin's life,
49/// so nothing here costs anything per keystroke beyond the prefix compare a
50/// `WriteToFile` actually needs.
51#[derive(Debug, Clone)]
52pub struct EffectAuthorizer {
53    /// Only the WRITE prefixes. A plugin granted `fs:read` over a tree may not
54    /// write into it — `walk_within_grant` accepts read *or* write because a
55    /// walk only reads, and this is the other half of that distinction.
56    write_prefixes: Vec<PathBuf>,
57    plugin: String,
58}
59
60impl EffectAuthorizer {
61    pub fn new(grant: &CapabilityGrant, plugin: impl Into<String>) -> Self {
62        Self {
63            write_prefixes: grant
64                .fs
65                .iter()
66                .filter(|g| g.write)
67                .map(|g| g.prefix.clone())
68                .collect(),
69            plugin: plugin.into(),
70        }
71    }
72
73    /// True when `path` lies within one of the plugin's `fs:write` prefixes.
74    ///
75    /// Both sides are canonicalized so a `..` segment cannot escape a granted
76    /// prefix — the same rule `host_services::grant_permits_walk` applies, and
77    /// for the same reason.
78    ///
79    /// **A target that does not exist yet canonicalizes its nearest real
80    /// ANCESTOR.** Capture's first run creates its file, and a non-existent
81    /// path canonicalizes to nothing; without this, "create the capture file"
82    /// would be a permanent denial and the feature would be unreachable by
83    /// design. Walking up rather than stopping at the immediate parent is what
84    /// makes the same true when the directory is new too — see
85    /// [`resolve_for_compare`].
86    ///
87    /// A path that still will not resolve falls back to its raw form, which
88    /// requires a literal prefix match — so it can only ever deny more, never
89    /// widen. Failing safe.
90    pub fn permits_write(&self, path: &Path) -> bool {
91        if self.write_prefixes.is_empty() {
92            return false;
93        }
94        let real = resolve_for_compare(path);
95        self.write_prefixes.iter().any(|prefix| {
96            let canon_prefix = std::fs::canonicalize(prefix).unwrap_or_else(|_| prefix.clone());
97            real.starts_with(&canon_prefix)
98        })
99    }
100
101    /// Authorise an effect, replacing any unpermitted `WriteToFile` with an
102    /// `Echo` naming the refusal.
103    ///
104    /// Recurses into `Many` so a write buried in a compound effect is checked
105    /// too — an unchecked path there would be the whole gate, bypassed by
106    /// wrapping.
107    pub fn authorize(&self, effect: NativeEffect) -> NativeEffect {
108        self.authorize_batch(effect).0
109    }
110
111    /// [`Self::authorize`], also reporting whether a write was denied — the
112    /// signal that truncates every enclosing `Many`, not only the innermost.
113    fn authorize_batch(&self, effect: NativeEffect) -> (NativeEffect, bool) {
114        match effect {
115            NativeEffect::Many(parts) => {
116                let mut kept = Vec::with_capacity(parts.len());
117                for part in parts {
118                    let (part, denied) = self.authorize_batch(part);
119                    kept.push(part);
120                    if denied {
121                        return (NativeEffect::Many(kept), true);
122                    }
123                }
124                (NativeEffect::Many(kept), false)
125            }
126            NativeEffect::WriteToFile { ref path, .. } if !self.permits_write(path) => {
127                // `info!`: one-shot and user-actionable ("a plugin was denied
128                // fs access"), which is the level rule's own example — not the
129                // per-keystroke `debug!` class.
130                tracing::info!(
131                    plugin = %self.plugin,
132                    path = %path.display(),
133                    "write-to-file denied: outside the plugin's fs:write grant"
134                );
135                (
136                    NativeEffect::Echo {
137                        level: EchoLevel::Warn,
138                        text: format!(
139                            "{}: write denied — {} is outside the plugin's granted paths",
140                            self.plugin,
141                            path.display()
142                        ),
143                    },
144                    true,
145                )
146            }
147            other => (other, false),
148        }
149    }
150}
151
152/// The path to compare against a granted prefix.
153///
154/// Canonicalize the target; if it does not exist, canonicalize the nearest
155/// ancestor that DOES and re-attach the unresolved tail. Falls back to the raw
156/// path when no ancestor resolves.
157///
158/// **Walking up all the way, not just one level.** Canonicalizing only the
159/// immediate parent covers "the file is new in a directory that exists" and
160/// silently fails "the file is new in a directory that is new too" — which is
161/// every first write into a subdirectory a plugin owns: org-roam's
162/// `daily/YYYY-MM-DD.org` on the day the journal starts, and capture's target
163/// under a fresh folder. There the parent does not resolve either, the raw path
164/// is used, and it is compared against a CANONICALIZED prefix — so on any
165/// system where the grant sits behind a symlink the match fails and the write
166/// is denied. macOS makes that the common case rather than the exotic one:
167/// `/tmp` and `/var/folders` are both symlinks into `/private`, so a grant over
168/// a temporary directory never matched a path this function had given up on.
169///
170/// The denial was indistinguishable from a capability the user had not granted
171/// — the message names the path and says it is outside the granted paths, which
172/// is exactly what it says when the grant really is missing.
173///
174/// Still fails safe: re-attaching an unresolved tail can only ever produce a
175/// path at or below a real directory, and `..` inside the tail is normalised
176/// away rather than followed, so a tail cannot climb back out of the prefix
177/// that was just resolved.
178pub(crate) fn resolve_for_compare(path: &Path) -> PathBuf {
179    if let Ok(real) = std::fs::canonicalize(path) {
180        return real;
181    }
182    // `.` contributes nothing and `..` pops — normalising rather than joining
183    // blindly is what keeps an unresolved tail from escaping the ancestor it
184    // was resolved against.
185    let apply = |mut base: PathBuf, tail: &Path| {
186        for segment in tail.components() {
187            match segment {
188                std::path::Component::ParentDir => {
189                    base.pop();
190                }
191                std::path::Component::CurDir => {}
192                other => base.push(other),
193            }
194        }
195        base
196    };
197    // Walk by `ancestors`, not by `parent` + `file_name`: `file_name` is
198    // `None` for a path ending in `..`, so that walk STOPPED at the first
199    // `..` it met and handed back the raw path — and the raw
200    // `<grant>/new/../../x` still starts with `<grant>` component for
201    // component, so the write was permitted outside the grant.
202    for ancestor in path.ancestors().skip(1) {
203        if let Ok(real) = std::fs::canonicalize(ancestor)
204            && let Ok(tail) = path.strip_prefix(ancestor)
205        {
206            return apply(real, tail);
207        }
208    }
209    // Nothing resolved at all (a relative path whose every ancestor is
210    // missing). Still never the raw path: its `..` segments are applied, so
211    // what is compared is where the write would land.
212    apply(PathBuf::new(), path)
213}
214
215#[cfg(test)]
216mod tests {
217    #![allow(clippy::unwrap_used, clippy::panic)]
218
219    use super::*;
220    use crate::capability::FsGrant;
221
222    fn grant(prefix: &Path, write: bool) -> CapabilityGrant {
223        CapabilityGrant {
224            fs: vec![FsGrant {
225                prefix: prefix.to_path_buf(),
226                write,
227            }],
228            ..Default::default()
229        }
230    }
231
232    fn tmp(tag: &str) -> PathBuf {
233        static N: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
234        let n = N.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
235        let nanos = std::time::SystemTime::now()
236            .duration_since(std::time::UNIX_EPOCH)
237            .map(|d| d.as_nanos())
238            .unwrap_or(0);
239        let dir = std::env::temp_dir().join(format!("lattice-xf4-{tag}-{nanos}-{n}"));
240        std::fs::create_dir_all(&dir).unwrap();
241        dir
242    }
243
244    fn write_effect(path: &Path) -> NativeEffect {
245        NativeEffect::WriteToFile {
246            path: path.to_path_buf(),
247            anchor: lattice_grammar::FileAnchor::End,
248            text: "x\n".to_string(),
249            cut: None,
250            create_parents: false,
251            save: false,
252        }
253    }
254
255    #[test]
256    fn a_path_inside_a_write_grant_is_permitted() {
257        let dir = tmp("inside");
258        let file = dir.join("archive.org");
259        std::fs::write(&file, "").unwrap();
260
261        let auth = EffectAuthorizer::new(&grant(&dir, true), "org");
262        assert!(auth.permits_write(&file));
263    }
264
265    #[test]
266    fn a_path_outside_every_prefix_is_refused() {
267        let granted = tmp("outside-granted");
268        let other = tmp("outside-other");
269        let auth = EffectAuthorizer::new(&grant(&granted, true), "org");
270        assert!(!auth.permits_write(&other.join("archive.org")));
271    }
272
273    /// A READ grant is not a write grant. `walk_within_grant` accepts either
274    /// because a walk only reads; this is the other half of that distinction,
275    /// and conflating them would let any plugin that can list a tree write
276    /// into it.
277    #[test]
278    fn a_read_only_grant_does_not_permit_writing() {
279        let dir = tmp("readonly");
280        let file = dir.join("archive.org");
281        std::fs::write(&file, "").unwrap();
282
283        let auth = EffectAuthorizer::new(&grant(&dir, false), "org");
284        assert!(!auth.permits_write(&file));
285    }
286
287    #[test]
288    fn a_plugin_with_no_fs_grant_writes_nothing() {
289        let dir = tmp("nogrant");
290        let file = dir.join("archive.org");
291        std::fs::write(&file, "").unwrap();
292
293        let auth = EffectAuthorizer::new(&CapabilityGrant::default(), "org");
294        assert!(!auth.permits_write(&file));
295    }
296
297    /// The escape attempt. Without canonicalizing both sides, a path that
298    /// *textually* starts with the prefix walks straight out of it.
299    #[test]
300    fn dotdot_cannot_escape_a_granted_prefix() {
301        let base = tmp("escape");
302        let granted = base.join("granted");
303        let secret = base.join("secret");
304        std::fs::create_dir_all(&granted).unwrap();
305        std::fs::create_dir_all(&secret).unwrap();
306        let target = secret.join("passwords");
307        std::fs::write(&target, "").unwrap();
308
309        let auth = EffectAuthorizer::new(&grant(&granted, true), "org");
310
311        let escaping = granted.join("..").join("secret").join("passwords");
312        assert!(
313            !auth.permits_write(&escaping),
314            "`<granted>/../secret/passwords` resolves outside the grant and \
315             must be refused — a textual prefix match would have allowed it"
316        );
317    }
318
319    /// Capture's first run. The file does not exist, so it cannot be
320    /// canonicalized; the PARENT is what decides. Without this the whole
321    /// create-a-capture-file case would be permanently denied.
322    #[test]
323    fn a_not_yet_existing_file_is_judged_by_its_parent() {
324        let dir = tmp("create");
325        let missing = dir.join("capture.org");
326        assert!(!missing.exists());
327
328        let auth = EffectAuthorizer::new(&grant(&dir, true), "org");
329        assert!(
330            auth.permits_write(&missing),
331            "a file this write would create must be permitted, or capture is \
332             unreachable by construction"
333        );
334    }
335
336    /// OR.10's first journal entry: the file is new AND so is the directory
337    /// holding it, so neither the target nor its parent canonicalizes.
338    ///
339    /// Judging by the immediate parent alone fell back to the RAW path here and
340    /// compared it against a canonicalized prefix — which on macOS never
341    /// matches, because `std::env::temp_dir()` is a symlink into `/private`. So
342    /// the very first `:org-roam-dailies-today` was denied with a message that
343    /// reads exactly like a missing capability, and the second one (after the
344    /// directory existed) worked.
345    #[test]
346    fn a_new_file_under_a_new_directory_is_judged_by_the_nearest_real_ancestor() {
347        let dir = tmp("create-deep");
348        let missing = dir.join("daily").join("2026-08-30.org");
349        assert!(!missing.parent().unwrap().exists());
350
351        let auth = EffectAuthorizer::new(&grant(&dir, true), "org");
352        assert!(
353            auth.permits_write(&missing),
354            "a plugin writing the first file into a subdirectory it owns must \
355             be permitted, or the feature is unreachable on its first use"
356        );
357    }
358
359    /// …and walking up does not become a hole either. The tail is normalised,
360    /// so `..` cannot climb back out of the ancestor it resolved against.
361    #[test]
362    fn an_unresolved_tail_cannot_escape_the_prefix_with_dotdot() {
363        let granted = tmp("escape-granted");
364        let auth = EffectAuthorizer::new(&grant(&granted, true), "org");
365        assert!(
366            !auth.permits_write(&granted.join("new").join("..").join("..").join("stolen.org")),
367            "a `..` in the unresolved tail must not widen the grant"
368        );
369    }
370
371    /// …and the parent check does not become a hole: a non-existent file in a
372    /// non-granted directory is still refused.
373    #[test]
374    fn a_not_yet_existing_file_outside_the_grant_is_still_refused() {
375        let granted = tmp("create-granted");
376        let other = tmp("create-other");
377        let auth = EffectAuthorizer::new(&grant(&granted, true), "org");
378        assert!(!auth.permits_write(&other.join("capture.org")));
379    }
380
381    #[test]
382    fn a_permitted_effect_passes_through_unchanged() {
383        let dir = tmp("passthrough");
384        let file = dir.join("a.org");
385        std::fs::write(&file, "").unwrap();
386        let auth = EffectAuthorizer::new(&grant(&dir, true), "org");
387
388        assert!(matches!(
389            auth.authorize(write_effect(&file)),
390            NativeEffect::WriteToFile { .. }
391        ));
392    }
393
394    #[test]
395    fn a_denied_effect_becomes_an_echo_naming_the_plugin() {
396        let granted = tmp("denied-granted");
397        let other = tmp("denied-other");
398        let auth = EffectAuthorizer::new(&grant(&granted, true), "org");
399
400        match auth.authorize(write_effect(&other.join("a.org"))) {
401            NativeEffect::Echo { level, text } => {
402                assert_eq!(level, EchoLevel::Warn);
403                assert!(text.contains("org"), "names the plugin: {text}");
404                assert!(text.contains("denied"), "{text}");
405            }
406            other => panic!("expected an Echo, got {other:?}"),
407        }
408    }
409
410    /// A write buried inside a `Many` is checked too. Missing this would be
411    /// the entire gate, bypassed by wrapping the effect in a list.
412    #[test]
413    fn a_write_nested_in_many_is_authorized_too() {
414        let granted = tmp("nested-granted");
415        let other = tmp("nested-other");
416        let auth = EffectAuthorizer::new(&grant(&granted, true), "org");
417
418        let effect = NativeEffect::Many(vec![
419            NativeEffect::None,
420            NativeEffect::Many(vec![write_effect(&other.join("a.org"))]),
421        ]);
422
423        match auth.authorize(effect) {
424            NativeEffect::Many(parts) => match &parts[1] {
425                NativeEffect::Many(inner) => assert!(
426                    matches!(inner[0], NativeEffect::Echo { .. }),
427                    "a nested write must be refused, not passed through"
428                ),
429                other => panic!("expected a nested Many, got {other:?}"),
430            },
431            other => panic!("expected a Many, got {other:?}"),
432        }
433    }
434
435    /// CD.3c: a denied write drops what follows it — the effects after a write
436    /// presume it happened (capture closes its buffer after filing) — and
437    /// keeps what came before it. This reverses the earlier "the rest of a
438    /// `Many` survives" rule; see the module doc.
439    #[test]
440    fn a_denied_write_drops_the_rest_of_its_batch_and_keeps_the_start() {
441        let granted = tmp("survive-granted");
442        let other = tmp("survive-other");
443        let auth = EffectAuthorizer::new(&grant(&granted, true), "org");
444
445        let effect = NativeEffect::Many(vec![
446            NativeEffect::CursorMove(lattice_protocol::position::Position::new(1, 0)),
447            write_effect(&other.join("a.org")),
448            NativeEffect::BufferDelete { force: true },
449        ]);
450
451        match auth.authorize(effect) {
452            NativeEffect::Many(parts) => {
453                assert_eq!(
454                    parts.len(),
455                    2,
456                    "the close after the write is dropped: {parts:?}"
457                );
458                assert!(
459                    matches!(parts[0], NativeEffect::CursorMove(_)),
460                    "before: kept"
461                );
462                assert!(
463                    matches!(parts[1], NativeEffect::Echo { .. }),
464                    "the denial is said"
465                );
466            }
467            other => panic!("expected a Many, got {other:?}"),
468        }
469    }
470
471    /// The truncation reaches the enclosing batch too — a write nested one
472    /// level down stops its parent's later effects, not only its siblings.
473    #[test]
474    fn a_nested_denial_truncates_the_enclosing_batch() {
475        let granted = tmp("outer-granted");
476        let other = tmp("outer-other");
477        let auth = EffectAuthorizer::new(&grant(&granted, true), "org");
478
479        let effect = NativeEffect::Many(vec![
480            NativeEffect::Many(vec![write_effect(&other.join("a.org"))]),
481            NativeEffect::BufferDelete { force: true },
482        ]);
483
484        match auth.authorize(effect) {
485            NativeEffect::Many(parts) => assert_eq!(parts.len(), 1, "{parts:?}"),
486            other => panic!("expected a Many, got {other:?}"),
487        }
488    }
489
490    /// A permitted write changes nothing about the batch.
491    #[test]
492    fn a_permitted_write_keeps_the_whole_batch() {
493        let granted = tmp("whole-granted");
494        let auth = EffectAuthorizer::new(&grant(&granted, true), "org");
495        let effect = NativeEffect::Many(vec![
496            write_effect(&granted.join("a.org")),
497            NativeEffect::BufferDelete { force: true },
498        ]);
499        match auth.authorize(effect) {
500            NativeEffect::Many(parts) => assert_eq!(parts.len(), 2),
501            other => panic!("expected a Many, got {other:?}"),
502        }
503    }
504
505    /// Effects with no path are untouched — the authorizer is about file
506    /// writes and must not become a general filter.
507    #[test]
508    fn effects_without_a_path_are_left_alone() {
509        let auth = EffectAuthorizer::new(&CapabilityGrant::default(), "org");
510        assert!(matches!(
511            auth.authorize(NativeEffect::None),
512            NativeEffect::None
513        ));
514        assert!(matches!(
515            auth.authorize(NativeEffect::Declined),
516            NativeEffect::Declined
517        ));
518    }
519}