Skip to main content

a3s_code_core/config/
agent_dir.rs

1//! Filesystem-first agent directory convention (harness-respecting).
2//!
3//! A single directory defines a durable agent by convention:
4//!
5//! ```text
6//! agent/
7//! ├── instructions.md   (required)  role/guidelines — injected as a prompt SLOT,
8//! │                                 NOT a system-prompt override, so the harness
9//! │                                 keeps BOUNDARIES, response-format, and
10//! │                                 verification authoritative.
11//! ├── agent.acl          (optional)  model/providers/queue (CodeConfig). Default if absent.
12//! ├── skills/            (optional)  *.md skills, appended to CodeConfig.skill_dirs.
13//! ├── schedules/         (optional)  *.md cron jobs (YAML frontmatter `cron:` + body=prompt).
14//! └── tools/             (optional)  *.md tool specs: `kind: mcp` → MCP server,
15//! │                                 `kind: script` → sandboxed QuickJS tool. Both
16//! │                                 register into the session as ordinary tools.
17//! ```
18//!
19//! [`AgentDir::load`] SYNTHESIZES existing config objects rather than adding a new
20//! runtime: `instructions.md` → [`SystemPromptSlots`], `agent.acl` → [`CodeConfig`],
21//! `skills/` → `skill_dirs`. Tool definition, visibility, and safety stay
22//! harness-owned (the deliberate divergence from user-defined-tools models).
23
24use std::path::{Path, PathBuf};
25
26use crate::config::CodeConfig;
27use crate::error::{CodeError, Result};
28use crate::mcp::McpServerConfig;
29use crate::prompts::SystemPromptSlots;
30
31/// A cron-triggered recurring turn, parsed from `schedules/<name>.md`.
32#[derive(Debug, Clone, PartialEq)]
33pub struct ScheduleSpec {
34    /// Schedule name (frontmatter `name`, else the file stem).
35    pub name: String,
36    /// Cron expression (validated/executed by the serve layer).
37    pub cron: String,
38    /// Markdown prompt sent into a turn on each fire (the file body).
39    pub prompt: String,
40    /// Whether the schedule is active (frontmatter `enabled`, default true).
41    pub enabled: bool,
42}
43
44/// A tool definition parsed from `tools/<name>.md`, dispatched by `kind`.
45///
46/// Tool *definition* may come from the directory, but visibility and safety stay
47/// harness-owned (a deliberate divergence from user-defined-tools models): an `mcp` spec is registered
48/// through the normal [`add_mcp_server`](crate::AgentSession) path, so its tools
49/// are namespaced `mcp__<server>__<tool>` and gated by the session's permission
50/// policy like any other tool.
51#[derive(Debug, Clone)]
52pub enum ToolSpec {
53    /// `kind = "mcp"` → an MCP server connected into the session, contributing its
54    /// `list_tools()` as `mcp__<name>__*` tools.
55    Mcp(McpServerConfig),
56    /// `kind = "script"` → a sandboxed QuickJS tool over the existing `program`
57    /// path. The model sees a named tool; the script `path`, allow-list, and
58    /// limits are pinned by the spec.
59    Script(ScriptToolSpec),
60}
61
62impl ToolSpec {
63    /// The tool/server name (registry key; unique within `tools/`).
64    pub fn name(&self) -> &str {
65        match self {
66            ToolSpec::Mcp(cfg) => &cfg.name,
67            ToolSpec::Script(spec) => &spec.name,
68        }
69    }
70
71    /// The spec kind discriminant (`mcp` or `script`).
72    pub fn kind(&self) -> &str {
73        match self {
74            ToolSpec::Mcp(_) => "mcp",
75            ToolSpec::Script(_) => "script",
76        }
77    }
78}
79
80/// A sandboxed QuickJS tool parsed from a `kind = "script"` file. Names a
81/// workspace-relative `.js`/`.mjs` source and pins the sandbox allow-list +
82/// limits; the model supplies only `inputs`. Executed via the existing `program`
83/// tool path — no new sandbox. The model's call to it is permission-gated like any
84/// tool; the script's inner `ctx.tool` calls are bounded by `allowed_tools` + the
85/// sandbox. Session executions additionally re-apply the governed tool policy to
86/// every inner call, so the allow-list is a second fail-closed boundary.
87#[derive(Debug, Clone)]
88pub struct ScriptToolSpec {
89    /// Model-visible tool name (registry key; unique within `tools/`).
90    pub name: String,
91    /// Model-facing description (frontmatter `description`, else the file body).
92    pub description: String,
93    /// Workspace-relative path to the `.js`/`.mjs` source.
94    pub path: PathBuf,
95    /// Tools the script may call through `ctx`. The agent-dir loader fails closed:
96    /// an omitted list becomes `Some(vec![])` (the script may call NO tools), so a
97    /// directory author must opt each tool in explicitly. `program` is always
98    /// excluded (no script-launches-script). The session's governed invoker still
99    /// applies permission/HITL and the remaining lifecycle guards to every allowed
100    /// inner call, so this list is an additional fail-closed boundary.
101    pub allowed_tools: Option<Vec<String>>,
102    /// Sandbox limits (timeout / tool-call / output caps); defaults apply when unset.
103    pub limits: ScriptToolLimits,
104}
105
106/// Sandbox limits for a `kind = "script"` tool. Mirrors the three numeric fields
107/// the `program` tool's `ScriptLimits` accepts and is serialized to it verbatim
108/// (camelCase keys), so no new limit machinery is introduced.
109#[derive(Debug, Clone, Default, serde::Serialize, serde::Deserialize)]
110#[serde(rename_all = "camelCase")]
111pub struct ScriptToolLimits {
112    #[serde(default, skip_serializing_if = "Option::is_none")]
113    pub timeout_ms: Option<u64>,
114    #[serde(default, skip_serializing_if = "Option::is_none")]
115    pub max_tool_calls: Option<usize>,
116    #[serde(default, skip_serializing_if = "Option::is_none")]
117    pub max_output_bytes: Option<usize>,
118}
119
120/// A loaded agent directory: synthesized [`CodeConfig`] + prompt slots + parsed
121/// schedule + tool specs. Build a session from `config` + `prompt_slots`.
122///
123/// Distinct from [`CodeConfig::agent_dirs`](crate::config::CodeConfig) /
124/// `register_agent_dir`, which scan a directory for **worker/subagent**
125/// definitions. An `AgentDir` is the filesystem-first *primary* agent — the directory
126/// that defines this agent's prompt, skills, schedules, and tools.
127#[derive(Debug, Clone)]
128pub struct AgentDir {
129    pub dir: PathBuf,
130    pub config: CodeConfig,
131    pub prompt_slots: SystemPromptSlots,
132    pub schedules: Vec<ScheduleSpec>,
133    pub tools: Vec<ToolSpec>,
134}
135
136impl AgentDir {
137    /// Load an agent directory by convention. `instructions.md` is required.
138    pub fn load(dir: impl AsRef<Path>) -> Result<Self> {
139        let dir = dir.as_ref().to_path_buf();
140        if !dir.is_dir() {
141            return Err(CodeError::Context(format!(
142                "agent directory not found: {}",
143                dir.display()
144            )));
145        }
146
147        // instructions.md (required) → role SLOT. Using a slot (not a raw system
148        // prompt) keeps the harness's BOUNDARIES/response-format/verification.
149        let instructions = std::fs::read_to_string(dir.join("instructions.md")).map_err(|e| {
150            CodeError::Context(format!(
151                "agent dir {} is missing required instructions.md: {e}",
152                dir.display()
153            ))
154        })?;
155        let prompt_slots = SystemPromptSlots {
156            role: Some(instructions.trim().to_string()),
157            ..Default::default()
158        };
159
160        // agent.acl (optional) → CodeConfig, else default.
161        let acl_path = dir.join("agent.acl");
162        let mut config = if acl_path.is_file() {
163            CodeConfig::from_file(&acl_path)?
164        } else {
165            CodeConfig::default()
166        };
167
168        // skills/ → appended to skill_dirs (existing *.md format, zero adaptation).
169        let skills_dir = dir.join("skills");
170        if skills_dir.is_dir() {
171            config.skill_dirs.push(skills_dir);
172        }
173
174        let schedules = load_schedules(&dir.join("schedules"))?;
175        let tools = load_tools(&dir.join("tools"))?;
176
177        Ok(Self {
178            dir,
179            config,
180            prompt_slots,
181            schedules,
182            tools,
183        })
184    }
185}
186
187/// Markdown files with a `<ext>` extension in `dir`, sorted by path. Returns an
188/// empty list when `dir` does not exist.
189fn md_files(dir: &Path, exts: &[&str]) -> Result<Vec<PathBuf>> {
190    if !dir.is_dir() {
191        return Ok(Vec::new());
192    }
193    let entries = std::fs::read_dir(dir)
194        .map_err(|e| CodeError::Context(format!("read {}: {e}", dir.display())))?
195        .map(|entry| entry.map(|entry| entry.path()));
196    collect_md_paths(dir, entries, exts)
197}
198
199fn collect_md_paths(
200    dir: &Path,
201    entries: impl IntoIterator<Item = std::io::Result<PathBuf>>,
202    exts: &[&str],
203) -> Result<Vec<PathBuf>> {
204    let mut paths = Vec::new();
205    for entry in entries {
206        let path = entry.map_err(|e| {
207            CodeError::Context(format!("read directory entry in {}: {e}", dir.display()))
208        })?;
209        if path
210            .extension()
211            .and_then(|s| s.to_str())
212            .map(|e| exts.contains(&e))
213            .unwrap_or(false)
214        {
215            paths.push(path);
216        }
217    }
218    paths.sort();
219    Ok(paths)
220}
221
222fn load_schedules(dir: &Path) -> Result<Vec<ScheduleSpec>> {
223    let mut out = Vec::new();
224    for path in md_files(dir, &["md"])? {
225        let content = std::fs::read_to_string(&path)
226            .map_err(|e| CodeError::Context(format!("read {}: {e}", path.display())))?;
227        let (front, body) = split_frontmatter(&content);
228        let front = front.ok_or_else(|| {
229            CodeError::Context(format!(
230                "schedule {} has no YAML frontmatter (need `cron:`)",
231                path.display()
232            ))
233        })?;
234        let meta: ScheduleFront = serde_yaml::from_str(&front).map_err(|e| {
235            CodeError::Context(format!("schedule {} frontmatter: {e}", path.display()))
236        })?;
237        out.push(ScheduleSpec {
238            name: meta.name.unwrap_or_else(|| file_stem(&path)),
239            cron: meta.cron,
240            prompt: body.trim().to_string(),
241            enabled: meta.enabled.unwrap_or(true),
242        });
243    }
244    Ok(out)
245}
246
247/// Upper bounds for a `kind = "script"` tool's sandbox limits. A `tools/` file is
248/// semi-trusted (the whole point of the guardrail), so an author cannot set an
249/// effectively-unbounded `timeoutMs` that hangs the harness, nor a zero that makes
250/// the tool silently non-functional. Generous ceilings; the program tool's own
251/// defaults (30s / 20 calls / 64 KiB) apply when a field is unset.
252const SCRIPT_MAX_TIMEOUT_MS: u64 = 600_000; // 10 minutes
253const SCRIPT_MAX_TOOL_CALLS: usize = 1_000;
254const SCRIPT_MAX_OUTPUT_BYTES: usize = 16 * 1024 * 1024; // 16 MiB
255
256/// Reject zero or above-ceiling limits at load (fail closed). Unset fields keep
257/// the program tool's defaults.
258fn validate_script_limits(
259    limits: ScriptToolLimits,
260) -> std::result::Result<ScriptToolLimits, String> {
261    fn check<T: PartialOrd + Copy + std::fmt::Display>(
262        v: Option<T>,
263        max: T,
264        one: T,
265        field: &str,
266    ) -> std::result::Result<(), String> {
267        if let Some(v) = v {
268            if v < one || v > max {
269                return Err(format!("limit {field}={v} is out of range [1, {max}]"));
270            }
271        }
272        Ok(())
273    }
274    check(limits.timeout_ms, SCRIPT_MAX_TIMEOUT_MS, 1, "timeoutMs")?;
275    check(
276        limits.max_tool_calls,
277        SCRIPT_MAX_TOOL_CALLS,
278        1,
279        "maxToolCalls",
280    )?;
281    check(
282        limits.max_output_bytes,
283        SCRIPT_MAX_OUTPUT_BYTES,
284        1,
285        "maxOutputBytes",
286    )?;
287    Ok(limits)
288}
289
290fn load_tools(dir: &Path) -> Result<Vec<ToolSpec>> {
291    let mut out = Vec::new();
292    let mut seen = std::collections::HashSet::new();
293    for path in md_files(dir, &["md"])? {
294        let content = std::fs::read_to_string(&path)
295            .map_err(|e| CodeError::Context(format!("read {}: {e}", path.display())))?;
296        let (front, body) = split_frontmatter(&content);
297        let front = front.ok_or_else(|| {
298            CodeError::Context(format!(
299                "tool {} has no YAML frontmatter (need `kind:`)",
300                path.display()
301            ))
302        })?;
303        let meta: ToolFront = serde_yaml::from_str(&front)
304            .map_err(|e| CodeError::Context(format!("tool {} frontmatter: {e}", path.display())))?;
305        let spec = match meta.kind.as_str() {
306            "mcp" => {
307                // The frontmatter's flat fields (transport/command/args/url/…) plus
308                // `name` deserialize straight into McpServerConfig; the `kind` key is
309                // ignored by its lenient Deserialize.
310                let cfg: McpServerConfig = serde_yaml::from_str(&front).map_err(|e| {
311                    CodeError::Context(format!(
312                        "tool {} (kind=mcp) is not a valid MCP server config: {e}",
313                        path.display()
314                    ))
315                })?;
316                ToolSpec::Mcp(cfg)
317            }
318            "script" => {
319                let meta: ScriptFront = serde_yaml::from_str(&front).map_err(|e| {
320                    CodeError::Context(format!(
321                        "tool {} (kind=script) frontmatter: {e}",
322                        path.display()
323                    ))
324                })?;
325                // Fail closed at load (not at first call), consistent with the
326                // runtime guards the script runs under: a non-JS source, a path
327                // that escapes the workspace, or an out-of-range sandbox limit are
328                // all directory-load errors rather than first-call surprises.
329                let p = meta.path.to_string_lossy();
330                if !(p.ends_with(".js") || p.ends_with(".mjs")) {
331                    return Err(CodeError::Context(format!(
332                        "tool {} (kind=script) path `{p}` must point to a .js or .mjs file",
333                        path.display()
334                    )));
335                }
336                crate::workspace::validate_relative_pattern(&p, "script path").map_err(|e| {
337                    CodeError::Context(format!("tool {} (kind=script): {e}", path.display()))
338                })?;
339                let limits =
340                    validate_script_limits(meta.limits.unwrap_or_default()).map_err(|e| {
341                        CodeError::Context(format!("tool {} (kind=script): {e}", path.display()))
342                    })?;
343                let description = meta
344                    .description
345                    .map(|d| d.trim().to_string())
346                    .filter(|d| !d.is_empty())
347                    .unwrap_or_else(|| body.trim().to_string());
348                ToolSpec::Script(ScriptToolSpec {
349                    name: meta.name.unwrap_or_else(|| file_stem(&path)),
350                    description,
351                    path: meta.path,
352                    // Fail closed: the governed session invoker re-checks each
353                    // inner `ctx.tool` call, while this allow-list independently
354                    // caps the script's reachable tools. An omitted list grants NO
355                    // tools rather than all of them, so the author must opt each
356                    // tool in explicitly.
357                    allowed_tools: Some(meta.allowed_tools.unwrap_or_default()),
358                    limits,
359                })
360            }
361            other => {
362                return Err(CodeError::Context(format!(
363                    "tool {} has unsupported kind `{other}` (supported: `mcp`, `script`)",
364                    path.display()
365                )));
366            }
367        };
368        if !seen.insert(spec.name().to_string()) {
369            return Err(CodeError::Context(format!(
370                "duplicate tool name `{}` in {}",
371                spec.name(),
372                path.display()
373            )));
374        }
375        out.push(spec);
376    }
377    Ok(out)
378}
379
380fn file_stem(path: &Path) -> String {
381    path.file_stem()
382        .and_then(|s| s.to_str())
383        .unwrap_or("unnamed")
384        .to_string()
385}
386
387/// Split a leading `---\n…\n---` YAML frontmatter block from the markdown body.
388/// Returns `(None, whole)` when there is no frontmatter.
389fn split_frontmatter(content: &str) -> (Option<String>, String) {
390    let trimmed = content.trim_start();
391    if let Some(rest) = trimmed.strip_prefix("---") {
392        let rest = rest.trim_start_matches(['\r', '\n']);
393        // Closing fence: a line that is exactly `---`.
394        for marker in ["\n---\n", "\n---\r\n", "\n---"] {
395            if let Some(end) = rest.find(marker) {
396                let front = rest[..end].to_string();
397                let body = rest[end + marker.len()..]
398                    .trim_start_matches(['\r', '\n'])
399                    .to_string();
400                return (Some(front), body);
401            }
402        }
403    }
404    (None, content.to_string())
405}
406
407#[derive(serde::Deserialize)]
408struct ScheduleFront {
409    cron: String,
410    #[serde(default)]
411    name: Option<String>,
412    #[serde(default)]
413    enabled: Option<bool>,
414}
415
416#[derive(serde::Deserialize)]
417struct ToolFront {
418    kind: String,
419}
420
421/// Frontmatter for a `kind = "script"` tool. The `kind` key is ignored here
422/// (already matched); unknown keys are tolerated like the other loaders.
423#[derive(serde::Deserialize)]
424struct ScriptFront {
425    #[serde(default)]
426    name: Option<String>,
427    path: PathBuf,
428    #[serde(default)]
429    description: Option<String>,
430    #[serde(default)]
431    allowed_tools: Option<Vec<String>>,
432    #[serde(default)]
433    limits: Option<ScriptToolLimits>,
434}
435
436#[cfg(test)]
437mod tests {
438    use super::*;
439
440    /// Build a fixture agent dir under a unique temp path.
441    fn fixture() -> PathBuf {
442        let base = std::env::temp_dir().join(format!("a3s-agentdir-{}", std::process::id()));
443        let _ = std::fs::remove_dir_all(&base);
444        std::fs::create_dir_all(base.join("skills")).unwrap();
445        std::fs::create_dir_all(base.join("schedules")).unwrap();
446        std::fs::create_dir_all(base.join("tools")).unwrap();
447        std::fs::write(
448            base.join("instructions.md"),
449            "You are a release-notes agent. Be terse and accurate.",
450        )
451        .unwrap();
452        std::fs::write(
453            base.join("skills/summarize.md"),
454            "---\nname: summarize\ndescription: summarize text\n---\n# Summarize\n",
455        )
456        .unwrap();
457        std::fs::write(
458            base.join("schedules/daily.md"),
459            "---\ncron: \"0 9 * * *\"\nname: daily-report\n---\nGenerate the daily report and post it.\n",
460        )
461        .unwrap();
462        std::fs::write(
463            base.join("tools/github.md"),
464            "---\nkind: mcp\nname: github\ntransport: stdio\ncommand: echo\nargs: [\"hi\"]\n---\nGitHub MCP tools.\n",
465        )
466        .unwrap();
467        std::fs::write(
468            base.join("tools/search.md"),
469            "---\nkind: script\nname: search-auth\npath: scripts/search.js\nallowed_tools: [search, read]\nlimits:\n  timeoutMs: 30000\n  maxToolCalls: 10\n---\nFind auth-related files.\n",
470        )
471        .unwrap();
472        base
473    }
474
475    #[test]
476    fn loads_convention_into_slots_and_specs() {
477        let dir = fixture();
478        let agent = AgentDir::load(&dir).unwrap();
479
480        // instructions.md → role SLOT (not a raw system-prompt override).
481        assert_eq!(
482            agent.prompt_slots.role.as_deref(),
483            Some("You are a release-notes agent. Be terse and accurate.")
484        );
485
486        // skills/ → appended to skill_dirs.
487        assert!(agent
488            .config
489            .skill_dirs
490            .iter()
491            .any(|p| p.ends_with("skills")));
492
493        // schedules/*.md → parsed cron + body prompt.
494        assert_eq!(agent.schedules.len(), 1);
495        let s = &agent.schedules[0];
496        assert_eq!(s.name, "daily-report");
497        assert_eq!(s.cron, "0 9 * * *");
498        assert_eq!(s.prompt, "Generate the daily report and post it.");
499        assert!(s.enabled);
500
501        // tools/*.md → parsed by kind (sorted by path: github.md, then search.md).
502        assert_eq!(agent.tools.len(), 2);
503        assert_eq!(agent.tools[0].kind(), "mcp");
504        assert_eq!(agent.tools[0].name(), "github");
505
506        // kind=script → ScriptToolSpec with pinned path, allow-list, limits; the
507        // body becomes the model-facing description.
508        assert_eq!(agent.tools[1].kind(), "script");
509        assert_eq!(agent.tools[1].name(), "search-auth");
510        let ToolSpec::Script(s) = &agent.tools[1] else {
511            panic!("expected a script tool");
512        };
513        assert_eq!(s.path, PathBuf::from("scripts/search.js"));
514        assert_eq!(s.description, "Find auth-related files.");
515        assert_eq!(
516            s.allowed_tools.as_deref(),
517            Some(["search".to_string(), "read".to_string()].as_slice())
518        );
519        assert_eq!(s.limits.timeout_ms, Some(30000));
520        assert_eq!(s.limits.max_tool_calls, Some(10));
521
522        let _ = std::fs::remove_dir_all(&dir);
523    }
524
525    #[test]
526    fn directory_entry_errors_are_not_silently_ignored() {
527        let error = collect_md_paths(
528            Path::new("schedules"),
529            [Err(std::io::Error::new(
530                std::io::ErrorKind::PermissionDenied,
531                "fixture entry denied",
532            ))],
533            &["md"],
534        )
535        .unwrap_err();
536
537        let message = error.to_string();
538        assert!(message.contains("read directory entry in schedules"));
539        assert!(message.contains("fixture entry denied"));
540    }
541
542    /// One script tool per file, written under a unique temp dir, must fail to load.
543    fn assert_script_tool_load_err(tag: &str, frontmatter: &str) {
544        let base = std::env::temp_dir().join(format!("a3s-agentdir-{tag}-{}", std::process::id()));
545        let _ = std::fs::remove_dir_all(&base);
546        std::fs::create_dir_all(base.join("tools")).unwrap();
547        std::fs::write(base.join("instructions.md"), "role").unwrap();
548        std::fs::write(base.join("tools/x.md"), frontmatter).unwrap();
549        assert!(
550            AgentDir::load(&base).is_err(),
551            "expected load error for: {frontmatter}"
552        );
553        let _ = std::fs::remove_dir_all(&base);
554    }
555
556    #[test]
557    fn script_tool_non_js_path_is_an_error() {
558        // path must end .js/.mjs — fail closed at load, not at first call.
559        assert_script_tool_load_err(
560            "py",
561            "---\nkind: script\nname: x\npath: scripts/run.py\n---\n",
562        );
563    }
564
565    #[test]
566    fn script_tool_escaping_path_is_an_error() {
567        // Absolute and parent-traversal paths are rejected at load (fail closed),
568        // matching the runtime workspace boundary.
569        #[cfg(not(windows))]
570        let absolute_path = "/etc/evil.js";
571        #[cfg(windows)]
572        let absolute_path = "C:/etc/evil.js";
573        assert_script_tool_load_err(
574            "abs",
575            &format!("---\nkind: script\nname: x\npath: {absolute_path}\n---\n"),
576        );
577        assert_script_tool_load_err(
578            "dotdot",
579            "---\nkind: script\nname: x\npath: ../../escape.js\n---\n",
580        );
581    }
582
583    #[test]
584    fn script_tool_out_of_range_limits_are_an_error() {
585        // Zero disables the tool; u64::MAX disables the sandbox timeout. Both rejected.
586        assert_script_tool_load_err(
587            "zero",
588            "---\nkind: script\nname: x\npath: a.js\nlimits:\n  timeoutMs: 0\n---\n",
589        );
590        assert_script_tool_load_err(
591            "huge",
592            "---\nkind: script\nname: x\npath: a.js\nlimits:\n  timeoutMs: 18446744073709551615\n---\n",
593        );
594        assert_script_tool_load_err(
595            "calls",
596            "---\nkind: script\nname: x\npath: a.js\nlimits:\n  maxToolCalls: 0\n---\n",
597        );
598    }
599
600    #[test]
601    fn unknown_tool_kind_is_an_error() {
602        let base =
603            std::env::temp_dir().join(format!("a3s-agentdir-toolkind-{}", std::process::id()));
604        let _ = std::fs::remove_dir_all(&base);
605        std::fs::create_dir_all(base.join("tools")).unwrap();
606        std::fs::write(base.join("instructions.md"), "role").unwrap();
607        std::fs::write(base.join("tools/x.md"), "---\nkind: wat\nname: x\n---\n").unwrap();
608        assert!(AgentDir::load(&base).is_err());
609        let _ = std::fs::remove_dir_all(&base);
610    }
611
612    #[test]
613    fn duplicate_tool_name_is_an_error() {
614        let base =
615            std::env::temp_dir().join(format!("a3s-agentdir-tooldup-{}", std::process::id()));
616        let _ = std::fs::remove_dir_all(&base);
617        std::fs::create_dir_all(base.join("tools")).unwrap();
618        std::fs::write(base.join("instructions.md"), "role").unwrap();
619        let spec = "---\nkind: mcp\nname: dup\ntransport: stdio\ncommand: echo\n---\n";
620        std::fs::write(base.join("tools/a.md"), spec).unwrap();
621        std::fs::write(base.join("tools/b.md"), spec).unwrap();
622        assert!(AgentDir::load(&base).is_err());
623        let _ = std::fs::remove_dir_all(&base);
624    }
625
626    #[test]
627    fn script_tool_accepts_mjs_and_frontmatter_description_wins_over_body() {
628        let base = std::env::temp_dir().join(format!("a3s-agentdir-mjs-{}", std::process::id()));
629        let _ = std::fs::remove_dir_all(&base);
630        std::fs::create_dir_all(base.join("tools")).unwrap();
631        std::fs::write(base.join("instructions.md"), "role").unwrap();
632        std::fs::write(
633            base.join("tools/x.md"),
634            "---\nkind: script\nname: x\npath: a.mjs\ndescription: from frontmatter\n---\nbody description\n",
635        )
636        .unwrap();
637
638        let agent = AgentDir::load(&base).unwrap();
639        let ToolSpec::Script(s) = &agent.tools[0] else {
640            panic!("expected script tool");
641        };
642        assert_eq!(s.path, PathBuf::from("a.mjs"), ".mjs is accepted");
643        assert_eq!(
644            s.description, "from frontmatter",
645            "frontmatter description takes precedence over the body"
646        );
647        let _ = std::fs::remove_dir_all(&base);
648    }
649
650    #[test]
651    fn script_tool_omitted_allow_list_fails_closed_to_empty() {
652        // A directory script with no `allowed_tools` must default to an EMPTY
653        // allow-list (no tools), not "all tools". Session governance remains in
654        // force, but the independent script boundary must not grant a tool merely
655        // because the session policy would allow it.
656        let base =
657            std::env::temp_dir().join(format!("a3s-agentdir-noallow-{}", std::process::id()));
658        let _ = std::fs::remove_dir_all(&base);
659        std::fs::create_dir_all(base.join("tools")).unwrap();
660        std::fs::write(base.join("instructions.md"), "role").unwrap();
661        std::fs::write(
662            base.join("tools/x.md"),
663            "---\nkind: script\nname: x\npath: a.js\n---\n",
664        )
665        .unwrap();
666
667        let agent = AgentDir::load(&base).unwrap();
668        let ToolSpec::Script(s) = &agent.tools[0] else {
669            panic!("expected script tool");
670        };
671        assert_eq!(
672            s.allowed_tools.as_deref(),
673            Some([].as_slice()),
674            "omitted allowed_tools must fail closed to an empty list, not None/all"
675        );
676        let _ = std::fs::remove_dir_all(&base);
677    }
678
679    #[test]
680    fn missing_instructions_is_an_error() {
681        let base = std::env::temp_dir().join(format!("a3s-agentdir-empty-{}", std::process::id()));
682        let _ = std::fs::remove_dir_all(&base);
683        std::fs::create_dir_all(&base).unwrap();
684        assert!(AgentDir::load(&base).is_err());
685        let _ = std::fs::remove_dir_all(&base);
686    }
687
688    #[test]
689    fn frontmatter_split_handles_no_frontmatter() {
690        let (f, b) = split_frontmatter("no frontmatter here");
691        assert!(f.is_none());
692        assert_eq!(b, "no frontmatter here");
693    }
694}