Skip to main content

speccoder_runtime/extensions/
mod.rs

1//! A project's extensions: Agent Plugins 1.0 folders in
2//! `.speccoder/extensions/<name>/` and Spec Kit's installed extensions,
3//! each with speccoder's part in `dev.speccoder/extension.toml`. Loaded as
4//! contributions speccoder already runs — `[hooks]`, hosted skills, MCP
5//! servers — plus views. One digest covers every file of an extension;
6//! nothing of it runs, is offered or reaches a model before a person
7//! trusted that digest ([`trust`]).
8
9pub mod add;
10pub mod report;
11pub mod trust;
12pub mod views;
13
14use std::path::{Path, PathBuf};
15
16use sha2::{Digest as _, Sha256};
17use speccoder_core::extensions::{
18    self as core, AddedFrom, Contribution, ContributionKind, ExtensionSource, ViewDef, ViewSource,
19    hook_contribution, view_contribution,
20};
21use speccoder_core::hooks::{HookDef, HookFile, TrustState};
22
23/// Where Agent Plugins folders live, relative to the project.
24pub const DIR: &str = ".speccoder/extensions";
25/// speccoder's namespace in an Agent Plugins folder.
26pub const NAMESPACE: &str = "dev.speccoder";
27
28const PLUGIN_KEYS: &[&str] = &[
29    "$schema",
30    "name",
31    "version",
32    "description",
33    "author",
34    "homepage",
35    "repository",
36    "license",
37    "keywords",
38    "extensions",
39];
40
41/// An `mcp.json` stdio server, its placeholders not yet expanded.
42#[derive(Debug, Clone, PartialEq, Eq)]
43pub struct Server {
44    /// `<ext>_<server>`.
45    pub name: String,
46    /// `command` then `args`.
47    pub command: Vec<String>,
48    /// Literal variables.
49    pub env: Vec<(String, String)>,
50    /// `cwd`, if given.
51    pub cwd: Option<String>,
52}
53
54/// A Spec Kit hook an extension declares.
55#[derive(Debug, Clone, PartialEq, Eq)]
56pub struct Offer {
57    /// `after_specify`.
58    pub event: String,
59    /// `speckit.threatspec.model`.
60    pub command: String,
61    /// What it asks.
62    pub prompt: Option<String>,
63    /// A mandatory hook.
64    pub mandatory: bool,
65}
66
67/// One extension as loaded.
68#[derive(Debug, Clone)]
69pub struct Loaded {
70    /// Its name.
71    pub name: String,
72    /// Agent Plugins or Spec Kit.
73    pub source: ExtensionSource,
74    /// Its folder.
75    pub folder: PathBuf,
76    /// Its folder, relative to the project.
77    pub rel: String,
78    /// Its version.
79    pub version: Option<String>,
80    /// What it says it is.
81    pub description: Option<String>,
82    /// Where it was added from.
83    pub origin: Option<AddedFrom>,
84    /// `sha256:<hex>` over every file.
85    pub digest: String,
86    /// Every file and its digest.
87    pub files: Vec<HookFile>,
88    /// Its `[hooks]`, named `<ext>.<hook>`.
89    pub hooks: Vec<HookDef>,
90    /// Its `[views]`.
91    pub views: Vec<ViewDef>,
92    /// Its skills: `(<ext>:<skill>, SKILL.md)`.
93    pub skills: Vec<(String, PathBuf)>,
94    /// Its stdio MCP servers.
95    pub servers: Vec<Server>,
96    /// Spec Kit: its commands.
97    pub commands: Vec<String>,
98    /// Spec Kit: its hooks.
99    pub offers: Vec<Offer>,
100    /// Why it cannot be loaded.
101    pub problems: Vec<String>,
102}
103
104impl Loaded {
105    /// Whether nothing keeps it from loading.
106    #[must_use]
107    pub fn usable(&self) -> bool {
108        self.problems.is_empty()
109    }
110
111    /// What it contributes, in words.
112    #[must_use]
113    pub fn contributions(&self) -> Vec<Contribution> {
114        let mut out: Vec<Contribution> = self
115            .hooks
116            .iter()
117            .map(|h| hook_contribution(&h.name, h))
118            .collect();
119        out.extend(
120            self.views
121                .iter()
122                .map(|v| view_contribution(&format!("{}.{}", self.name, v.name), v)),
123        );
124        out.extend(self.skills.iter().map(|(n, _)| Contribution {
125            kind: ContributionKind::Skill,
126            name: format!("/{n}"),
127            line: format!("skill /{n}: a prompt you run from the composer"),
128            reaches: Vec::new(),
129        }));
130        out.extend(self.servers.iter().map(|s| Contribution {
131            kind: ContributionKind::McpServer,
132            name: s.name.clone(),
133            line: format!(
134                "MCP server {}: starts {} in the sandbox; each call asks unless allowed",
135                s.name,
136                core::shown(&s.command)
137            ),
138            reaches: vec![format!("server:{}", s.name)],
139        }));
140        out.extend(self.commands.iter().map(|c| Contribution {
141            kind: ContributionKind::Command,
142            name: c.clone(),
143            line: format!("command {c}: a prompt you run from the palette"),
144            reaches: Vec::new(),
145        }));
146        // One line per command and kind: the events it stands at.
147        let mut seen: Vec<(String, bool)> = Vec::new();
148        for o in &self.offers {
149            if seen.contains(&(o.command.clone(), o.mandatory)) {
150                continue;
151            }
152            seen.push((o.command.clone(), o.mandatory));
153            let events: Vec<&str> = self
154                .offers
155                .iter()
156                .filter(|x| x.command == o.command && x.mandatory == o.mandatory)
157                .map(|x| x.event.as_str())
158                .collect();
159            out.push(Contribution {
160                kind: ContributionKind::HookOffer,
161                name: format!(
162                    "{} {}",
163                    if o.mandatory { "runs" } else { "offers" },
164                    o.command
165                ),
166                line: if o.mandatory {
167                    format!(
168                        "runs {} at {} (Spec Kit hook)",
169                        o.command,
170                        events.join(", ")
171                    )
172                } else {
173                    format!("offers {} at {}", o.command, events.join(", "))
174                },
175                reaches: if o.mandatory {
176                    events.iter().map(|e| format!("hook:{e}")).collect()
177                } else {
178                    Vec::new()
179                },
180            });
181        }
182        out
183    }
184}
185
186/// The project folder of `root`: where `speccoder.toml` is, else `root`.
187#[must_use]
188pub fn project(root: &Path) -> PathBuf {
189    crate::verify::find_config(root).unwrap_or_else(|| root.to_path_buf())
190}
191
192/// Every extension of the project at `root`, by name.
193#[must_use]
194pub fn load(root: &Path) -> Vec<Loaded> {
195    let project = project(root);
196    let mut out = Vec::new();
197    let mut folders: Vec<PathBuf> = std::fs::read_dir(project.join(DIR))
198        .into_iter()
199        .flatten()
200        .flatten()
201        .map(|e| e.path())
202        // A link is listed too: it is refused by name, never followed.
203        .filter(|p| {
204            (p.is_dir() || std::fs::symlink_metadata(p).is_ok_and(|m| m.file_type().is_symlink()))
205                && !p
206                    .file_name()
207                    .and_then(|n| n.to_str())
208                    .is_some_and(|n| n.starts_with('.'))
209        })
210        .collect();
211    folders.sort();
212    for folder in folders {
213        out.push(one(&project, &folder, ExtensionSource::AgentPlugins));
214    }
215    // The format's own extensions, through its adapter.
216    for folder in crate::format::speckit::extensions::folders(&project) {
217        out.push(one(&project, &folder, ExtensionSource::SpecKit));
218    }
219    // An MCP server name taken twice — by another extension, or by a
220    // `[mcp]` server of the project — makes the later one not loadable.
221    let mut servers: Vec<String> = crate::verify::find_config(&project)
222        .and_then(|d| std::fs::read_to_string(d.join(crate::verify::config::FILE)).ok())
223        .and_then(|t| crate::mcp::parse(&t).ok())
224        .unwrap_or_default()
225        .into_iter()
226        .map(|s| s.name)
227        .collect();
228    for e in &mut out {
229        for s in &e.servers {
230            if servers.contains(&s.name) {
231                e.problems.push(format!(
232                    "mcp.json server {}: the name is taken by another MCP server",
233                    s.name
234                ));
235            } else {
236                servers.push(s.name.clone());
237            }
238        }
239    }
240    // A name taken twice: the second is not loadable.
241    for i in 0..out.len() {
242        if let Some(first) = out[..i].iter().find(|e| e.name == out[i].name) {
243            let line = format!("the name `{}` is taken by {}", out[i].name, first.rel);
244            out[i].problems.push(line);
245        }
246    }
247    out
248}
249
250/// The usable extensions a person trusted, as they are now.
251#[must_use]
252pub fn trusted(root: &Path, state: &Path) -> Vec<Loaded> {
253    let dir = crate::hooks::dir(state, root);
254    load(root)
255        .into_iter()
256        .filter(|e| e.usable() && trust::state(&dir, e) == TrustState::Trusted)
257        .collect()
258}
259
260pub(crate) fn one(project: &Path, folder: &Path, source: ExtensionSource) -> Loaded {
261    let rel = folder
262        .strip_prefix(project)
263        .unwrap_or(folder)
264        .to_string_lossy()
265        .replace('\\', "/");
266    let dir_name = folder
267        .file_name()
268        .and_then(|n| n.to_str())
269        .unwrap_or_default()
270        .to_owned();
271    let mut e = Loaded {
272        name: dir_name.clone(),
273        source,
274        folder: folder.to_path_buf(),
275        rel,
276        version: None,
277        description: None,
278        origin: None,
279        digest: String::new(),
280        files: Vec::new(),
281        hooks: Vec::new(),
282        views: Vec::new(),
283        skills: Vec::new(),
284        servers: Vec::new(),
285        commands: Vec::new(),
286        offers: Vec::new(),
287        problems: Vec::new(),
288    };
289    match digest(folder) {
290        Ok((d, files)) => {
291            e.digest = d;
292            e.files = files;
293        }
294        Err(p) => {
295            e.problems.push(p);
296            return e;
297        }
298    }
299    match source {
300        ExtensionSource::AgentPlugins => plugin(&mut e, &dir_name),
301        ExtensionSource::SpecKit => {
302            spec_kit(&mut e);
303            installed_skills(&mut e, project);
304        }
305    }
306    speccoder_part(&mut e);
307    check_paths(&mut e);
308    e
309}
310
311/// Variables an `mcp.json` may not set: they decide which program runs or
312/// what it loads before its own code.
313const LOADER_VARS: &[&str] = &[
314    "PATH",
315    "NODE_OPTIONS",
316    "NODE_PATH",
317    "PYTHONPATH",
318    "PYTHONHOME",
319    "PYTHONSTARTUP",
320    "RUBYOPT",
321    "RUBYLIB",
322    "PERL5OPT",
323    "PERL5LIB",
324    "BASH_ENV",
325    "ENV",
326    "JAVA_TOOL_OPTIONS",
327];
328
329/// Whether `arg` climbs out of where it starts or into `.git`.
330fn escapes(arg: &str) -> bool {
331    arg.split(['/', '\\']).any(|c| c == ".." || c == ".git")
332}
333
334/// What an extension runs is its own: a `./` program is one of its
335/// digested files, no argument climbs out with `..` or into `.git`, and its
336/// MCP servers set no loader variable.
337fn check_paths(e: &mut Loaded) {
338    let own = |p: &str| {
339        let rel = p.trim_start_matches("./");
340        e.files.iter().any(|f| f.path == rel)
341    };
342    let mut problems = Vec::new();
343    let check = |what: &str, argv: &[String], problems: &mut Vec<String>| {
344        if let Some(a) = argv.iter().find(|a| escapes(a)) {
345            problems.push(format!("{what}: `{a}` leaves the extension's folder"));
346        }
347        if let Some(p) = argv.first().filter(|p| p.starts_with("./"))
348            && !own(p)
349        {
350            problems.push(format!(
351                "{what}: runs `{p}`, which is not one of its committed files"
352            ));
353        }
354    };
355    for h in &e.hooks {
356        if let Some(argv) = h.argv() {
357            check(&format!("[hooks.{}]", h.name), argv, &mut problems);
358        }
359    }
360    for v in &e.views {
361        if let ViewSource::Run(argv) = &v.source {
362            check(&format!("[views.{}]", v.name), argv, &mut problems);
363        }
364    }
365    for s in &e.servers {
366        check(
367            &format!("mcp.json server {}", s.name),
368            &s.command,
369            &mut problems,
370        );
371        if s.cwd.as_deref().is_some_and(escapes) {
372            problems.push(format!(
373                "mcp.json server {}: its cwd leaves the extension's folder",
374                s.name
375            ));
376        }
377        if let Some((k, _)) = s.env.iter().find(|(k, _)| {
378            LOADER_VARS.contains(&k.as_str()) || k.starts_with("LD_") || k.starts_with("DYLD_")
379        }) {
380            problems.push(format!(
381                "mcp.json server {}: it may not set {k} (it decides what runs)",
382                s.name
383            ));
384        }
385        if !crate::mcp::valid_server_name(&s.name) {
386            problems.push(format!(
387                "mcp.json server {}: not a valid server name (lowercase letters, digits, single `_`)",
388                s.name
389            ));
390        }
391    }
392    e.problems.extend(problems);
393}
394
395/// `plugin.json`, `skills/`, `mcp.json`.
396fn plugin(e: &mut Loaded, dir_name: &str) {
397    let text = match std::fs::read_to_string(e.folder.join("plugin.json")) {
398        Ok(t) => t,
399        Err(_) => {
400            e.problems
401                .push("no plugin.json (an Agent Plugins 1.0 folder starts with one)".to_owned());
402            return;
403        }
404    };
405    let manifest: serde_json::Value = match serde_json::from_str(&text) {
406        Ok(v) => v,
407        Err(err) => {
408            e.problems.push(format!("plugin.json: {err}"));
409            return;
410        }
411    };
412    let Some(m) = manifest.as_object() else {
413        e.problems.push("plugin.json is not an object".to_owned());
414        return;
415    };
416    let name = m
417        .get("name")
418        .and_then(serde_json::Value::as_str)
419        .unwrap_or_default();
420    if !core::valid_name(name) {
421        e.problems.push(format!(
422            "plugin.json: `name` \"{name}\" is not a valid name (1–64 of a-z, 0-9, `-`, `.`)"
423        ));
424        return;
425    }
426    if name != dir_name {
427        e.problems.push(format!(
428            "plugin.json names `{name}`, but the folder is `{dir_name}`: they must match"
429        ));
430        return;
431    }
432    for (k, v) in m {
433        let is_string = v.is_string();
434        let bad = match k.as_str() {
435            "version" | "description" | "homepage" | "repository" | "license" | "$schema" => {
436                !is_string
437            }
438            "keywords" => !v
439                .as_array()
440                .is_some_and(|a| a.iter().all(serde_json::Value::is_string)),
441            _ => false,
442        };
443        if bad {
444            e.problems
445                .push(format!("plugin.json: `{k}` has the wrong type"));
446        }
447    }
448    // An unknown top-level field is ignored (Agent Plugins §5.2); a type
449    // error in a known one is fatal (above).
450    debug_assert!(PLUGIN_KEYS.contains(&"name"));
451    e.name = name.to_owned();
452    e.version = m.get("version").and_then(|v| v.as_str()).map(str::to_owned);
453    e.description = m
454        .get("description")
455        .and_then(|v| v.as_str())
456        .map(str::to_owned);
457    // skills/<name>/SKILL.md, one level deep.
458    let mut skills: Vec<(String, PathBuf)> = std::fs::read_dir(e.folder.join("skills"))
459        .into_iter()
460        .flatten()
461        .flatten()
462        .filter_map(|d| {
463            let file = d.path().join("SKILL.md");
464            let skill = d.file_name().to_str()?.to_owned();
465            file.is_file().then(|| (format!("{name}:{skill}"), file))
466        })
467        .collect();
468    skills.sort();
469    e.skills = skills;
470    if let Ok(text) = std::fs::read_to_string(e.folder.join("mcp.json")) {
471        mcp(e, &text);
472    }
473}
474
475/// `mcp.json`: stdio servers; remote ones are not supported yet.
476fn mcp(e: &mut Loaded, text: &str) {
477    let v: serde_json::Value = match serde_json::from_str(text) {
478        Ok(v) => v,
479        Err(err) => {
480            e.problems.push(format!("mcp.json: {err}"));
481            return;
482        }
483    };
484    let Some(servers) = v.get("mcpServers").and_then(|s| s.as_object()) else {
485        e.problems.push("mcp.json: no `mcpServers`".to_owned());
486        return;
487    };
488    let placeholders_ok = |s: &str| {
489        let mut rest = s;
490        while let Some(i) = rest.find("${") {
491            let after = &rest[i + 2..];
492            let Some(end) = after.find('}') else {
493                return false;
494            };
495            if !matches!(&after[..end], "PLUGIN_ROOT" | "PLUGIN_DATA") {
496                return false;
497            }
498            rest = &after[end + 1..];
499        }
500        true
501    };
502    for (server, s) in servers {
503        let at = |m: &str| format!("mcp.json server `{server}`: {m}");
504        match s.get("type").and_then(|t| t.as_str()) {
505            Some("stdio") => {}
506            Some(t @ ("streamable-http" | "sse")) => {
507                e.problems.push(at(&format!(
508                    "`{t}` servers are not supported by this version (stdio only)"
509                )));
510                continue;
511            }
512            _ => {
513                e.problems
514                    .push(at("`type` is stdio, streamable-http or sse"));
515                continue;
516            }
517        }
518        let command = s
519            .get("command")
520            .and_then(|c| c.as_str())
521            .unwrap_or_default();
522        let bare = !command.is_empty()
523            && !command.contains("${")
524            && (command.starts_with("./") || !command.contains(['/', '\\']));
525        if !bare {
526            e.problems.push(at(
527                "`command` is a bare program name or a `./` path, without placeholders",
528            ));
529            continue;
530        }
531        let args: Vec<String> = s
532            .get("args")
533            .and_then(|a| a.as_array())
534            .map(|a| {
535                a.iter()
536                    .filter_map(|x| x.as_str().map(str::to_owned))
537                    .collect()
538            })
539            .unwrap_or_default();
540        let env: Vec<(String, String)> = s
541            .get("env")
542            .and_then(|a| a.as_object())
543            .map(|o| {
544                o.iter()
545                    .filter_map(|(k, v)| v.as_str().map(|v| (k.clone(), v.to_owned())))
546                    .collect()
547            })
548            .unwrap_or_default();
549        let cwd = s.get("cwd").and_then(|c| c.as_str()).map(str::to_owned);
550        let all_ok = args.iter().all(|a| placeholders_ok(a))
551            && env
552                .iter()
553                .all(|(k, v)| placeholders_ok(v) && k != "PLUGIN_ROOT" && k != "PLUGIN_DATA")
554            && cwd.as_deref().is_none_or(|c| {
555                placeholders_ok(c)
556                    && (c.starts_with("./")
557                        || c.starts_with("${PLUGIN_ROOT}")
558                        || c.starts_with("${PLUGIN_DATA}"))
559            });
560        if !all_ok {
561            e.problems.push(at(
562                "only ${PLUGIN_ROOT} and ${PLUGIN_DATA} may appear in args, env and cwd",
563            ));
564            continue;
565        }
566        let mut argv = vec![command.to_owned()];
567        argv.extend(args);
568        e.servers.push(Server {
569            name: core::server_name(&e.name, server),
570            command: argv,
571            env,
572            cwd,
573        });
574    }
575}
576
577/// A Spec Kit extension's manifest, read by its adapter.
578fn spec_kit(e: &mut Loaded) {
579    match crate::format::speckit::extensions::manifest(&e.folder) {
580        Ok(m) => {
581            if let Some(id) = m.id {
582                e.name = id;
583            }
584            e.version = m.version;
585            e.description = m.description;
586            e.commands = m.commands;
587            e.offers = m
588                .hooks
589                .into_iter()
590                .map(|(event, command, prompt, mandatory)| Offer {
591                    event,
592                    command,
593                    prompt,
594                    mandatory,
595                })
596                .collect();
597        }
598        Err(err) => e.problems.push(err),
599    }
600}
601
602/// A Spec Kit extension's commands run as the skills Spec Kit installed
603/// for them: those files are part of what is trusted.
604fn installed_skills(e: &mut Loaded, project: &Path) {
605    let mut extra = Vec::new();
606    for c in &e.commands {
607        let skill = crate::format::speckit::extensions::skill_of(c);
608        for base in crate::skills::DIRS {
609            let rel = format!("{base}/{skill}/SKILL.md");
610            let path = project.join(&rel);
611            let Ok(meta) = std::fs::symlink_metadata(&path) else {
612                continue;
613            };
614            if !meta.file_type().is_file() || meta.len() > core::MAX_BYTES {
615                e.problems
616                    .push(format!("{rel} is not a regular file of a sane size"));
617                continue;
618            }
619            let bytes = std::fs::read(&path).unwrap_or_default();
620            extra.push(HookFile {
621                path: rel,
622                digest: format!("sha256:{}", sha256_hex(&bytes)),
623            });
624        }
625    }
626    if extra.is_empty() {
627        return;
628    }
629    e.files.extend(extra);
630    let mut all = Vec::new();
631    for f in &e.files {
632        all.extend_from_slice(f.path.as_bytes());
633        all.push(0);
634        all.extend_from_slice(f.digest.as_bytes());
635        all.push(0);
636    }
637    e.digest = format!("sha256:{}", sha256_hex(&all));
638}
639
640/// `dev.speccoder/extension.toml` and `dev.speccoder/source.toml`.
641fn speccoder_part(e: &mut Loaded) {
642    let part = e.folder.join(NAMESPACE);
643    if let Ok(text) = std::fs::read_to_string(part.join("source.toml"))
644        && let Ok(origin) = toml::from_str::<AddedFrom>(&text)
645    {
646        e.origin = Some(origin);
647    }
648    let Ok(text) = std::fs::read_to_string(part.join("extension.toml")) else {
649        return;
650    };
651    let table: toml::Table = match toml::from_str(&text) {
652        Ok(t) => t,
653        Err(err) => {
654            e.problems
655                .push(format!("{NAMESPACE}/extension.toml: {err}"));
656            return;
657        }
658    };
659    for (key, value) in &table {
660        let Some(entries) = value.as_table() else {
661            e.problems.push(format!(
662                "{NAMESPACE}/extension.toml: `{key}` is not a table (known: hooks, views)"
663            ));
664            continue;
665        };
666        for (name, t) in entries {
667            let Ok(json) = serde_json::to_value(t) else {
668                continue;
669            };
670            match key.as_str() {
671                "hooks" => match speccoder_core::hooks::parse(name, &json) {
672                    Ok(mut def) => {
673                        def.name = format!("{}.{name}", e.name);
674                        def.extension =
675                            Some((e.name.clone(), e.folder.to_string_lossy().into_owned()));
676                        e.hooks.push(def);
677                    }
678                    Err(err) => e.problems.push(err),
679                },
680                "views" => match core::parse_view(name, &json) {
681                    Ok(v) => e.views.push(v),
682                    Err(err) => e.problems.push(err),
683                },
684                other => e.problems.push(format!(
685                    "{NAMESPACE}/extension.toml: unknown table `[{other}]` (known: hooks, views)"
686                )),
687            }
688        }
689    }
690}
691
692fn sha256_hex(bytes: &[u8]) -> String {
693    Sha256::digest(bytes)
694        .iter()
695        .map(|b| format!("{b:02x}"))
696        .collect()
697}
698
699/// The digest of the files of the extension in `folder`, and each file's
700/// own, sorted. In a git checkout these are the files git tracks there —
701/// what a run's worktree holds, so trust and runs see the same bytes;
702/// elsewhere (a folder being added) every file.
703///
704/// # Errors
705///
706/// The folder or anything in it is a link, something in it is not a
707/// regular file, it holds more than 2,000 files or 20 MB, or — in a git
708/// checkout — files not committed or ignored by git sit in it (they would
709/// run without being what was trusted).
710pub fn digest(folder: &Path) -> Result<(String, Vec<HookFile>), String> {
711    let meta =
712        std::fs::symlink_metadata(folder).map_err(|e| format!("{}: {e}", folder.display()))?;
713    if meta.file_type().is_symlink() {
714        return Err(
715            "the extension's folder is a link: an extension is a folder of its own".to_owned(),
716        );
717    }
718    let listed = match git_files(folder)? {
719        Some(files) => files,
720        None => walk(folder, false)?,
721    };
722    if listed.len() > core::MAX_FILES {
723        return Err(format!(
724            "more than {} files: an extension holds at most that many",
725            core::MAX_FILES
726        ));
727    }
728    let mut files = Vec::new();
729    let mut bytes_total: u64 = 0;
730    for rel in listed {
731        let path = folder.join(&rel);
732        let meta = std::fs::symlink_metadata(&path).map_err(|e| format!("{rel}: {e}"))?;
733        if meta.file_type().is_symlink() {
734            return Err(format!("{rel} is a link: an extension holds files only"));
735        }
736        if !meta.file_type().is_file() {
737            return Err(format!("{rel} is not a regular file"));
738        }
739        bytes_total += meta.len();
740        if bytes_total > core::MAX_BYTES {
741            return Err(format!(
742                "more than {} MB: an extension holds at most that much",
743                core::MAX_BYTES / (1024 * 1024)
744            ));
745        }
746        let mut hasher = Sha256::new();
747        let mut f = std::fs::File::open(&path).map_err(|e| format!("{rel}: {e}"))?;
748        let mut chunk = [0u8; 64 * 1024];
749        loop {
750            let n = std::io::Read::read(&mut f, &mut chunk).map_err(|e| format!("{rel}: {e}"))?;
751            if n == 0 {
752                break;
753            }
754            hasher.update(&chunk[..n]);
755        }
756        let hex: String = hasher
757            .finalize()
758            .iter()
759            .map(|b| format!("{b:02x}"))
760            .collect();
761        files.push(HookFile {
762            path: rel,
763            digest: format!("sha256:{hex}"),
764        });
765    }
766    files.sort_by(|a, b| a.path.cmp(&b.path));
767    let mut all = Vec::new();
768    for f in &files {
769        all.extend_from_slice(f.path.as_bytes());
770        all.push(0);
771        all.extend_from_slice(f.digest.as_bytes());
772        all.push(0);
773    }
774    Ok((format!("sha256:{}", sha256_hex(&all)), files))
775}
776
777/// The files git tracks in `folder`, when it is in a git checkout; refused
778/// while files there are not committed or are ignored by git.
779fn git_files(folder: &Path) -> Result<Option<Vec<String>>, String> {
780    let git = |args: &[&str]| {
781        std::process::Command::new("git")
782            .args(["-c", "core.quotePath=off"])
783            .args(args)
784            .current_dir(folder)
785            .env("GIT_CONFIG_NOSYSTEM", "1")
786            .output()
787            .ok()
788            .filter(|o| o.status.success())
789            .map(|o| String::from_utf8_lossy(&o.stdout).into_owned())
790    };
791    let Some(top) = git(&["rev-parse", "--show-toplevel"]) else {
792        return Ok(None);
793    };
794    if top.trim().is_empty() {
795        return Ok(None);
796    }
797    let names = |text: String| -> Vec<String> {
798        text.split('\0')
799            .filter(|s| !s.is_empty())
800            .map(str::to_owned)
801            .collect()
802    };
803    let loose = names(
804        git(&[
805            "ls-files",
806            "-z",
807            "--others",
808            "--exclude-standard",
809            "--",
810            ".",
811        ])
812        .unwrap_or_default(),
813    );
814    let changed = names(
815        git(&["diff", "--name-only", "-z", "--relative", "HEAD", "--", "."]).unwrap_or_default(),
816    );
817    let ignored = names(
818        git(&[
819            "ls-files",
820            "-z",
821            "--others",
822            "--ignored",
823            "--exclude-standard",
824            "--directory",
825            "--",
826            ".",
827        ])
828        .unwrap_or_default(),
829    );
830    let mut pending: Vec<String> = loose.into_iter().chain(changed).collect();
831    pending.sort();
832    pending.dedup();
833    if !pending.is_empty() {
834        return Err(format!(
835            "has changes not committed ({}): commit them — a run uses what is committed",
836            pending.join(", ")
837        ));
838    }
839    if !ignored.is_empty() {
840        return Err(format!(
841            "holds files git ignores ({}): they would run without being trusted — delete them",
842            ignored.join(", ")
843        ));
844    }
845    let tracked = names(git(&["ls-files", "-z", "--", "."]).unwrap_or_default());
846    if tracked.is_empty() {
847        return Err("is not committed: commit it — a run uses what is committed".to_owned());
848    }
849    Ok(Some(tracked))
850}
851
852/// Every entry under `folder`, relative, for a folder outside a checkout;
853/// a link anywhere is refused.
854pub(crate) fn walk(folder: &Path, skip_git: bool) -> Result<Vec<String>, String> {
855    let mut out = Vec::new();
856    let mut stack = vec![folder.to_path_buf()];
857    let mut entries = 0usize;
858    while let Some(dir) = stack.pop() {
859        for entry in std::fs::read_dir(&dir)
860            .map_err(|e| format!("{}: {e}", dir.display()))?
861            .flatten()
862        {
863            entries += 1;
864            if entries > core::MAX_FILES * 2 {
865                return Err(format!(
866                    "more than {} files: an extension holds at most that many",
867                    core::MAX_FILES
868                ));
869            }
870            let path = entry.path();
871            let rel = path
872                .strip_prefix(folder)
873                .unwrap_or(&path)
874                .to_string_lossy()
875                .replace('\\', "/");
876            let kind = entry.file_type().map_err(|e| format!("{rel}: {e}"))?;
877            if kind.is_symlink() {
878                return Err(format!("{rel} is a link: an extension holds files only"));
879            }
880            if kind.is_dir() {
881                if path.file_name().is_some_and(|n| n == ".git") {
882                    if skip_git {
883                        continue;
884                    }
885                    return Err(
886                        ".git inside an extension: add it from its repository instead".to_owned(),
887                    );
888                }
889                stack.push(path);
890            } else {
891                out.push(rel);
892            }
893        }
894    }
895    Ok(out)
896}
897
898/// The folder of `feature` in the project at `root`, if the format lists it.
899#[must_use]
900pub fn feature_dir(root: &Path, feature: &str) -> Option<PathBuf> {
901    let opened = crate::format::open(root, Some(feature)).ok()?;
902    let f = opened
903        .snapshot
904        .features
905        .iter()
906        .find(|f| f.name == feature)?;
907    Some(Path::new(&opened.snapshot.root).join(&f.dir))
908}
909
910/// `argv` with `./` paths inside `folder` (an extension's) and the
911/// placeholders `vars` names expanded: `${NAME}` → value. Nothing else is
912/// expanded.
913#[must_use]
914pub fn resolve(argv: &[String], folder: Option<&Path>, vars: &[(&str, String)]) -> Vec<String> {
915    argv.iter()
916        .enumerate()
917        .map(|(i, a)| {
918            let mut a = a.clone();
919            for (k, v) in vars {
920                a = a.replace(&format!("${{{k}}}"), v);
921            }
922            match folder {
923                Some(f)
924                    if a.starts_with("./")
925                        && !escapes(&a)
926                        && (i == 0 || f.join(&a[2..]).exists()) =>
927                {
928                    f.join(&a[2..]).to_string_lossy().into_owned()
929                }
930                _ => a,
931            }
932        })
933        .collect()
934}
935
936/// A path a tool reported, relative to the project at `project`: as
937/// given when relative and inside, stripped when absolute inside; `None`
938/// when it leaves the project.
939#[must_use]
940pub fn relative_to(project: &Path, path: &str) -> Option<String> {
941    let p = Path::new(path);
942    let rel = if p.is_absolute() {
943        let canonical = std::fs::canonicalize(project).unwrap_or_else(|_| project.to_path_buf());
944        p.strip_prefix(&canonical)
945            .or_else(|_| p.strip_prefix(project))
946            .ok()?
947            .to_path_buf()
948    } else {
949        p.to_path_buf()
950    };
951    let text = rel.to_string_lossy().replace('\\', "/");
952    (!text.is_empty() && !text.split('/').any(|s| s == "..")).then_some(text)
953}
954
955/// The stdio MCP servers of the trusted extensions of the project at
956/// `root`, their placeholders expanded: `${PLUGIN_ROOT}` the extension's
957/// folder, `${PLUGIN_DATA}` a folder of its own in speccoder's state, the
958/// one place it may write besides the worktree.
959#[must_use]
960pub fn servers(root: &Path, state: &Path) -> Vec<crate::mcp::ServerConfig> {
961    let base = crate::worktree::project_state_dir(state, root).join("extensions/data");
962    let mut out = Vec::new();
963    for e in trusted(root, state) {
964        let data = base.join(&e.name);
965        let _ = std::fs::create_dir_all(&data);
966        let vars = [
967            ("PLUGIN_ROOT", e.folder.to_string_lossy().into_owned()),
968            ("PLUGIN_DATA", data.to_string_lossy().into_owned()),
969        ];
970        for s in &e.servers {
971            let expand = |v: &str| {
972                vars.iter()
973                    .fold(v.to_owned(), |a, (k, x)| a.replace(&format!("${{{k}}}"), x))
974            };
975            let mut command = resolve(&s.command[..1], Some(&e.folder), &[]);
976            command.extend(s.command[1..].iter().map(|a| expand(a)));
977            out.push(crate::mcp::ServerConfig {
978                name: s.name.clone(),
979                command,
980                allow: Vec::new(),
981                deny: Vec::new(),
982                env: s.env.iter().map(|(k, v)| (k.clone(), expand(v))).collect(),
983                // In the worktree, as a `[mcp]` server, unless it names one.
984                cwd: s.cwd.as_deref().map(|c| {
985                    let c = expand(c);
986                    c.strip_prefix("./")
987                        .map_or_else(|| PathBuf::from(&c), |r| e.folder.join(r))
988                }),
989                data: Some(data.clone()),
990            });
991        }
992    }
993    out
994}
995
996/// The journey steps at which a Spec Kit hook on `event` is offered: what
997/// it follows is done, what it precedes is next.
998#[must_use]
999pub fn offered_at(event: &str) -> &'static [&'static str] {
1000    match event {
1001        "after_specify" | "after_clarify" | "before_plan" => &["plan"],
1002        "after_plan" | "before_tasks" => &["tasks"],
1003        "after_tasks" | "before_implement" => &["commit", "run"],
1004        "after_implement" | "before_converge" | "after_converge" => &["review"],
1005        _ => &[],
1006    }
1007}