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