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}