speccoder-runtime 0.1.0

Application services of speccoder: the only crate that executes anything.
Documentation
//! Path rules for tools: normalisation, containment,
//! secrets, and the protected set the model may never write.

use crate::format::Rules;
use std::fs;
use std::path::{Component, Path, PathBuf};

use globset::{Glob, GlobSet, GlobSetBuilder};

/// A path refused, with the reason the model is told.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Refused(pub String);

/// `a/./b/` → `a/b`; refuses absolute paths, `..`, NUL and the empty path.
pub fn normalise(rel: &str) -> Result<String, Refused> {
    if rel.contains('\0') {
        return Err(Refused("path contains NUL".to_owned()));
    }
    let mut parts = Vec::new();
    for c in Path::new(rel).components() {
        match c {
            Component::Normal(p) => parts.push(p.to_string_lossy().into_owned()),
            Component::CurDir => {}
            _ => {
                return Err(Refused(format!(
                    "`{rel}` must be relative to the workspace and must not use `..`"
                )));
            }
        }
    }
    if parts.is_empty() {
        return Err(Refused("empty path".to_owned()));
    }
    Ok(parts.join("/"))
}

fn set(patterns: &[&str]) -> GlobSet {
    let mut b = GlobSetBuilder::new();
    for p in patterns {
        if let Ok(g) = Glob::new(p) {
            b.add(g);
        }
    }
    b.build().unwrap_or_else(|_| GlobSet::empty())
}

/// Files the model may neither read nor write.
const SECRETS: &[&str] = &[
    ".env",
    "**/.env",
    ".env.*",
    "**/.env.*",
    "**/*.pem",
    "**/*.key",
    "**/id_rsa*",
    "**/id_ed25519*",
    ".ssh/**",
    "**/.ssh/**",
    "**/.netrc",
    "**/.npmrc",
    "**/.pypirc",
];

/// Inputs that decide verification.
const JUDGE: &[&str] = &[
    "speccoder.toml",
    "**/speccoder.toml",
    ".github/**",
    ".gitlab-ci.yml",
    ".cargo/config.toml",
    ".cargo/config",
    "nextest.toml",
    ".config/nextest.toml",
    "pytest.ini",
    "tox.ini",
    "**/jest.config.*",
    "**/vitest.config.*",
    "deny.toml",
    "justfile",
    "Makefile",
    // Agent configuration: hooks, settings, skills, commands and MCP servers
    // an agent runs or loads. Spec Kit's runtime events render extension
    // hooks into these files; a change here runs code in a later session,
    // outside speccoder's sandbox.
    ".claude/**",
    ".agents/**",
    ".codex/**",
    ".gemini/**",
    ".opencode/**",
    ".cursor/**",
    ".kiro/**",
    ".windsurf/**",
    ".roo/**",
    ".qwen/**",
    ".kilocode/**",
    ".augment/**",
    ".amazonq/**",
    ".codebuddy/**",
    ".qoder/**",
    ".mcp.json",
    "**/.mcp.json",
    "opencode.json",
    "opencode.jsonc",
];

/// Agent context files: instructions every agent session reads. Editing them
/// changes what later attempts are told, so it needs a person.
const CONTEXT: &[&str] = &[
    "AGENTS.md",
    "**/AGENTS.md",
    "CLAUDE.md",
    "**/CLAUDE.md",
    "GEMINI.md",
    "**/GEMINI.md",
];

/// Dependency manifests: editable only with approval.
const MANIFESTS: &[&str] = &[
    "**/Cargo.toml",
    "**/package.json",
    "**/pyproject.toml",
    "**/requirements*.txt",
    "**/go.mod",
    "**/Gemfile",
    "**/pom.xml",
    "**/build.gradle",
    "**/build.gradle.kts",
];

/// Test files by convention.
const TESTS: &[&str] = &[
    "tests/**",
    "**/tests/**",
    "test/**",
    "**/test/**",
    "**/__tests__/**",
    "**/*_test.*",
    "**/test_*.py",
    "**/*.test.*",
    "**/*.spec.*",
    "**/*Test.java",
    "**/*_spec.rb",
];

