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