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 a normalised path is a design document of the intent: an
290    /// intent path that is no judge input, agent context file, manifest,
291    /// credential or hidden file — what a planning task may write.
292    #[must_use]
293    pub fn is_design_doc(&self, rel: &str) -> bool {
294        self.write_class(rel) == Some(Class::Intent)
295            && !self.judge.is_match(rel)
296            && !self.context.is_match(rel)
297            && !self.is_manifest(rel)
298            && !rel.split('/').any(|s| s.starts_with('.'))
299    }
300
301    /// Whether writing this path changes dependencies (needs approval).
302    #[must_use]
303    pub fn is_manifest(&self, rel: &str) -> bool {
304        self.manifests.is_match(rel)
305    }
306}
307
308/// An existing path inside `root` (symlinks resolved), for reading.
309pub fn resolve_existing(root: &Path, rel: &str) -> Result<PathBuf, Refused> {
310    let rel = normalise(rel)?;
311    let full = root.join(&rel);
312    let canonical =
313        fs::canonicalize(&full).map_err(|_| Refused(format!("`{rel}` does not exist")))?;
314    if canonical.starts_with(root) {
315        Ok(canonical)
316    } else {
317        Err(Refused(format!("`{rel}` resolves outside the workspace")))
318    }
319}
320
321/// A path inside `root` to write: every existing ancestor must resolve
322/// inside the root, and the target itself must not be a symlink.
323pub fn resolve_for_write(root: &Path, rel: &str) -> Result<PathBuf, Refused> {
324    let rel = normalise(rel)?;
325    let full = root.join(&rel);
326    if fs::symlink_metadata(&full).is_ok_and(|m| m.file_type().is_symlink()) {
327        return Err(Refused(format!(
328            "`{rel}` is a symlink; not written through"
329        )));
330    }
331    let mut ancestor = full.parent();
332    while let Some(a) = ancestor {
333        if a.exists() {
334            let canonical = fs::canonicalize(a).map_err(|e| Refused(format!("`{rel}`: {e}")))?;
335            if !canonical.starts_with(root) {
336                return Err(Refused(format!("`{rel}` resolves outside the workspace")));
337            }
338            break;
339        }
340        ancestor = a.parent();
341    }
342    Ok(full)
343}
344
345/// The file a write of `rel` reaches, workspace-relative: every existing
346/// ancestor's symlinks resolved (they must stay inside `root`, which is
347/// canonical), the components that do not exist yet appended as written,
348/// and the target itself never a symlink. Every component that exists is
349/// named as the volume stores it, so on a case-insensitive volume
350/// `Speccoder.toml` answers `speccoder.toml`. Two spellings that reach the
351/// same file give the same answer; the same spelling after a directory was
352/// swapped for a symlink gives another.
353pub fn canonical_for_write(root: &Path, rel: &str) -> Result<String, Refused> {
354    let rel = normalise(rel)?;
355    let parts: Vec<&str> = rel.split('/').collect();
356    let mut at = root.to_path_buf();
357    for (i, part) in parts.iter().enumerate() {
358        let next = at.join(part);
359        let Ok(meta) = fs::symlink_metadata(&next) else {
360            // Not there yet: the rest is appended as written.
361            for rest in &parts[i..] {
362                at.push(rest);
363            }
364            break;
365        };
366        if i + 1 == parts.len() {
367            if meta.file_type().is_symlink() {
368                return Err(Refused(format!(
369                    "`{rel}` is a symlink; not written through"
370                )));
371            }
372            // Not a symlink: canonicalizing only names it as stored.
373            let canonical =
374                fs::canonicalize(&next).map_err(|e| Refused(format!("`{rel}`: {e}")))?;
375            if !canonical.starts_with(root) {
376                return Err(Refused(format!("`{rel}` resolves outside the workspace")));
377            }
378            at = canonical;
379            break;
380        }
381        let canonical = fs::canonicalize(&next).map_err(|e| Refused(format!("`{rel}`: {e}")))?;
382        if !canonical.starts_with(root) {
383            return Err(Refused(format!("`{rel}` resolves outside the workspace")));
384        }
385        at = canonical;
386    }
387    let inside = at
388        .strip_prefix(root)
389        .map_err(|_| Refused(format!("`{rel}` resolves outside the workspace")))?;
390    let out: Vec<String> = inside
391        .components()
392        .map(|c| c.as_os_str().to_string_lossy().into_owned())
393        .collect();
394    if out.is_empty() {
395        return Err(Refused("empty path".to_owned()));
396    }
397    Ok(out.join("/"))
398}
399
400#[cfg(test)]
401mod tests {
402    use super::*;
403
404    #[test]
405    fn normalises_and_refuses_escapes() {
406        assert_eq!(normalise("./src//lib.rs").unwrap(), "src/lib.rs");
407        assert_eq!(normalise("src/").unwrap(), "src");
408        assert!(normalise("../x").is_err());
409        assert!(normalise("/etc/passwd").is_err());
410        assert!(normalise("a/../../b").is_err());
411        assert!(normalise("").is_err());
412    }
413
414    #[test]
415    fn classifies_the_protected_set() {
416        let p = Protected::default();
417        for (path, class) in [
418            ("specs/001-x/tasks.md", Class::Intent),
419            (".specify/memory/constitution.md", Class::Intent),
420            ("speccoder.toml", Class::Judge),
421            (".github/workflows/ci.yml", Class::Judge),
422            (".config/nextest.toml", Class::Judge),
423            ("web/jest.config.ts", Class::Judge),
424            (".git/config", Class::Git),
425            (".env", Class::Secret),
426            ("deploy/.env.production", Class::Secret),
427            ("certs/server.key", Class::Secret),
428        ] {
429            assert_eq!(p.write_class(path), Some(class), "{path}");
430        }
431        for agent in [
432            ".claude/settings.json",
433            ".claude/settings.local.json",
434            ".claude/skills/x/SKILL.md",
435            ".agents/skills/y/SKILL.md",
436            ".codex/config.toml",
437            ".kiro/hooks/a.json",
438            ".mcp.json",
439            "web/.mcp.json",
440            "opencode.json",
441        ] {
442            assert_eq!(p.write_class(agent), Some(Class::Judge), "{agent}");
443        }
444        for context in ["AGENTS.md", "web/AGENTS.md", "CLAUDE.md", "GEMINI.md"] {
445            assert_eq!(p.write_class(context), None, "{context}");
446            assert!(p.is_context(context), "{context}");
447        }
448        assert!(!p.is_context("README.md") && !p.is_context(".specify/memory/constitution.md"));
449        for free in ["src/lib.rs", "tests/api.rs", "docs/specs.md", "README.md"] {
450            assert_eq!(p.write_class(free), None, "{free}");
451        }
452        assert!(p.is_manifest("Cargo.toml") && p.is_manifest("crates/x/Cargo.toml"));
453        assert!(p.is_manifest("web/package.json") && !p.is_manifest("src/main.rs"));
454        for t in [
455            "tests/api.rs",
456            "crates/x/tests/it.rs",
457            "app/test_login.py",
458            "web/a.test.ts",
459            "web/__tests__/b.js",
460            "pkg/x_test.go",
461        ] {
462            assert!(is_test_path(t), "{t}");
463        }
464        for f in ["src/lib.rs", "src/testing.rs", "docs/tests.md"] {
465            assert!(!is_test_path(f), "{f}");
466        }
467    }
468
469    /// Letter case never takes a path out of the protected set: on a
470    /// case-insensitive volume each of these is the protected file itself.
471    #[test]
472    fn letter_case_does_not_leave_the_protected_set() {
473        let p = Protected::default();
474        for (path, class) in [
475            ("Speccoder.toml", Class::Judge),
476            ("SPECCODER.TOML", Class::Judge),
477            ("makefile", Class::Judge),
478            ("JUSTFILE", Class::Judge),
479            (".CURSOR/rules.json", Class::Judge),
480            (".CLAUDE/settings.json", Class::Judge),
481            (".Github/workflows/ci.yml", Class::Judge),
482            ("Specs/001-x/tasks.md", Class::Intent),
483            (".SPECIFY/memory/constitution.md", Class::Intent),
484            (".GIT/config", Class::Git),
485            ("sub/.Git", Class::Git),
486            (".ENV", Class::Secret),
487            ("certs/server.KEY", Class::Secret),
488        ] {
489            assert_eq!(p.write_class(path), Some(class), "{path}");
490        }
491        assert!(p.is_review("specs/001-x/Review.JSON"));
492        assert!(p.is_context("agents.md") && p.is_context("web/Claude.md"));
493        assert!(p.is_manifest("crates/x/cargo.TOML"));
494        assert!(is_test_path("Tests/api.rs"));
495    }
496
497    /// An existing file is named as the volume stores it: on a
498    /// case-insensitive volume another spelling reaches the same file and
499    /// gives the same answer.
500    #[test]
501    fn an_existing_file_is_named_as_stored() {
502        let dir = tempfile::tempdir().unwrap();
503        let root = fs::canonicalize(dir.path()).unwrap();
504        fs::create_dir_all(root.join("src")).unwrap();
505        fs::write(root.join("speccoder.toml"), "").unwrap();
506        fs::write(root.join("src/lib.rs"), "").unwrap();
507        let insensitive = root.join("SPECCODER.toml").exists();
508        let got = canonical_for_write(&root, "Speccoder.toml").unwrap();
509        let lib = canonical_for_write(&root, "SRC/Lib.rs").unwrap();
510        if insensitive {
511            assert_eq!(got, "speccoder.toml");
512            assert_eq!(lib, "src/lib.rs");
513        } else {
514            assert_eq!(got, "Speccoder.toml");
515            assert_eq!(lib, "SRC/Lib.rs");
516        }
517    }
518
519    #[test]
520    fn a_write_reaches_its_canonical_file() {
521        let dir = tempfile::tempdir().unwrap();
522        let root = fs::canonicalize(dir.path()).unwrap();
523        fs::create_dir_all(root.join("src/b")).unwrap();
524        fs::create_dir_all(root.join("src/a")).unwrap();
525        assert_eq!(
526            canonical_for_write(&root, "./src/a/x.rs").unwrap(),
527            "src/a/x.rs"
528        );
529        // Not there yet: appended as written.
530        assert_eq!(
531            canonical_for_write(&root, "src/new/deep/x.rs").unwrap(),
532            "src/new/deep/x.rs"
533        );
534        // A directory swapped for a symlink inside the root: another file.
535        fs::remove_dir(root.join("src/a")).unwrap();
536        std::os::unix::fs::symlink(root.join("src/b"), root.join("src/a")).unwrap();
537        assert_eq!(
538            canonical_for_write(&root, "src/a/x.rs").unwrap(),
539            "src/b/x.rs"
540        );
541        // An ancestor that leaves the root, and a symlinked target, are refused.
542        let outside = tempfile::tempdir().unwrap();
543        std::os::unix::fs::symlink(outside.path(), root.join("out")).unwrap();
544        assert!(canonical_for_write(&root, "out/x.rs").is_err());
545        fs::write(root.join("src/b/real.rs"), "").unwrap();
546        std::os::unix::fs::symlink(root.join("src/b/real.rs"), root.join("src/b/link.rs")).unwrap();
547        assert!(canonical_for_write(&root, "src/b/link.rs").is_err());
548        assert!(canonical_for_write(&root, "../x").is_err());
549    }
550}