/// Whether a normalised path is a test file by convention.
#[must_use]
pub fn is_test_path(rel: &str) -> bool {
    static SET: std::sync::OnceLock<GlobSet> = std::sync::OnceLock::new();
    SET.get_or_init(|| set(TESTS)).is_match(rel)
}

/// The file a person's spec review is recorded in, in a unit's directory
///. The model never writes it — not while implementing, not while
/// running a planning skill: an agent must not approve its own spec.
pub const REVIEW_FILE: &str = "review.json";

/// Why a path is protected.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Class {
    /// The spec format's intent: the spec the work is judged against.
    Intent,
    /// Verification inputs.
    Judge,
    /// Git internals.
    Git,
    /// Credentials.
    Secret,
}

impl Class {
    /// What the model is told, for a project in the reference format
    /// ([`Protected::reason`] for the project's own).
    #[must_use]
    pub fn reason(self) -> &'static str {
        self.reason_with(crate::format::reference().rules())
    }

    fn reason_with(self, rules: &Rules) -> &'static str {
        match self {
            Self::Intent => rules.intent_reason,
            Self::Judge => {
                "it decides verification or configures an agent (speccoder.toml, CI and test-runner configuration, agent hooks, settings, skills and MCP servers)"
            }
            Self::Git => "it is git's own data",
            Self::Secret => "it looks like a credential",
        }
    }
}

/// The protected set, compiled once.
#[derive(Debug, Clone)]
pub struct Protected {
    rules: &'static Rules,
    secrets: GlobSet,
    intent: GlobSet,
    judge: GlobSet,
    context: GlobSet,
    manifests: GlobSet,
}

impl Default for Protected {
    fn default() -> Self {
        Self::new(crate::format::reference().rules())
    }
}

impl Protected {
    /// The protected set of a project whose format has `rules`.
    #[must_use]
    pub fn new(rules: &'static Rules) -> Self {
        Self {
            rules,
            secrets: set(SECRETS),
            intent: set(rules.intent),
            judge: set(JUDGE),
            context: set(CONTEXT),
            manifests: set(MANIFESTS),
        }
    }

    /// The format's rules this set was built from.
    #[must_use]
    pub fn rules(&self) -> &'static Rules {
        self.rules
    }

    /// What the model is told about a write to a path of `class`.
    #[must_use]
    pub fn reason(&self, class: Class) -> &'static str {
        class.reason_with(self.rules)
    }

    /// Whether a normalised path is a review record: an intent path named
    /// [`REVIEW_FILE`]. A planning skill writes intent; never this.
    #[must_use]
    pub fn is_review(&self, rel: &str) -> bool {
        self.intent.is_match(rel) && rel.rsplit('/').next() == Some(REVIEW_FILE)
    }

    /// Whether a normalised path is a secret (refused for reading too).
    #[must_use]
    pub fn is_secret(&self, rel: &str) -> bool {
        self.secrets.is_match(rel)
    }

    /// The protected class a normalised path belongs to, for writing.
    #[must_use]
    pub fn write_class(&self, rel: &str) -> Option<Class> {
        if rel == ".git" || rel.starts_with(".git/") || rel.contains("/.git/") {
            Some(Class::Git)
        } else if self.is_secret(rel) {
            Some(Class::Secret)
        } else if self.intent.is_match(rel) {
            Some(Class::Intent)
        } else if self.judge.is_match(rel) {
            Some(Class::Judge)
        } else {
            None
        }
    }

    /// Whether a normalised path is an agent context file (`AGENTS.md`,
    /// `CLAUDE.md`, `GEMINI.md`): writable only with a person's approval.
    #[must_use]
    pub fn is_context(&self, rel: &str) -> bool {
        self.write_class(rel).is_none() && self.context.is_match(rel)
    }

    /// Whether a normalised path is a judge or intent input: what a command
    /// must not change while an attempt runs.
    #[must_use]
    pub fn is_judged(&self, rel: &str) -> bool {
        matches!(self.write_class(rel), Some(Class::Judge | Class::Intent))
    }

    /// Whether writing this path changes dependencies (needs approval).
    #[must_use]
    pub fn is_manifest(&self, rel: &str) -> bool {
        self.manifests.is_match(rel)
    }
}

