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}