Skip to main content

speccoder_runtime/tools/
paths.rs

1//! Path rules for tools: normalisation, containment,
2//! secrets, and the protected set the model may never write.
3
4use crate::format::Rules;
5use std::fs;
6use std::path::{Component, Path, PathBuf};
7
8use globset::{GlobBuilder, GlobSet, GlobSetBuilder};
9
10/// A path refused, with the reason the model is told.
11#[derive(Debug, Clone, PartialEq, Eq)]
12pub struct Refused(pub String);
13
14/// `a/./b/` → `a/b`; refuses absolute paths, `..`, NUL and the empty path.
15pub fn normalise(rel: &str) -> Result<String, Refused> {
16    if rel.contains('\0') {
17        return Err(Refused("path contains NUL".to_owned()));
18    }
19    let mut parts = Vec::new();
20    for c in Path::new(rel).components() {
21        match c {
22            Component::Normal(p) => parts.push(p.to_string_lossy().into_owned()),
23            Component::CurDir => {}
24            _ => {
25                return Err(Refused(format!(
26                    "`{rel}` must be relative to the workspace and must not use `..`"
27                )));
28            }
29        }
30    }
31    if parts.is_empty() {
32        return Err(Refused("empty path".to_owned()));
33    }
34    Ok(parts.join("/"))
35}
36
37/// A glob set matched regardless of letter case, on every OS: on a
38/// case-insensitive volume (macOS and Windows by default) `Speccoder.toml`
39/// and `.CLAUDE/settings.json` are the protected files, and a set that
40/// folds case everywhere is the stricter answer on the others.
41fn set(patterns: &[&str]) -> GlobSet {
42    let mut b = GlobSetBuilder::new();
43    for p in patterns {
44        if let Ok(g) = GlobBuilder::new(p).case_insensitive(true).build() {
45            b.add(g);
46        }
47    }
48    b.build().unwrap_or_else(|_| GlobSet::empty())
49}
50
51/// Files the model may neither read nor write.
52const SECRETS: &[&str] = &[
53    ".env",
54    "**/.env",
55    ".env.*",
56    "**/.env.*",
57    "**/*.pem",
58    "**/*.key",
59    "**/id_rsa*",
60    "**/id_ed25519*",
61    ".ssh/**",
62    "**/.ssh/**",
63    "**/.netrc",
64    "**/.npmrc",
65    "**/.pypirc",
66];
67
68/// Inputs that decide verification.
69const JUDGE: &[&str] = &[
70    "speccoder.toml",
71    "**/speccoder.toml",
72    ".github/**",
73    ".gitlab-ci.yml",
74    ".cargo/config.toml",
75    ".cargo/config",
76    "nextest.toml",
77    ".config/nextest.toml",
78    "pytest.ini",
79    "tox.ini",
80    "**/jest.config.*",
81    "**/vitest.config.*",
82    "deny.toml",
83    "justfile",
84    "Makefile",
85    // Agent configuration: hooks, settings, skills, commands and MCP servers
86    // an agent runs or loads. Spec Kit's runtime events render extension
87    // hooks into these files; a change here runs code in a later session,
88    // outside speccoder's sandbox.
89    ".claude/**",
90    ".agents/**",
91    ".codex/**",
92    ".gemini/**",
93    ".opencode/**",
94    ".cursor/**",
95    ".kiro/**",
96    ".windsurf/**",
97    ".roo/**",
98    ".qwen/**",
99    ".kilocode/**",
100    ".augment/**",
101    ".amazonq/**",
102    ".codebuddy/**",
103    ".qoder/**",
104    ".mcp.json",
105    "**/.mcp.json",
106    "opencode.json",
107    "opencode.jsonc",
108];
109
110/// Agent context files: instructions every agent session reads. Editing them
111/// changes what later attempts are told, so it needs a person.
112const CONTEXT: &[&str] = &[
113    "AGENTS.md",
114    "**/AGENTS.md",
115    "CLAUDE.md",
116    "**/CLAUDE.md",
117    "GEMINI.md",
118    "**/GEMINI.md",
119];
120
121/// Dependency manifests: editable only with approval.
122const MANIFESTS: &[&str] = &[
123    "**/Cargo.toml",
124    "**/package.json",
125    "**/pyproject.toml",
126    "**/requirements*.txt",
127    "**/go.mod",
128    "**/Gemfile",
129    "**/pom.xml",
130    "**/build.gradle",
131    "**/build.gradle.kts",
132];
133
134/// Test files by convention.
135const TESTS: &[&str] = &[
136    "tests/**",
137    "**/tests/**",
138    "test/**",
139    "**/test/**",
140    "**/__tests__/**",
141    "**/*_test.*",
142    "**/test_*.py",
143    "**/*.test.*",
144    "**/*.spec.*",
145    "**/*Test.java",
146    "**/*_spec.rb",
147];
148
149/// Whether a normalised path is a test file by convention.
150#[must_use]
151pub fn is_test_path(rel: &str) -> bool {
152    static SET: std::sync::OnceLock<GlobSet> = std::sync::OnceLock::new();
153    SET.get_or_init(|| set(TESTS)).is_match(rel)
154}
155
156/// The file a person's spec review is recorded in, in a unit's directory
157///. The model never writes it — not while implementing, not while
158/// running a planning skill: an agent must not approve its own spec.
159pub const REVIEW_FILE: &str = "review.json";
160
161/// Why a path is protected.
162#[derive(Debug, Clone, Copy, PartialEq, Eq)]
163pub enum Class {
164    /// The spec format's intent: the spec the work is judged against.
165    Intent,
166    /// Verification inputs.
167    Judge,
168    /// Git internals.
169    Git,
170    /// Credentials.
171    Secret,
172}
173
174impl Class {
175    /// What the model is told, for a project in the reference format
176    /// ([`Protected::reason`] for the project's own).
177    #[must_use]
178    pub fn reason(self) -> &'static str {
179        self.reason_with(crate::format::reference().rules())
180    }
181
182    fn reason_with(self, rules: &Rules) -> &'static str {
183        match self {
184            Self::Intent => rules.intent_reason,
185            Self::Judge => {
186                "it decides verification or configures an agent (speccoder.toml, CI and test-runner configuration, agent hooks, settings, skills and MCP servers)"
187            }
188            Self::Git => "it is git's own data",
189            Self::Secret => "it looks like a credential",
190        }
191    }
192}
193
194/// The protected set, compiled once.
195#[derive(Debug, Clone)]
196pub struct Protected {
197    rules: &'static Rules,
198    secrets: GlobSet,
199    intent: GlobSet,
200    judge: GlobSet,
201    context: GlobSet,
202    manifests: GlobSet,
203}
204
205impl Default for Protected {
206    fn default() -> Self {
207        Self::new(crate::format::reference().rules())
208    }
209}
210
211impl Protected {
212    /// The protected set of a project whose format has `rules`.
213    #[must_use]
214    pub fn new(rules: &'static Rules) -> Self {
215        Self {
216            rules,
217            secrets: set(SECRETS),
218            intent: set(rules.intent),
219            judge: set(JUDGE),
220            context: set(CONTEXT),
221            manifests: set(MANIFESTS),
222        }
223    }
224
225    /// The format's rules this set was built from.
226    #[must_use]
227    pub fn rules(&self) -> &'static Rules {
228        self.rules
229    }
230
231    /// What the model is told about a write to a path of `class`.
232    #[must_use]
233    pub fn reason(&self, class: Class) -> &'static str {
234        class.reason_with(self.rules)
235    }
236
237    /// Whether a normalised path is a review record: an intent path named
238    /// [`REVIEW_FILE`]. A planning skill writes intent; never this.
239    #[must_use]
240    pub fn is_review(&self, rel: &str) -> bool {
241        self.intent.is_match(rel)
242            && rel
243                .rsplit('/')
244                .next()
245                .is_some_and(|f| f.eq_ignore_ascii_case(REVIEW_FILE))
246    }
247
248    /// Whether a normalised path is a secret (refused for reading too).
249    #[must_use]
250    pub fn is_secret(&self, rel: &str) -> bool {
251        self.secrets.is_match(rel)
252    }
253
254    /// The protected class a normalised path belongs to, for writing.
255    #[must_use]
256    pub fn write_class(&self, rel: &str) -> Option<Class> {
257        let folded = rel.to_lowercase();
258        if folded == ".git"
259            || folded.starts_with(".git/")
260            || folded.contains("/.git/")
261            || folded.ends_with("/.git")
262        {
263            Some(Class::Git)
264        } else if self.is_secret(rel) {
265            Some(Class::Secret)
266        } else if self.intent.is_match(rel) {
267            Some(Class::Intent)
268        } else if self.judge.is_match(rel) {
269            Some(Class::Judge)
270        } else {
271            None
272        }
273    }
274
275    /// Whether a normalised path is an agent context file (`AGENTS.md`,
276    /// `CLAUDE.md`, `GEMINI.md`): writable only with a person's approval.
277    #[must_use]
278    pub fn is_context(&self, rel: &str) -> bool {
279        self.write_class(rel).is_none() && self.context.is_match(rel)
280    }
281
282    /// Whether a normalised path is a judge or intent input: what a command
283    /// must not change while an attempt runs.
284    #[must_use]
285    pub fn is_judged(&self, rel: &str) -> bool {
286        matches!(self.write_class(rel), Some(Class::Judge | Class::Intent))
287    }
288
289    /// Whether writing this path changes dependencies (needs approval).
290    #[must_use]
291    pub fn is_manifest(&self, rel: &str) -> bool {
292        self.manifests.is_match(rel)
293    }
294}
295
296/// An existing path inside `root` (symlinks resolved), for reading.
297pub fn resolve_existing(root: &Path, rel: &str) -> Result<PathBuf, Refused> {
298    let rel = normalise(rel)?;
299    let full = root.join(&rel);
300    let canonical =
301        fs::canonicalize(&full).map_err(|_| Refused(format!("`{rel}` does not exist")))?;
302    if canonical.starts_with(root) {
303        Ok(canonical)
304    } else {
305        Err(Refused(format!("`{rel}` resolves outside the workspace")))
306    }
307}
308
309/// A path inside `root` to write: every existing ancestor must resolve
310/// inside the root, and the target itself must not be a symlink.
311pub fn resolve_for_write(root: &Path, rel: &str) -> Result<PathBuf, Refused> {
312    let rel = normalise(rel)?;
313    let full = root.join(&rel);
314    if fs::symlink_metadata(&full).is_ok_and(|m| m.file_type().is_symlink()) {
315        return Err(Refused(format!(
316            "`{rel}` is a symlink; not written through"
317        )));
318    }
319    let mut ancestor = full.parent();
320    while let Some(a) = ancestor {
321        if a.exists() {
322            let canonical = fs::canonicalize(a).map_err(|e| Refused(format!("`{rel}`: {e}")))?;
323            if !canonical.starts_with(root) {
324                return Err(Refused(format!("`{rel}` resolves outside the workspace")));
325            }
326            break;
327        }
328        ancestor = a.parent();
329    }
330    Ok(full)
331}
332
333/// The file a write of `rel` reaches, workspace-relative: every existing
334/// ancestor's symlinks resolved (they must stay inside `root`, which is
335/// canonical), the components that do not exist yet appended as written,
336/// and the target itself never a symlink. Every component that exists is
337/// named as the volume stores it, so on a case-insensitive volume
338/// `Speccoder.toml` answers `speccoder.toml`. Two spellings that reach the
339/// same file give the same answer; the same spelling after a directory was
340/// swapped for a symlink gives another.
341pub fn canonical_for_write(root: &Path, rel: &str) -> Result<String, Refused> {
342    let rel = normalise(rel)?;
343    let parts: Vec<&str> = rel.split('/').collect();
344    let mut at = root.to_path_buf();
345    for (i, part) in parts.iter().enumerate() {
346        let next = at.join(part);
347        let Ok(meta) = fs::symlink_metadata(&next) else {
348            // Not there yet: the rest is appended as written.
349            for rest in &parts[i..] {
350                at.push(rest);
351            }
352            break;
353        };
354        if i + 1 == parts.len() {
355            if meta.file_type().is_symlink() {
356                return Err(Refused(format!(
357                    "`{rel}` is a symlink; not written through"
358                )));
359            }
360            // Not a symlink: canonicalizing only names it as stored.
361            let canonical =
362                fs::canonicalize(&next).map_err(|e| Refused(format!("`{rel}`: {e}")))?;
363            if !canonical.starts_with(root) {
364                return Err(Refused(format!("`{rel}` resolves outside the workspace")));
365            }
366            at = canonical;
367            break;
368        }
369        let canonical = fs::canonicalize(&next).map_err(|e| Refused(format!("`{rel}`: {e}")))?;
370        if !canonical.starts_with(root) {
371            return Err(Refused(format!("`{rel}` resolves outside the workspace")));
372        }
373        at = canonical;
374    }
375    let inside = at
376        .strip_prefix(root)
377        .map_err(|_| Refused(format!("`{rel}` resolves outside the workspace")))?;
378    let out: Vec<String> = inside
379        .components()
380        .map(|c| c.as_os_str().to_string_lossy().into_owned())
381        .collect();
382    if out.is_empty() {
383        return Err(Refused("empty path".to_owned()));
384    }
385    Ok(out.join("/"))
386}
387
388#[cfg(test)]
389mod tests {
390    use super::*;
391
392    #[test]
393    fn normalises_and_refuses_escapes() {
394        assert_eq!(normalise("./src//lib.rs").unwrap(), "src/lib.rs");
395        assert_eq!(normalise("src/").unwrap(), "src");
396        assert!(normalise("../x").is_err());
397        assert!(normalise("/etc/passwd").is_err());
398        assert!(normalise("a/../../b").is_err());
399        assert!(normalise("").is_err());
400    }
401
402    #[test]
403    fn classifies_the_protected_set() {
404        let p = Protected::default();
405        for (path, class) in [
406            ("specs/001-x/tasks.md", Class::Intent),
407            (".specify/memory/constitution.md", Class::Intent),
408            ("speccoder.toml", Class::Judge),
409            (".github/workflows/ci.yml", Class::Judge),
410            (".config/nextest.toml", Class::Judge),
411            ("web/jest.config.ts", Class::Judge),
412            (".git/config", Class::Git),
413            (".env", Class::Secret),
414            ("deploy/.env.production", Class::Secret),
415            ("certs/server.key", Class::Secret),
416        ] {
417            assert_eq!(p.write_class(path), Some(class), "{path}");
418        }
419        for agent in [
420            ".claude/settings.json",
421            ".claude/settings.local.json",
422            ".claude/skills/x/SKILL.md",
423            ".agents/skills/y/SKILL.md",
424            ".codex/config.toml",
425            ".kiro/hooks/a.json",
426            ".mcp.json",
427            "web/.mcp.json",
428            "opencode.json",
429        ] {
430            assert_eq!(p.write_class(agent), Some(Class::Judge), "{agent}");
431        }
432        for context in ["AGENTS.md", "web/AGENTS.md", "CLAUDE.md", "GEMINI.md"] {
433            assert_eq!(p.write_class(context), None, "{context}");
434            assert!(p.is_context(context), "{context}");
435        }
436        assert!(!p.is_context("README.md") && !p.is_context(".specify/memory/constitution.md"));
437        for free in ["src/lib.rs", "tests/api.rs", "docs/specs.md", "README.md"] {
438            assert_eq!(p.write_class(free), None, "{free}");
439        }
440        assert!(p.is_manifest("Cargo.toml") && p.is_manifest("crates/x/Cargo.toml"));
441        assert!(p.is_manifest("web/package.json") && !p.is_manifest("src/main.rs"));
442        for t in [
443            "tests/api.rs",
444            "crates/x/tests/it.rs",
445            "app/test_login.py",
446            "web/a.test.ts",
447            "web/__tests__/b.js",
448            "pkg/x_test.go",
449        ] {
450            assert!(is_test_path(t), "{t}");
451        }
452        for f in ["src/lib.rs", "src/testing.rs", "docs/tests.md"] {
453            assert!(!is_test_path(f), "{f}");
454        }
455    }
456
457    /// Letter case never takes a path out of the protected set: on a
458    /// case-insensitive volume each of these is the protected file itself.
459    #[test]
460    fn letter_case_does_not_leave_the_protected_set() {
461        let p = Protected::default();
462        for (path, class) in [
463            ("Speccoder.toml", Class::Judge),
464            ("SPECCODER.TOML", Class::Judge),
465            ("makefile", Class::Judge),
466            ("JUSTFILE", Class::Judge),
467            (".CURSOR/rules.json", Class::Judge),
468            (".CLAUDE/settings.json", Class::Judge),
469            (".Github/workflows/ci.yml", Class::Judge),
470            ("Specs/001-x/tasks.md", Class::Intent),
471            (".SPECIFY/memory/constitution.md", Class::Intent),
472            (".GIT/config", Class::Git),
473            ("sub/.Git", Class::Git),
474            (".ENV", Class::Secret),
475            ("certs/server.KEY", Class::Secret),
476        ] {
477            assert_eq!(p.write_class(path), Some(class), "{path}");
478        }
479        assert!(p.is_review("specs/001-x/Review.JSON"));
480        assert!(p.is_context("agents.md") && p.is_context("web/Claude.md"));
481        assert!(p.is_manifest("crates/x/cargo.TOML"));
482        assert!(is_test_path("Tests/api.rs"));
483    }
484
485    /// An existing file is named as the volume stores it: on a
486    /// case-insensitive volume another spelling reaches the same file and
487    /// gives the same answer.
488    #[test]
489    fn an_existing_file_is_named_as_stored() {
490        let dir = tempfile::tempdir().unwrap();
491        let root = fs::canonicalize(dir.path()).unwrap();
492        fs::create_dir_all(root.join("src")).unwrap();
493        fs::write(root.join("speccoder.toml"), "").unwrap();
494        fs::write(root.join("src/lib.rs"), "").unwrap();
495        let insensitive = root.join("SPECCODER.toml").exists();
496        let got = canonical_for_write(&root, "Speccoder.toml").unwrap();
497        let lib = canonical_for_write(&root, "SRC/Lib.rs").unwrap();
498        if insensitive {
499            assert_eq!(got, "speccoder.toml");
500            assert_eq!(lib, "src/lib.rs");
501        } else {
502            assert_eq!(got, "Speccoder.toml");
503            assert_eq!(lib, "SRC/Lib.rs");
504        }
505    }
506
507    #[test]
508    fn a_write_reaches_its_canonical_file() {
509        let dir = tempfile::tempdir().unwrap();
510        let root = fs::canonicalize(dir.path()).unwrap();
511        fs::create_dir_all(root.join("src/b")).unwrap();
512        fs::create_dir_all(root.join("src/a")).unwrap();
513        assert_eq!(
514            canonical_for_write(&root, "./src/a/x.rs").unwrap(),
515            "src/a/x.rs"
516        );
517        // Not there yet: appended as written.
518        assert_eq!(
519            canonical_for_write(&root, "src/new/deep/x.rs").unwrap(),
520            "src/new/deep/x.rs"
521        );
522        // A directory swapped for a symlink inside the root: another file.
523        fs::remove_dir(root.join("src/a")).unwrap();
524        std::os::unix::fs::symlink(root.join("src/b"), root.join("src/a")).unwrap();
525        assert_eq!(
526            canonical_for_write(&root, "src/a/x.rs").unwrap(),
527            "src/b/x.rs"
528        );
529        // An ancestor that leaves the root, and a symlinked target, are refused.
530        let outside = tempfile::tempdir().unwrap();
531        std::os::unix::fs::symlink(outside.path(), root.join("out")).unwrap();
532        assert!(canonical_for_write(&root, "out/x.rs").is_err());
533        fs::write(root.join("src/b/real.rs"), "").unwrap();
534        std::os::unix::fs::symlink(root.join("src/b/real.rs"), root.join("src/b/link.rs")).unwrap();
535        assert!(canonical_for_write(&root, "src/b/link.rs").is_err());
536        assert!(canonical_for_write(&root, "../x").is_err());
537    }
538}