/// An existing path inside `root` (symlinks resolved), for reading.
pub fn resolve_existing(root: &Path, rel: &str) -> Result<PathBuf, Refused> {
    let rel = normalise(rel)?;
    let full = root.join(&rel);
    let canonical =
        fs::canonicalize(&full).map_err(|_| Refused(format!("`{rel}` does not exist")))?;
    if canonical.starts_with(root) {
        Ok(canonical)
    } else {
        Err(Refused(format!("`{rel}` resolves outside the workspace")))
    }
}

/// A path inside `root` to write: every existing ancestor must resolve
/// inside the root, and the target itself must not be a symlink.
pub fn resolve_for_write(root: &Path, rel: &str) -> Result<PathBuf, Refused> {
    let rel = normalise(rel)?;
    let full = root.join(&rel);
    if fs::symlink_metadata(&full).is_ok_and(|m| m.file_type().is_symlink()) {
        return Err(Refused(format!(
            "`{rel}` is a symlink; not written through"
        )));
    }
    let mut ancestor = full.parent();
    while let Some(a) = ancestor {
        if a.exists() {
            let canonical = fs::canonicalize(a).map_err(|e| Refused(format!("`{rel}`: {e}")))?;
            if !canonical.starts_with(root) {
                return Err(Refused(format!("`{rel}` resolves outside the workspace")));
            }
            break;
        }
        ancestor = a.parent();
    }
    Ok(full)
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn normalises_and_refuses_escapes() {
        assert_eq!(normalise("./src//lib.rs").unwrap(), "src/lib.rs");
        assert_eq!(normalise("src/").unwrap(), "src");
        assert!(normalise("../x").is_err());
        assert!(normalise("/etc/passwd").is_err());
        assert!(normalise("a/../../b").is_err());
        assert!(normalise("").is_err());
    }

    #[test]
    fn classifies_the_protected_set() {
        let p = Protected::default();
        for (path, class) in [
            ("specs/001-x/tasks.md", Class::Intent),
            (".specify/memory/constitution.md", Class::Intent),
            ("speccoder.toml", Class::Judge),
            (".github/workflows/ci.yml", Class::Judge),
            (".config/nextest.toml", Class::Judge),
            ("web/jest.config.ts", Class::Judge),
            (".git/config", Class::Git),
            (".env", Class::Secret),
            ("deploy/.env.production", Class::Secret),
            ("certs/server.key", Class::Secret),
        ] {
            assert_eq!(p.write_class(path), Some(class), "{path}");
        }
        for agent in [
            ".claude/settings.json",
            ".claude/settings.local.json",
            ".claude/skills/x/SKILL.md",
            ".agents/skills/y/SKILL.md",
            ".codex/config.toml",
            ".kiro/hooks/a.json",
            ".mcp.json",
            "web/.mcp.json",
            "opencode.json",
        ] {
            assert_eq!(p.write_class(agent), Some(Class::Judge), "{agent}");
        }
        for context in ["AGENTS.md", "web/AGENTS.md", "CLAUDE.md", "GEMINI.md"] {
            assert_eq!(p.write_class(context), None, "{context}");
            assert!(p.is_context(context), "{context}");
        }
        assert!(!p.is_context("README.md") && !p.is_context(".specify/memory/constitution.md"));
        for free in ["src/lib.rs", "tests/api.rs", "docs/specs.md", "README.md"] {
            assert_eq!(p.write_class(free), None, "{free}");
        }
        assert!(p.is_manifest("Cargo.toml") && p.is_manifest("crates/x/Cargo.toml"));
        assert!(p.is_manifest("web/package.json") && !p.is_manifest("src/main.rs"));
        for t in [
            "tests/api.rs",
            "crates/x/tests/it.rs",
            "app/test_login.py",
            "web/a.test.ts",
            "web/__tests__/b.js",
            "pkg/x_test.go",
        ] {
            assert!(is_test_path(t), "{t}");
        }
        for f in ["src/lib.rs", "src/testing.rs", "docs/tests.md"] {
            assert!(!is_test_path(f), "{f}");
        }
    }
}