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    /// The files the project's hooks run: judge files by exact path.
204    hook_files: Vec<String>,
205}
206
207impl Default for Protected {
208    fn default() -> Self {
209        Self::new(crate::format::reference().rules())
210    }
211}
212
213impl Protected {
214    /// The protected set of a project whose format has `rules`.
215    #[must_use]
216    pub fn new(rules: &'static Rules) -> Self {
217        Self {
218            rules,
219            secrets: set(SECRETS),
220            intent: set(rules.intent),
221            judge: set(JUDGE),
222            context: set(CONTEXT),
223            manifests: set(MANIFESTS),
224            hook_files: Vec::new(),
225        }
226    }
227
228    /// The same set with the files the project's hooks run as judge files.
229    #[must_use]
230    pub fn with_hook_files(mut self, files: Vec<String>) -> Self {
231        self.hook_files = files;
232        self
233    }
234
235    /// The format's rules this set was built from.
236    #[must_use]
237    pub fn rules(&self) -> &'static Rules {
238        self.rules
239    }
240
241    /// What the model is told about a write to a path of `class`.
242    #[must_use]
243    pub fn reason(&self, class: Class) -> &'static str {
244        class.reason_with(self.rules)
245    }
246
247    /// Whether a normalised path is a review record: an intent path named
248    /// [`REVIEW_FILE`]. A planning skill writes intent; never this.
249    #[must_use]
250    pub fn is_review(&self, rel: &str) -> bool {
251        self.intent.is_match(rel)
252            && rel
253                .rsplit('/')
254                .next()
255                .is_some_and(|f| f.eq_ignore_ascii_case(REVIEW_FILE))
256    }
257
258    /// Whether a normalised path is a secret (refused for reading too).
259    #[must_use]
260    pub fn is_secret(&self, rel: &str) -> bool {
261        self.secrets.is_match(rel)
262    }
263
264    /// The protected class a normalised path belongs to, for writing.
265    #[must_use]
266    pub fn write_class(&self, rel: &str) -> Option<Class> {
267        let folded = rel.to_lowercase();
268        if folded == ".git"
269            || folded.starts_with(".git/")
270            || folded.contains("/.git/")
271            || folded.ends_with("/.git")
272        {
273            Some(Class::Git)
274        } else if self.is_secret(rel) {
275            Some(Class::Secret)
276        } else if self.intent.is_match(rel) {
277            Some(Class::Intent)
278        } else if self.judge.is_match(rel) || self.hook_files.iter().any(|f| f == rel) {
279            Some(Class::Judge)
280        } else {
281            None
282        }
283    }
284
285    /// Whether a normalised path is an agent context file (`AGENTS.md`,
286    /// `CLAUDE.md`, `GEMINI.md`): writable only with a person's approval.
287    #[must_use]
288    pub fn is_context(&self, rel: &str) -> bool {
289        self.write_class(rel).is_none() && self.context.is_match(rel)
290    }
291
292    /// Whether a normalised path is a judge or intent input: what a command
293    /// must not change while an attempt runs.
294    #[must_use]
295    pub fn is_judged(&self, rel: &str) -> bool {
296        matches!(self.write_class(rel), Some(Class::Judge | Class::Intent))
297    }
298
299    /// Whether a normalised path is a design document of the intent: an
300    /// intent path that is no judge input, agent context file, manifest,
301    /// credential or hidden file — what a planning task may write.
302    #[must_use]
303    pub fn is_design_doc(&self, rel: &str) -> bool {
304        self.write_class(rel) == Some(Class::Intent)
305            && !self.judge.is_match(rel)
306            && !self.context.is_match(rel)
307            && !self.is_manifest(rel)
308            && !rel.split('/').any(|s| s.starts_with('.'))
309    }
310
311    /// Whether writing this path changes dependencies (needs approval).
312    #[must_use]
313    pub fn is_manifest(&self, rel: &str) -> bool {
314        self.manifests.is_match(rel)
315    }
316}
317
318/// An existing path inside `root` (symlinks resolved), for reading.
319pub fn resolve_existing(root: &Path, rel: &str) -> Result<PathBuf, Refused> {
320    let rel = normalise(rel)?;
321    let full = root.join(&rel);
322    let canonical =
323        fs::canonicalize(&full).map_err(|_| Refused(format!("`{rel}` does not exist")))?;
324    if canonical.starts_with(root) {
325        Ok(canonical)
326    } else {
327        Err(Refused(format!("`{rel}` resolves outside the workspace")))
328    }
329}
330
331/// A path inside `root` to write: every existing ancestor must resolve
332/// inside the root, and the target itself must not be a symlink.
333pub fn resolve_for_write(root: &Path, rel: &str) -> Result<PathBuf, Refused> {
334    let rel = normalise(rel)?;
335    let full = root.join(&rel);
336    if fs::symlink_metadata(&full).is_ok_and(|m| m.file_type().is_symlink()) {
337        return Err(Refused(format!(
338            "`{rel}` is a symlink; not written through"
339        )));
340    }
341    let mut ancestor = full.parent();
342    while let Some(a) = ancestor {
343        if a.exists() {
344            let canonical = fs::canonicalize(a).map_err(|e| Refused(format!("`{rel}`: {e}")))?;
345            if !canonical.starts_with(root) {
346                return Err(Refused(format!("`{rel}` resolves outside the workspace")));
347            }
348            break;
349        }
350        ancestor = a.parent();
351    }
352    Ok(full)
353}
354
355/// The file a write of `rel` reaches, workspace-relative: every existing
356/// ancestor's symlinks resolved (they must stay inside `root`, which is
357/// canonical), the components that do not exist yet appended as written,
358/// and the target itself never a symlink. Every component that exists is
359/// named as the volume stores it, so on a case-insensitive volume
360/// `Speccoder.toml` answers `speccoder.toml`. Two spellings that reach the
361/// same file give the same answer; the same spelling after a directory was
362/// swapped for a symlink gives another.
363pub fn canonical_for_write(root: &Path, rel: &str) -> Result<String, Refused> {
364    let rel = normalise(rel)?;
365    let parts: Vec<&str> = rel.split('/').collect();
366    let mut at = root.to_path_buf();
367    for (i, part) in parts.iter().enumerate() {
368        let next = at.join(part);
369        let Ok(meta) = fs::symlink_metadata(&next) else {
370            // Not there yet: the rest is appended as written.
371            for rest in &parts[i..] {
372                at.push(rest);
373            }
374            break;
375        };
376        if i + 1 == parts.len() {
377            if meta.file_type().is_symlink() {
378                return Err(Refused(format!(
379                    "`{rel}` is a symlink; not written through"
380                )));
381            }
382            // Not a symlink: canonicalizing only names it as stored.
383            let canonical =
384                fs::canonicalize(&next).map_err(|e| Refused(format!("`{rel}`: {e}")))?;
385            if !canonical.starts_with(root) {
386                return Err(Refused(format!("`{rel}` resolves outside the workspace")));
387            }
388            at = canonical;
389            break;
390        }
391        let canonical = fs::canonicalize(&next).map_err(|e| Refused(format!("`{rel}`: {e}")))?;
392        if !canonical.starts_with(root) {
393            return Err(Refused(format!("`{rel}` resolves outside the workspace")));
394        }
395        at = canonical;
396    }
397    let inside = at
398        .strip_prefix(root)
399        .map_err(|_| Refused(format!("`{rel}` resolves outside the workspace")))?;
400    let out: Vec<String> = inside
401        .components()
402        .map(|c| c.as_os_str().to_string_lossy().into_owned())
403        .collect();
404    if out.is_empty() {
405        return Err(Refused("empty path".to_owned()));
406    }
407    Ok(out.join("/"))
408}
409
410#[cfg(test)]
411mod tests {
412    use super::*;
413
414    #[test]
415    fn normalises_and_refuses_escapes() {
416        assert_eq!(normalise("./src//lib.rs").unwrap(), "src/lib.rs");
417        assert_eq!(normalise("src/").unwrap(), "src");
418        assert!(normalise("../x").is_err());
419        assert!(normalise("/etc/passwd").is_err());
420        assert!(normalise("a/../../b").is_err());
421        assert!(normalise("").is_err());
422    }
423
424    #[test]
425    fn a_file_a_hook_runs_is_a_judge_file() {
426        let p = Protected::default().with_hook_files(vec!["scripts/licence.sh".into()]);
427        assert_eq!(p.write_class("scripts/licence.sh"), Some(Class::Judge));
428        assert_eq!(p.write_class("scripts/other.sh"), None);
429        assert!(p.is_judged("scripts/licence.sh"));
430    }
431
432    #[test]
433    fn classifies_the_protected_set() {
434        let p = Protected::default();
435        for (path, class) in [
436            ("specs/001-x/tasks.md", Class::Intent),
437            (".specify/memory/constitution.md", Class::Intent),
438            ("speccoder.toml", Class::Judge),
439            (".github/workflows/ci.yml", Class::Judge),
440            (".config/nextest.toml", Class::Judge),
441            ("web/jest.config.ts", Class::Judge),
442            (".git/config", Class::Git),
443            (".env", Class::Secret),
444            ("deploy/.env.production", Class::Secret),
445            ("certs/server.key", Class::Secret),
446        ] {
447            assert_eq!(p.write_class(path), Some(class), "{path}");
448        }
449        for agent in [
450            ".claude/settings.json",
451            ".claude/settings.local.json",
452            ".claude/skills/x/SKILL.md",
453            ".agents/skills/y/SKILL.md",
454            ".codex/config.toml",
455            ".kiro/hooks/a.json",
456            ".mcp.json",
457            "web/.mcp.json",
458            "opencode.json",
459        ] {
460            assert_eq!(p.write_class(agent), Some(Class::Judge), "{agent}");
461        }
462        for context in ["AGENTS.md", "web/AGENTS.md", "CLAUDE.md", "GEMINI.md"] {
463            assert_eq!(p.write_class(context), None, "{context}");
464            assert!(p.is_context(context), "{context}");
465        }
466        assert!(!p.is_context("README.md") && !p.is_context(".specify/memory/constitution.md"));
467        for free in ["src/lib.rs", "tests/api.rs", "docs/specs.md", "README.md"] {
468            assert_eq!(p.write_class(free), None, "{free}");
469        }
470        assert!(p.is_manifest("Cargo.toml") && p.is_manifest("crates/x/Cargo.toml"));
471        assert!(p.is_manifest("web/package.json") && !p.is_manifest("src/main.rs"));
472        for t in [
473            "tests/api.rs",
474            "crates/x/tests/it.rs",
475            "app/test_login.py",
476            "web/a.test.ts",
477            "web/__tests__/b.js",
478            "pkg/x_test.go",
479        ] {
480            assert!(is_test_path(t), "{t}");
481        }
482        for f in ["src/lib.rs", "src/testing.rs", "docs/tests.md"] {
483            assert!(!is_test_path(f), "{f}");
484        }
485    }
486
487    /// Letter case never takes a path out of the protected set: on a
488    /// case-insensitive volume each of these is the protected file itself.
489    #[test]
490    fn letter_case_does_not_leave_the_protected_set() {
491        let p = Protected::default();
492        for (path, class) in [
493            ("Speccoder.toml", Class::Judge),
494            ("SPECCODER.TOML", Class::Judge),
495            ("makefile", Class::Judge),
496            ("JUSTFILE", Class::Judge),
497            (".CURSOR/rules.json", Class::Judge),
498            (".CLAUDE/settings.json", Class::Judge),
499            (".Github/workflows/ci.yml", Class::Judge),
500            ("Specs/001-x/tasks.md", Class::Intent),
501            (".SPECIFY/memory/constitution.md", Class::Intent),
502            (".GIT/config", Class::Git),
503            ("sub/.Git", Class::Git),
504            (".ENV", Class::Secret),
505            ("certs/server.KEY", Class::Secret),
506        ] {
507            assert_eq!(p.write_class(path), Some(class), "{path}");
508        }
509        assert!(p.is_review("specs/001-x/Review.JSON"));
510        assert!(p.is_context("agents.md") && p.is_context("web/Claude.md"));
511        assert!(p.is_manifest("crates/x/cargo.TOML"));
512        assert!(is_test_path("Tests/api.rs"));
513    }
514
515    /// An existing file is named as the volume stores it: on a
516    /// case-insensitive volume another spelling reaches the same file and
517    /// gives the same answer.
518    #[test]
519    fn an_existing_file_is_named_as_stored() {
520        let dir = tempfile::tempdir().unwrap();
521        let root = fs::canonicalize(dir.path()).unwrap();
522        fs::create_dir_all(root.join("src")).unwrap();
523        fs::write(root.join("speccoder.toml"), "").unwrap();
524        fs::write(root.join("src/lib.rs"), "").unwrap();
525        let insensitive = root.join("SPECCODER.toml").exists();
526        let got = canonical_for_write(&root, "Speccoder.toml").unwrap();
527        let lib = canonical_for_write(&root, "SRC/Lib.rs").unwrap();
528        if insensitive {
529            assert_eq!(got, "speccoder.toml");
530            assert_eq!(lib, "src/lib.rs");
531        } else {
532            assert_eq!(got, "Speccoder.toml");
533            assert_eq!(lib, "SRC/Lib.rs");
534        }
535    }
536
537    #[test]
538    fn a_write_reaches_its_canonical_file() {
539        let dir = tempfile::tempdir().unwrap();
540        let root = fs::canonicalize(dir.path()).unwrap();
541        fs::create_dir_all(root.join("src/b")).unwrap();
542        fs::create_dir_all(root.join("src/a")).unwrap();
543        assert_eq!(
544            canonical_for_write(&root, "./src/a/x.rs").unwrap(),
545            "src/a/x.rs"
546        );
547        // Not there yet: appended as written.
548        assert_eq!(
549            canonical_for_write(&root, "src/new/deep/x.rs").unwrap(),
550            "src/new/deep/x.rs"
551        );
552        // A directory swapped for a symlink inside the root: another file.
553        fs::remove_dir(root.join("src/a")).unwrap();
554        std::os::unix::fs::symlink(root.join("src/b"), root.join("src/a")).unwrap();
555        assert_eq!(
556            canonical_for_write(&root, "src/a/x.rs").unwrap(),
557            "src/b/x.rs"
558        );
559        // An ancestor that leaves the root, and a symlinked target, are refused.
560        let outside = tempfile::tempdir().unwrap();
561        std::os::unix::fs::symlink(outside.path(), root.join("out")).unwrap();
562        assert!(canonical_for_write(&root, "out/x.rs").is_err());
563        fs::write(root.join("src/b/real.rs"), "").unwrap();
564        std::os::unix::fs::symlink(root.join("src/b/real.rs"), root.join("src/b/link.rs")).unwrap();
565        assert!(canonical_for_write(&root, "src/b/link.rs").is_err());
566        assert!(canonical_for_write(&root, "../x").is_err());
567    }
568}