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::{Glob, 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
37fn set(patterns: &[&str]) -> GlobSet {
38    let mut b = GlobSetBuilder::new();
39    for p in patterns {
40        if let Ok(g) = Glob::new(p) {
41            b.add(g);
42        }
43    }
44    b.build().unwrap_or_else(|_| GlobSet::empty())
45}
46
47/// Files the model may neither read nor write.
48const SECRETS: &[&str] = &[
49    ".env",
50    "**/.env",
51    ".env.*",
52    "**/.env.*",
53    "**/*.pem",
54    "**/*.key",
55    "**/id_rsa*",
56    "**/id_ed25519*",
57    ".ssh/**",
58    "**/.ssh/**",
59    "**/.netrc",
60    "**/.npmrc",
61    "**/.pypirc",
62];
63
64/// Inputs that decide verification.
65const JUDGE: &[&str] = &[
66    "speccoder.toml",
67    "**/speccoder.toml",
68    ".github/**",
69    ".gitlab-ci.yml",
70    ".cargo/config.toml",
71    ".cargo/config",
72    "nextest.toml",
73    ".config/nextest.toml",
74    "pytest.ini",
75    "tox.ini",
76    "**/jest.config.*",
77    "**/vitest.config.*",
78    "deny.toml",
79    "justfile",
80    "Makefile",
81    // Agent configuration: hooks, settings, skills, commands and MCP servers
82    // an agent runs or loads. Spec Kit's runtime events render extension
83    // hooks into these files; a change here runs code in a later session,
84    // outside speccoder's sandbox.
85    ".claude/**",
86    ".agents/**",
87    ".codex/**",
88    ".gemini/**",
89    ".opencode/**",
90    ".cursor/**",
91    ".kiro/**",
92    ".windsurf/**",
93    ".roo/**",
94    ".qwen/**",
95    ".kilocode/**",
96    ".augment/**",
97    ".amazonq/**",
98    ".codebuddy/**",
99    ".qoder/**",
100    ".mcp.json",
101    "**/.mcp.json",
102    "opencode.json",
103    "opencode.jsonc",
104];
105
106/// Agent context files: instructions every agent session reads. Editing them
107/// changes what later attempts are told, so it needs a person.
108const CONTEXT: &[&str] = &[
109    "AGENTS.md",
110    "**/AGENTS.md",
111    "CLAUDE.md",
112    "**/CLAUDE.md",
113    "GEMINI.md",
114    "**/GEMINI.md",
115];
116
117/// Dependency manifests: editable only with approval.
118const MANIFESTS: &[&str] = &[
119    "**/Cargo.toml",
120    "**/package.json",
121    "**/pyproject.toml",
122    "**/requirements*.txt",
123    "**/go.mod",
124    "**/Gemfile",
125    "**/pom.xml",
126    "**/build.gradle",
127    "**/build.gradle.kts",
128];
129
130/// Test files by convention.
131const TESTS: &[&str] = &[
132    "tests/**",
133    "**/tests/**",
134    "test/**",
135    "**/test/**",
136    "**/__tests__/**",
137    "**/*_test.*",
138    "**/test_*.py",
139    "**/*.test.*",
140    "**/*.spec.*",
141    "**/*Test.java",
142    "**/*_spec.rb",
143];
144
145/// Whether a normalised path is a test file by convention.
146#[must_use]
147pub fn is_test_path(rel: &str) -> bool {
148    static SET: std::sync::OnceLock<GlobSet> = std::sync::OnceLock::new();
149    SET.get_or_init(|| set(TESTS)).is_match(rel)
150}
151
152/// The file a person's spec review is recorded in, in a unit's directory
153///. The model never writes it — not while implementing, not while
154/// running a planning skill: an agent must not approve its own spec.
155pub const REVIEW_FILE: &str = "review.json";
156
157/// Why a path is protected.
158#[derive(Debug, Clone, Copy, PartialEq, Eq)]
159pub enum Class {
160    /// The spec format's intent: the spec the work is judged against.
161    Intent,
162    /// Verification inputs.
163    Judge,
164    /// Git internals.
165    Git,
166    /// Credentials.
167    Secret,
168}
169
170impl Class {
171    /// What the model is told, for a project in the reference format
172    /// ([`Protected::reason`] for the project's own).
173    #[must_use]
174    pub fn reason(self) -> &'static str {
175        self.reason_with(crate::format::reference().rules())
176    }
177
178    fn reason_with(self, rules: &Rules) -> &'static str {
179        match self {
180            Self::Intent => rules.intent_reason,
181            Self::Judge => {
182                "it decides verification or configures an agent (speccoder.toml, CI and test-runner configuration, agent hooks, settings, skills and MCP servers)"
183            }
184            Self::Git => "it is git's own data",
185            Self::Secret => "it looks like a credential",
186        }
187    }
188}
189
190/// The protected set, compiled once.
191#[derive(Debug, Clone)]
192pub struct Protected {
193    rules: &'static Rules,
194    secrets: GlobSet,
195    intent: GlobSet,
196    judge: GlobSet,
197    context: GlobSet,
198    manifests: GlobSet,
199}
200
201impl Default for Protected {
202    fn default() -> Self {
203        Self::new(crate::format::reference().rules())
204    }
205}
206
207impl Protected {
208    /// The protected set of a project whose format has `rules`.
209    #[must_use]
210    pub fn new(rules: &'static Rules) -> Self {
211        Self {
212            rules,
213            secrets: set(SECRETS),
214            intent: set(rules.intent),
215            judge: set(JUDGE),
216            context: set(CONTEXT),
217            manifests: set(MANIFESTS),
218        }
219    }
220
221    /// The format's rules this set was built from.
222    #[must_use]
223    pub fn rules(&self) -> &'static Rules {
224        self.rules
225    }
226
227    /// What the model is told about a write to a path of `class`.
228    #[must_use]
229    pub fn reason(&self, class: Class) -> &'static str {
230        class.reason_with(self.rules)
231    }
232
233    /// Whether a normalised path is a review record: an intent path named
234    /// [`REVIEW_FILE`]. A planning skill writes intent; never this.
235    #[must_use]
236    pub fn is_review(&self, rel: &str) -> bool {
237        self.intent.is_match(rel) && rel.rsplit('/').next() == Some(REVIEW_FILE)
238    }
239
240    /// Whether a normalised path is a secret (refused for reading too).
241    #[must_use]
242    pub fn is_secret(&self, rel: &str) -> bool {
243        self.secrets.is_match(rel)
244    }
245
246    /// The protected class a normalised path belongs to, for writing.
247    #[must_use]
248    pub fn write_class(&self, rel: &str) -> Option<Class> {
249        if rel == ".git" || rel.starts_with(".git/") || rel.contains("/.git/") {
250            Some(Class::Git)
251        } else if self.is_secret(rel) {
252            Some(Class::Secret)
253        } else if self.intent.is_match(rel) {
254            Some(Class::Intent)
255        } else if self.judge.is_match(rel) {
256            Some(Class::Judge)
257        } else {
258            None
259        }
260    }
261
262    /// Whether a normalised path is an agent context file (`AGENTS.md`,
263    /// `CLAUDE.md`, `GEMINI.md`): writable only with a person's approval.
264    #[must_use]
265    pub fn is_context(&self, rel: &str) -> bool {
266        self.write_class(rel).is_none() && self.context.is_match(rel)
267    }
268
269    /// Whether a normalised path is a judge or intent input: what a command
270    /// must not change while an attempt runs.
271    #[must_use]
272    pub fn is_judged(&self, rel: &str) -> bool {
273        matches!(self.write_class(rel), Some(Class::Judge | Class::Intent))
274    }
275
276    /// Whether writing this path changes dependencies (needs approval).
277    #[must_use]
278    pub fn is_manifest(&self, rel: &str) -> bool {
279        self.manifests.is_match(rel)
280    }
281}
282
283/// An existing path inside `root` (symlinks resolved), for reading.
284pub fn resolve_existing(root: &Path, rel: &str) -> Result<PathBuf, Refused> {
285    let rel = normalise(rel)?;
286    let full = root.join(&rel);
287    let canonical =
288        fs::canonicalize(&full).map_err(|_| Refused(format!("`{rel}` does not exist")))?;
289    if canonical.starts_with(root) {
290        Ok(canonical)
291    } else {
292        Err(Refused(format!("`{rel}` resolves outside the workspace")))
293    }
294}
295
296/// A path inside `root` to write: every existing ancestor must resolve
297/// inside the root, and the target itself must not be a symlink.
298pub fn resolve_for_write(root: &Path, rel: &str) -> Result<PathBuf, Refused> {
299    let rel = normalise(rel)?;
300    let full = root.join(&rel);
301    if fs::symlink_metadata(&full).is_ok_and(|m| m.file_type().is_symlink()) {
302        return Err(Refused(format!(
303            "`{rel}` is a symlink; not written through"
304        )));
305    }
306    let mut ancestor = full.parent();
307    while let Some(a) = ancestor {
308        if a.exists() {
309            let canonical = fs::canonicalize(a).map_err(|e| Refused(format!("`{rel}`: {e}")))?;
310            if !canonical.starts_with(root) {
311                return Err(Refused(format!("`{rel}` resolves outside the workspace")));
312            }
313            break;
314        }
315        ancestor = a.parent();
316    }
317    Ok(full)
318}
319
320#[cfg(test)]
321mod tests {
322    use super::*;
323
324    #[test]
325    fn normalises_and_refuses_escapes() {
326        assert_eq!(normalise("./src//lib.rs").unwrap(), "src/lib.rs");
327        assert_eq!(normalise("src/").unwrap(), "src");
328        assert!(normalise("../x").is_err());
329        assert!(normalise("/etc/passwd").is_err());
330        assert!(normalise("a/../../b").is_err());
331        assert!(normalise("").is_err());
332    }
333
334    #[test]
335    fn classifies_the_protected_set() {
336        let p = Protected::default();
337        for (path, class) in [
338            ("specs/001-x/tasks.md", Class::Intent),
339            (".specify/memory/constitution.md", Class::Intent),
340            ("speccoder.toml", Class::Judge),
341            (".github/workflows/ci.yml", Class::Judge),
342            (".config/nextest.toml", Class::Judge),
343            ("web/jest.config.ts", Class::Judge),
344            (".git/config", Class::Git),
345            (".env", Class::Secret),
346            ("deploy/.env.production", Class::Secret),
347            ("certs/server.key", Class::Secret),
348        ] {
349            assert_eq!(p.write_class(path), Some(class), "{path}");
350        }
351        for agent in [
352            ".claude/settings.json",
353            ".claude/settings.local.json",
354            ".claude/skills/x/SKILL.md",
355            ".agents/skills/y/SKILL.md",
356            ".codex/config.toml",
357            ".kiro/hooks/a.json",
358            ".mcp.json",
359            "web/.mcp.json",
360            "opencode.json",
361        ] {
362            assert_eq!(p.write_class(agent), Some(Class::Judge), "{agent}");
363        }
364        for context in ["AGENTS.md", "web/AGENTS.md", "CLAUDE.md", "GEMINI.md"] {
365            assert_eq!(p.write_class(context), None, "{context}");
366            assert!(p.is_context(context), "{context}");
367        }
368        assert!(!p.is_context("README.md") && !p.is_context(".specify/memory/constitution.md"));
369        for free in ["src/lib.rs", "tests/api.rs", "docs/specs.md", "README.md"] {
370            assert_eq!(p.write_class(free), None, "{free}");
371        }
372        assert!(p.is_manifest("Cargo.toml") && p.is_manifest("crates/x/Cargo.toml"));
373        assert!(p.is_manifest("web/package.json") && !p.is_manifest("src/main.rs"));
374        for t in [
375            "tests/api.rs",
376            "crates/x/tests/it.rs",
377            "app/test_login.py",
378            "web/a.test.ts",
379            "web/__tests__/b.js",
380            "pkg/x_test.go",
381        ] {
382            assert!(is_test_path(t), "{t}");
383        }
384        for f in ["src/lib.rs", "src/testing.rs", "docs/tests.md"] {
385            assert!(!is_test_path(f), "{f}");
386        }
387    }
388}