Skip to main content

memstead_base/ingest/
check_path.rs

1//! Deny-path verdicts for tool-call candidates — the engine half of the
2//! plugin's PreToolUse deny hook, and the one home of the deny dialect.
3//!
4//! A binding's `deny_paths` are **workspace-relative glob patterns** (the
5//! facet-scope grammar), resolved here with the *same* `globset` machinery the
6//! enumeration path uses — one dialect, one implementation. The plugin hook
7//! used to re-implement these semantics in JavaScript against an
8//! engine-written deny-list cache; both are retired. The hook now asks the
9//! engine (`memstead projection check-path`), and the only cross-process
10//! state left is a **pointer** to the active binding — the deny list itself
11//! is read fresh from the binding record on every check, so a stale *list*
12//! can no longer be enforced by construction.
13//!
14//! Two rules extend the raw glob match, ported from the hook they replace:
15//!
16//! - **Directory-prefix rule.** The literal base of an entry (the portion
17//!   before its first glob metacharacter, trailing `/` trimmed) blocks the
18//!   directory itself: `dev/**` also blocks a read targeted at `dev`, which
19//!   the glob alone would let through. A legacy bare name (`dev`) degrades to
20//!   the same prefix block instead of erroring.
21//! - **`..` candidates match.** A candidate outside the workspace resolves to
22//!   a `../…` relative path and is matched verbatim — the dogfood mediums
23//!   point at sibling directories, denied by `../…` entries.
24//!
25//! A malformed deny entry never disables enforcement: its glob half is
26//! skipped, its literal-base prefix rule still applies.
27
28use std::path::{Component, Path, PathBuf};
29
30use globset::Glob;
31
32use super::cursor::{normalize_lexical, relative_path};
33
34/// One candidate's verdict.
35#[derive(Debug, Clone, PartialEq, Eq)]
36pub struct PathVerdict {
37    /// The candidate as supplied.
38    pub path: String,
39    /// Whether the candidate is denied for the binding.
40    pub denied: bool,
41    /// The deny entry that matched (first match in declaration order), when
42    /// denied — the machine-readable "why" a blocking consumer reports.
43    pub matched: Option<String>,
44}
45
46/// Evaluate tool-call candidates against a binding's `deny_paths`.
47///
48/// Each candidate — a path or a Glob/Grep pattern — is resolved to its
49/// workspace-relative form (absolute candidates as-is, relative ones against
50/// `cwd`; `/`-separated; may contain `..`) and matched against every deny
51/// entry: the entry's glob (engine `globset`, the enumeration dialect) plus
52/// the literal-base directory-prefix rule. An empty deny list denies nothing
53/// (default-open). Non-path candidate strings (a Grep regex like `TODO`)
54/// resolve to harmless paths and match nothing.
55pub fn check_deny_paths(
56    deny_paths: &[String],
57    candidates: &[String],
58    cwd: &Path,
59    workspace_root: &Path,
60) -> Vec<PathVerdict> {
61    // Symlink-consistent resolution: the caller's workspace root may be
62    // canonical (the CLI's cwd is) while candidates arrive raw — on macOS a
63    // temp path is `/var/…` for one and `/private/var/…` for the other, and a
64    // lexical relative-path between them fabricates `../` chains that match
65    // nothing. Canonicalising the longest EXISTING prefix of each input puts
66    // all three in the same namespace without requiring candidates to exist
67    // (they may be Glob patterns).
68    let cwd = canonicalize_existing_prefix(cwd);
69    let workspace_root = canonicalize_existing_prefix(workspace_root);
70    let (cwd, workspace_root) = (cwd.as_path(), workspace_root.as_path());
71    // Compile each entry once per call: the glob matcher (skipped when the
72    // entry is malformed — the prefix rule below still applies) and the
73    // literal base for the directory-prefix rule.
74    let entries: Vec<(&String, Option<globset::GlobMatcher>, String)> = deny_paths
75        .iter()
76        .filter(|e| !e.is_empty())
77        .map(|e| {
78            let matcher = Glob::new(e).ok().map(|g| g.compile_matcher());
79            (e, matcher, literal_base(e))
80        })
81        .collect();
82
83    candidates
84        .iter()
85        .map(|candidate| {
86            let rel = workspace_relative(candidate, cwd, workspace_root);
87            let matched = entries
88                .iter()
89                .find(|(_, matcher, base)| {
90                    matcher.as_ref().is_some_and(|m| m.is_match(&rel))
91                        || (!base.is_empty()
92                            && (rel == *base || rel.starts_with(&format!("{base}/"))))
93                })
94                .map(|(entry, _, _)| (*entry).clone());
95            PathVerdict {
96                path: candidate.clone(),
97                denied: matched.is_some(),
98                matched,
99            }
100        })
101        .collect()
102}
103
104/// The candidate's workspace-relative path in `/`-separated form. Absolute
105/// candidates resolve as-is; relative ones against `cwd`. May contain `..`
106/// when the candidate sits outside the workspace — expected, and matched by
107/// `../…` deny entries.
108fn workspace_relative(candidate: &str, cwd: &Path, workspace_root: &Path) -> String {
109    let path = Path::new(candidate);
110    let abs: PathBuf = if path.is_absolute() {
111        canonicalize_existing_prefix(path)
112    } else {
113        normalize_lexical(&cwd.join(path))
114    };
115    let rel = relative_path(workspace_root, &abs);
116    rel.components()
117        .map(|c| match c {
118            Component::ParentDir => "..".to_string(),
119            other => other.as_os_str().to_string_lossy().to_string(),
120        })
121        .collect::<Vec<_>>()
122        .join("/")
123}
124
125/// Lexically normalize, then canonicalize the longest EXISTING prefix and
126/// re-append the non-existing tail — symlink resolution that tolerates paths
127/// (and Glob patterns) naming files that are not there. A path with no
128/// existing prefix comes back lexically normalized only.
129fn canonicalize_existing_prefix(path: &Path) -> PathBuf {
130    let norm = normalize_lexical(path);
131    let mut existing = norm.as_path();
132    let mut tail: Vec<std::ffi::OsString> = Vec::new();
133    loop {
134        if existing.exists() {
135            break;
136        }
137        match (existing.parent(), existing.file_name()) {
138            (Some(parent), Some(name)) => {
139                tail.push(name.to_os_string());
140                existing = parent;
141            }
142            _ => return norm,
143        }
144    }
145    let mut out = std::fs::canonicalize(existing).unwrap_or_else(|_| existing.to_path_buf());
146    for name in tail.iter().rev() {
147        out.push(name);
148    }
149    out
150}
151
152/// The literal path prefix of a deny entry (before its first glob
153/// metacharacter), trailing `/` trimmed. Empty when the entry starts with a
154/// metacharacter (e.g. a leading globstar) — then only the glob applies.
155fn literal_base(entry: &str) -> String {
156    let cut = entry
157        .find(['*', '?', '[', '{'])
158        .map_or(entry, |i| &entry[..i]);
159    cut.trim_end_matches('/').to_string()
160}
161
162// ── the active-binding pointer ──────────────────────────────────────────────
163
164/// The active-binding pointer:
165/// `<workspace>/.memstead.cache/projection/active-binding.json`.
166///
167/// Successor to the retired deny-list cache. It carries only the canonical id
168/// of the binding whose brief was last **consumed** — never a deny list — so
169/// the enforcement path re-reads the binding record on every check and a
170/// stale list is structurally impossible. A pointer to a binding that no
171/// longer resolves refuses typed at the check, and the consumer fails open.
172fn active_binding_path(workspace_root: &Path) -> PathBuf {
173    workspace_root
174        .join(".memstead.cache")
175        .join("projection")
176        .join("active-binding.json")
177}
178
179/// Publish `binding_id` as the active binding, **stale-safe**: the previous
180/// pointer is unlinked *before* the new write, so a failed write leaves *no*
181/// pointer (the check refuses `NO_ACTIVE_BINDING`, consumers fail open)
182/// rather than a previous binding's pointer. Best-effort engine cache, not a
183/// tracked mutation.
184pub fn write_active_binding_file(workspace_root: &Path, binding_id: &str) {
185    let path = active_binding_path(workspace_root);
186    let _ = std::fs::remove_file(&path);
187    if let Some(parent) = path.parent() {
188        let _ = std::fs::create_dir_all(parent);
189    }
190    let payload = serde_json::json!({ "binding": binding_id });
191    if let Ok(bytes) = serde_json::to_vec(&payload) {
192        let _ = std::fs::write(&path, bytes);
193    }
194}
195
196/// The active binding's canonical id, or `None` when no consuming render has
197/// published one (or the pointer is unreadable — same answer, fail open).
198pub fn read_active_binding_file(workspace_root: &Path) -> Option<String> {
199    let raw = std::fs::read(active_binding_path(workspace_root)).ok()?;
200    let value: serde_json::Value = serde_json::from_slice(&raw).ok()?;
201    value["binding"].as_str().map(str::to_string)
202}
203
204#[cfg(test)]
205mod tests {
206    use super::*;
207
208    const WS: &str = "/home/dev/memstead";
209
210    fn verdicts(deny: &[&str], candidates: &[&str], cwd: &str) -> Vec<PathVerdict> {
211        let deny: Vec<String> = deny.iter().map(|s| s.to_string()).collect();
212        let candidates: Vec<String> = candidates.iter().map(|s| s.to_string()).collect();
213        check_deny_paths(&deny, &candidates, Path::new(cwd), Path::new(WS))
214    }
215
216    fn denied(deny: &[&str], candidate: &str) -> bool {
217        verdicts(deny, &[candidate], WS)[0].denied
218    }
219
220    /// The retired shared fixture's cases, verbatim — the same entry list must
221    /// block the same paths and pass the same paths it pinned when the dialect
222    /// lived twice. Now both consumers run THIS code, and this test pins the
223    /// dialect itself.
224    #[test]
225    fn fixture_cases_hold_at_the_one_seam() {
226        let entries = &["dev/**", "**/VISION.md", "docs/meta/CLAUDE.md"];
227        for blocked in [
228            "dev/notes/a.md",
229            "dev/x.rs",
230            "dev/deep/nested/y.txt",
231            "VISION.md",
232            "crates/foo/VISION.md",
233            "docs/meta/CLAUDE.md",
234        ] {
235            assert!(denied(entries, blocked), "must block {blocked}");
236        }
237        for allowed in [
238            "src/lib.rs",
239            "dev-tools/x.rs",
240            "VISION-draft.md",
241            "docs/meta/README.md",
242            "other/CLAUDE.md",
243            "crates/foo/mod.rs",
244        ] {
245            assert!(!denied(entries, allowed), "must allow {allowed}");
246        }
247    }
248
249    /// The directory-prefix rule: `dev/**` blocks a read of the directory
250    /// `dev` itself (and `dev/`), which the glob alone would allow — the one
251    /// behaviour the JS clone carried that the engine had no equivalent for.
252    #[test]
253    fn subtree_entry_blocks_the_directory_itself() {
254        let entries = &["dev/**"];
255        assert!(denied(entries, "dev"));
256        assert!(denied(entries, "dev/"));
257        assert!(denied(entries, &format!("{WS}/dev")));
258        // Sibling names do not over-match.
259        assert!(!denied(entries, "dev-tools/x.rs"));
260    }
261
262    /// A legacy bare name (the pre-glob dialect) degrades to a
263    /// directory/file-prefix block instead of erroring.
264    #[test]
265    fn legacy_bare_names_degrade_to_prefix_blocks() {
266        let entries = &["VISION.md", "CLAUDE.md", "dev"];
267        assert!(denied(entries, "CLAUDE.md"));
268        assert!(denied(entries, &format!("{WS}/VISION.md")));
269        assert!(denied(entries, "dev/notes/foo.md"));
270        assert!(denied(entries, "dev"));
271        // Sub-area CLAUDE.md files and similar names stay readable.
272        assert!(!denied(entries, "subdir/CLAUDE.md"));
273        assert!(!denied(entries, "VISION-draft.md"));
274        assert!(!denied(entries, "engine/dev-tools/foo.rs"));
275    }
276
277    /// Glob/Grep patterns recursing a denied subtree are candidates too, and
278    /// the same match logic catches them.
279    #[test]
280    fn glob_pattern_candidates_are_blocked() {
281        let entries = &["dev/**"];
282        assert!(denied(entries, "dev/**/*.md"));
283        assert!(denied(entries, &format!("{WS}/dev/**")));
284        assert!(denied(entries, "dev/notes/*"));
285    }
286
287    /// The dogfood `../` cross-medium dialect: deny entries and candidates
288    /// both resolve against the workspace root, so a `../dev/**` entry blocks
289    /// a sibling-directory read while in-workspace files stay readable.
290    #[test]
291    fn dot_dot_entries_match_out_of_workspace_candidates() {
292        let ws = format!("{WS}/graph");
293        let deny: Vec<String> = vec!["../dev/**".into(), "../CLAUDE.md".into()];
294        let candidates: Vec<String> = vec![
295            format!("{WS}/dev/notes/a.md"),
296            format!("{WS}/CLAUDE.md"),
297            format!("{ws}/src/x.rs"),
298        ];
299        let v = check_deny_paths(&deny, &candidates, Path::new(&ws), Path::new(&ws));
300        assert!(v[0].denied, "sibling dev/ read is blocked");
301        assert!(v[1].denied, "sibling CLAUDE.md read is blocked");
302        assert!(!v[2].denied, "in-workspace file is untouched");
303    }
304
305    /// Default-open: an empty deny list denies nothing, and candidates
306    /// outside the workspace are allowed unless an entry names them.
307    #[test]
308    fn empty_list_and_outside_paths_are_open() {
309        assert!(!denied(&[], "CLAUDE.md"));
310        assert!(!denied(&[], "dev/notes/foo.md"));
311        assert!(!denied(&["dev/**"], "/etc/hosts"));
312        assert!(!denied(&["dev/**"], "/tmp/something.md"));
313    }
314
315    /// Relative candidates resolve against `cwd`, not the workspace root —
316    /// an agent working in a subdirectory still cannot reach a denied file
317    /// through a relative path.
318    #[test]
319    fn relative_candidates_resolve_against_cwd() {
320        let v = verdicts(&["dev/**"], &["../dev/notes/a.md"], &format!("{WS}/graph"));
321        assert!(v[0].denied);
322        let v = verdicts(&["dev/**"], &["notes/a.md"], &format!("{WS}/dev"));
323        assert!(v[0].denied);
324    }
325
326    /// The verdict names the FIRST matching entry in declaration order — the
327    /// machine-readable "why" for a blocking consumer's message.
328    #[test]
329    fn matched_entry_is_named_in_order() {
330        let v = verdicts(&["**/VISION.md", "dev/**"], &["dev/VISION.md"], WS);
331        assert_eq!(v[0].matched.as_deref(), Some("**/VISION.md"));
332        let v = verdicts(&["dev/**"], &["src/lib.rs"], WS);
333        assert_eq!(v[0].matched, None);
334        assert!(!v[0].denied);
335    }
336
337    /// A malformed glob entry never disables enforcement: its literal-base
338    /// prefix rule still applies, and the other entries are unaffected.
339    #[test]
340    fn malformed_entry_degrades_to_prefix_rule() {
341        // `[` unclosed — globset refuses the pattern; the base `secrets/`
342        // still prefix-blocks the subtree.
343        let entries = &["secrets/[", "dev/**"];
344        assert!(denied(entries, "secrets/key.txt"));
345        assert!(denied(entries, "dev/x.rs"));
346        assert!(!denied(entries, "src/lib.rs"));
347    }
348
349    /// Engine `globset` semantics apply beyond the old JS parity boundary:
350    /// character classes and brace alternates match as globs — never treated
351    /// as literals. (The literal-base prefix rule still applies on top, as it
352    /// always has: an entry like `docs/[ab].md` also prefix-guards `docs/`
353    /// up to its literal base `docs/` — deny-side over-blocking is the
354    /// conservative direction and matches the retired hook verbatim.)
355    #[test]
356    fn character_classes_and_braces_follow_engine_semantics() {
357        assert!(denied(&["docs/x[ab].md"], "docs/xa.md"));
358        assert!(!denied(&["docs/x[ab].md"], "docs/xz.md"));
359        assert!(denied(&["**/*.{png,jpg}"], "assets/logo.png"));
360        assert!(!denied(&["**/*.{png,jpg}"], "assets/logo.svg"));
361    }
362
363    /// The pointer channel: publish X, overwrite with Y, and read back —
364    /// nothing of X survives a later consume, and a missing pointer reads as
365    /// `None` (consumers fail open).
366    #[test]
367    fn active_binding_pointer_overwrites_and_fails_open() {
368        let ws = tempfile::tempdir().unwrap();
369        assert_eq!(read_active_binding_file(ws.path()), None);
370
371        write_active_binding_file(ws.path(), "engine/x-graph");
372        assert_eq!(
373            read_active_binding_file(ws.path()).as_deref(),
374            Some("engine/x-graph")
375        );
376
377        write_active_binding_file(ws.path(), "project/y-graph");
378        assert_eq!(
379            read_active_binding_file(ws.path()).as_deref(),
380            Some("project/y-graph")
381        );
382    }
383}