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 = crate::bounded_io::read_utf8_file_bounded(
150            &dir.join("instructions.md"),
151            crate::bounded_io::MAX_AGENT_DIRECTORY_FILE_BYTES,
152        )
153        .map_err(|e| {
154            CodeError::Context(format!(
155                "agent dir {} is missing required instructions.md: {e}",
156                dir.display()
157            ))
158        })?;
159        let prompt_slots = SystemPromptSlots {
160            role: Some(instructions.trim().to_string()),
161            ..Default::default()
162        };
163
164        // agent.acl (optional) → CodeConfig, else default.
165        let acl_path = dir.join("agent.acl");
166        let mut config = if acl_path.is_file() {
167            CodeConfig::from_file(&acl_path)?
168        } else {
169            CodeConfig::default()
170        };
171
172        // skills/ → appended to skill_dirs (existing *.md format, zero adaptation).
173        let skills_dir = dir.join("skills");
174        if skills_dir.is_dir() {
175            config.skill_dirs.push(skills_dir);
176        }
177
178        let schedules = load_schedules(&dir.join("schedules"))?;
179        let tools = load_tools(&dir.join("tools"))?;
180
181        Ok(Self {
182            dir,
183            config,
184            prompt_slots,
185            schedules,
186            tools,
187        })
188    }
189}
190
191/// Markdown files with a `<ext>` extension in `dir`, sorted by path. Returns an
192/// empty list when `dir` does not exist.
193fn md_files(dir: &Path, exts: &[&str]) -> Result<Vec<PathBuf>> {
194    if !dir.is_dir() {
195        return Ok(Vec::new());
196    }
197    let entries = std::fs::read_dir(dir)
198        .map_err(|e| CodeError::Context(format!("read {}: {e}", dir.display())))?
199        .map(|entry| entry.map(|entry| entry.path()));
200    collect_md_paths(dir, entries, exts)
201}
202
203fn collect_md_paths(
204    dir: &Path,
205    entries: impl IntoIterator<Item = std::io::Result<PathBuf>>,
206    exts: &[&str],
207) -> Result<Vec<PathBuf>> {
208    let mut paths = Vec::new();
209    for entry in entries {
210        let path = entry.map_err(|e| {
211            CodeError::Context(format!("read directory entry in {}: {e}", dir.display()))
212        })?;
213        if path
214            .extension()
215            .and_then(|s| s.to_str())
216            .map(|e| exts.contains(&e))
217            .unwrap_or(false)
218        {
219            paths.push(path);
220        }
221    }
222    paths.sort();
223    Ok(paths)
224}
225
226fn load_schedules(dir: &Path) -> Result<Vec<ScheduleSpec>> {
227    let mut out = Vec::new();
228    for path in md_files(dir, &["md"])? {
229        let content = crate::bounded_io::read_utf8_file_bounded(
230            &path,
231            crate::bounded_io::MAX_AGENT_DIRECTORY_FILE_BYTES,
232        )
233        .map_err(|e| CodeError::Context(format!("read {}: {e}", path.display())))?;
234        let (front, body) = split_frontmatter(&content);
235        let front = front.ok_or_else(|| {
236            CodeError::Context(format!(
237                "schedule {} has no YAML frontmatter (need `cron:`)",
238                path.display()
239            ))
240        })?;
241        let meta: ScheduleFront = serde_yaml::from_str(&front).map_err(|e| {
242            CodeError::Context(format!("schedule {} frontmatter: {e}", path.display()))
243        })?;
244        out.push(ScheduleSpec {
245            name: meta.name.unwrap_or_else(|| file_stem(&path)),
246            cron: meta.cron,
247            prompt: body.trim().to_string(),
248            enabled: meta.enabled.unwrap_or(true),
249        });
250    }
251    Ok(out)
252}
253
254/// Upper bounds for a `kind = "script"` tool's sandbox limits. A `tools/` file is
255/// semi-trusted (the whole point of the guardrail), so an author cannot set an
256/// effectively-unbounded `timeoutMs` that hangs the harness, nor a zero that makes
257/// the tool silently non-functional. Generous ceilings; the program tool's own
258/// defaults (30s / 20 calls / 64 KiB) apply when a field is unset.
259const SCRIPT_MAX_TIMEOUT_MS: u64 = 600_000; // 10 minutes
260const SCRIPT_MAX_TOOL_CALLS: usize = 1_000;
261const SCRIPT_MAX_OUTPUT_BYTES: usize = 16 * 1024 * 1024; // 16 MiB
262
263/// Reject zero or above-ceiling limits at load (fail closed). Unset fields keep
264/// the program tool's defaults.
265fn validate_script_limits(
266    limits: ScriptToolLimits,
267) -> std::result::Result<ScriptToolLimits, String> {
268    fn check<T: PartialOrd + Copy + std::fmt::Display>(
269        v: Option<T>,
270        max: T,
271        one: T,
272        field: &str,
273    ) -> std::result::Result<(), String> {
274        if let Some(v) = v {
275            if v < one || v > max {
276                return Err(format!("limit {field}={v} is out of range [1, {max}]"));
277            }
278        }
279        Ok(())
280    }
281    check(limits.timeout_ms, SCRIPT_MAX_TIMEOUT_MS, 1, "timeoutMs")?;
282    check(
283        limits.max_tool_calls,
284        SCRIPT_MAX_TOOL_CALLS,
285        1,
286        "maxToolCalls",
287    )?;
288    check(
289        limits.max_output_bytes,
290        SCRIPT_MAX_OUTPUT_BYTES,
291        1,
292        "maxOutputBytes",
293    )?;
294    Ok(limits)
295}
296
297fn load_tools(dir: &Path) -> Result<Vec<ToolSpec>> {
298    let mut out = Vec::new();
299    let mut seen = std::collections::HashSet::new();
300    for path in md_files(dir, &["md"])? {
301        let content = crate::bounded_io::read_utf8_file_bounded(
302            &path,
303            crate::bounded_io::MAX_AGENT_DIRECTORY_FILE_BYTES,
304        )
305        .map_err(|e| CodeError::Context(format!("read {}: {e}", path.display())))?;
306        let (front, body) = split_frontmatter(&content);
307        let front = front.ok_or_else(|| {
308            CodeError::Context(format!(
309                "tool {} has no YAML frontmatter (need `kind:`)",
310                path.display()
311            ))
312        })?;
313        let meta: ToolFront = serde_yaml::from_str(&front)
314            .map_err(|e| CodeError::Context(format!("tool {} frontmatter: {e}", path.display())))?;
315        let spec = match meta.kind.as_str() {
316            "mcp" => {
317                // The frontmatter's flat fields (transport/command/args/url/…) plus
318                // `name` deserialize straight into McpServerConfig; the `kind` key is
319                // ignored by its lenient Deserialize.
320                let cfg: McpServerConfig = serde_yaml::from_str(&front).map_err(|e| {
321                    CodeError::Context(format!(
322                        "tool {} (kind=mcp) is not a valid MCP server config: {e}",
323                        path.display()
324                    ))
325                })?;
326                ToolSpec::Mcp(cfg)
327            }
328            "script" => {
329                let meta: ScriptFront = serde_yaml::from_str(&front).map_err(|e| {
330                    CodeError::Context(format!(
331                        "tool {} (kind=script) frontmatter: {e}",
332                        path.display()
333                    ))
334                })?;
335                // Fail closed at load (not at first call), consistent with the
336                // runtime guards the script runs under: a non-JS source, a path
337                // that escapes the workspace, or an out-of-range sandbox limit are
338                // all directory-load errors rather than first-call surprises.
339                let p = meta.path.to_string_lossy();
340                if !(p.ends_with(".js") || p.ends_with(".mjs")) {
341                    return Err(CodeError::Context(format!(
342                        "tool {} (kind=script) path `{p}` must point to a .js or .mjs file",
343                        path.display()
344                    )));
345                }
346                crate::workspace::validate_relative_pattern(&p, "script path").map_err(|e| {
347                    CodeError::Context(format!("tool {} (kind=script): {e}", path.display()))
348                })?;
349                let limits =
350                    validate_script_limits(meta.limits.unwrap_or_default()).map_err(|e| {
351                        CodeError::Context(format!("tool {} (kind=script): {e}", path.display()))
352                    })?;
353                let description = meta
354                    .description
355                    .map(|d| d.trim().to_string())
356                    .filter(|d| !d.is_empty())
357                    .unwrap_or_else(|| body.trim().to_string());
358                ToolSpec::Script(ScriptToolSpec {
359                    name: meta.name.unwrap_or_else(|| file_stem(&path)),
360                    description,
361                    path: meta.path,
362                    // Fail closed: the governed session invoker re-checks each
363                    // inner `ctx.tool` call, while this allow-list independently
364                    // caps the script's reachable tools. An omitted list grants NO
365                    // tools rather than all of them, so the author must opt each
366                    // tool in explicitly.
367                    allowed_tools: Some(meta.allowed_tools.unwrap_or_default()),
368                    limits,
369                })
370            }
371            other => {
372                return Err(CodeError::Context(format!(
373                    "tool {} has unsupported kind `{other}` (supported: `mcp`, `script`)",
374                    path.display()
375                )));
376            }
377        };
378        if !seen.insert(spec.name().to_string()) {
379            return Err(CodeError::Context(format!(
380                "duplicate tool name `{}` in {}",
381                spec.name(),
382                path.display()
383            )));
384        }
385        out.push(spec);
386    }
387    Ok(out)
388}
389
390fn file_stem(path: &Path) -> String {
391    path.file_stem()
392        .and_then(|s| s.to_str())
393        .unwrap_or("unnamed")
394        .to_string()
395}
396
397/// Split a leading `---\n…\n---` YAML frontmatter block from the markdown body.
398/// Returns `(None, whole)` when there is no frontmatter.
399fn split_frontmatter(content: &str) -> (Option<String>, String) {
400    let trimmed = content.trim_start();
401    if let Some(rest) = trimmed.strip_prefix("---") {
402        let rest = rest.trim_start_matches(['\r', '\n']);
403        // Closing fence: a line that is exactly `---`.
404        for marker in ["\n---\n", "\n---\r\n", "\n---"] {
405            if let Some(end) = rest.find(marker) {
406                let front = rest[..end].to_string();
407                let body = rest[end + marker.len()..]
408                    .trim_start_matches(['\r', '\n'])
409                    .to_string();
410                return (Some(front), body);
411            }
412        }
413    }
414    (None, content.to_string())
415}
416
417#[derive(serde::Deserialize)]
418struct ScheduleFront {
419    cron: String,
420    #[serde(default)]
421    name: Option<String>,
422    #[serde(default)]
423    enabled: Option<bool>,
424}
425
426#[derive(serde::Deserialize)]
427struct ToolFront {
428    kind: String,
429}
430
431/// Frontmatter for a `kind = "script"` tool. The `kind` key is ignored here
432/// (already matched); unknown keys are tolerated like the other loaders.
433#[derive(serde::Deserialize)]
434struct ScriptFront {
435    #[serde(default)]
436    name: Option<String>,
437    path: PathBuf,
438    #[serde(default)]
439    description: Option<String>,
440    #[serde(default)]
441    allowed_tools: Option<Vec<String>>,
442    #[serde(default)]
443    limits: Option<ScriptToolLimits>,
444}
445
446#[cfg(test)]
447mod tests {
448    use super::*;
449
450    /// Build a fixture agent dir under a unique temp path.
451    fn fixture() -> PathBuf {
452        let base = std::env::temp_dir().join(format!("a3s-agentdir-{}", std::process::id()));
453        let _ = std::fs::remove_dir_all(&base);
454        std::fs::create_dir_all(base.join("skills")).unwrap();
455        std::fs::create_dir_all(base.join("schedules")).unwrap();
456        std::fs::create_dir_all(base.join("tools")).unwrap();
457        std::fs::write(
458            base.join("instructions.md"),
459            "You are a release-notes agent. Be terse and accurate.",
460        )
461        .unwrap();
462        std::fs::write(
463            base.join("skills/summarize.md"),
464            "---\nname: summarize\ndescription: summarize text\n---\n# Summarize\n",
465        )
466        .unwrap();
467        std::fs::write(
468            base.join("schedules/daily.md"),
469            "---\ncron: \"0 9 * * *\"\nname: daily-report\n---\nGenerate the daily report and post it.\n",
470        )
471        .unwrap();
472        std::fs::write(
473            base.join("tools/github.md"),
474            "---\nkind: mcp\nname: github\ntransport: stdio\ncommand: echo\nargs: [\"hi\"]\n---\nGitHub MCP tools.\n",
475        )
476        .unwrap();
477        std::fs::write(
478            base.join("tools/search.md"),
479            "---\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",
480        )
481        .unwrap();
482        base
483    }
484
485    #[test]
486    fn loads_convention_into_slots_and_specs() {
487        let dir = fixture();
488        let agent = AgentDir::load(&dir).unwrap();
489
490        // instructions.md → role SLOT (not a raw system-prompt override).
491        assert_eq!(
492            agent.prompt_slots.role.as_deref(),
493            Some("You are a release-notes agent. Be terse and accurate.")
494        );
495
496        // skills/ → appended to skill_dirs.
497        assert!(agent
498            .config
499            .skill_dirs
500            .iter()
501            .any(|p| p.ends_with("skills")));
502
503        // schedules/*.md → parsed cron + body prompt.
504        assert_eq!(agent.schedules.len(), 1);
505        let s = &agent.schedules[0];
506        assert_eq!(s.name, "daily-report");
507        assert_eq!(s.cron, "0 9 * * *");
508        assert_eq!(s.prompt, "Generate the daily report and post it.");
509        assert!(s.enabled);
510
511        // tools/*.md → parsed by kind (sorted by path: github.md, then search.md).
512        assert_eq!(agent.tools.len(), 2);
513        assert_eq!(agent.tools[0].kind(), "mcp");
514        assert_eq!(agent.tools[0].name(), "github");
515
516        // kind=script → ScriptToolSpec with pinned path, allow-list, limits; the
517        // body becomes the model-facing description.
518        assert_eq!(agent.tools[1].kind(), "script");
519        assert_eq!(agent.tools[1].name(), "search-auth");
520        let ToolSpec::Script(s) = &agent.tools[1] else {
521            panic!("expected a script tool");
522        };
523        assert_eq!(s.path, PathBuf::from("scripts/search.js"));
524        assert_eq!(s.description, "Find auth-related files.");
525        assert_eq!(
526            s.allowed_tools.as_deref(),
527            Some(["search".to_string(), "read".to_string()].as_slice())
528        );
529        assert_eq!(s.limits.timeout_ms, Some(30000));
530        assert_eq!(s.limits.max_tool_calls, Some(10));
531
532        let _ = std::fs::remove_dir_all(&dir);
533    }
534
535    #[test]
536    fn directory_entry_errors_are_not_silently_ignored() {
537        let error = collect_md_paths(
538            Path::new("schedules"),
539            [Err(std::io::Error::new(
540                std::io::ErrorKind::PermissionDenied,
541                "fixture entry denied",
542            ))],
543            &["md"],
544        )
545        .unwrap_err();
546
547        let message = error.to_string();
548        assert!(message.contains("read directory entry in schedules"));
549        assert!(message.contains("fixture entry denied"));
550    }
551
552    /// One script tool per file, written under a unique temp dir, must fail to load.
553    fn assert_script_tool_load_err(tag: &str, frontmatter: &str) {
554        let base = std::env::temp_dir().join(format!("a3s-agentdir-{tag}-{}", std::process::id()));
555        let _ = std::fs::remove_dir_all(&base);
556        std::fs::create_dir_all(base.join("tools")).unwrap();
557        std::fs::write(base.join("instructions.md"), "role").unwrap();
558        std::fs::write(base.join("tools/x.md"), frontmatter).unwrap();
559        assert!(
560            AgentDir::load(&base).is_err(),
561            "expected load error for: {frontmatter}"
562        );
563        let _ = std::fs::remove_dir_all(&base);
564    }
565
566    #[test]
567    fn script_tool_non_js_path_is_an_error() {
568        // path must end .js/.mjs — fail closed at load, not at first call.
569        assert_script_tool_load_err(
570            "py",
571            "---\nkind: script\nname: x\npath: scripts/run.py\n---\n",
572        );
573    }
574
575    #[test]
576    fn script_tool_escaping_path_is_an_error() {
577        // Absolute and parent-traversal paths are rejected at load (fail closed),
578        // matching the runtime workspace boundary.
579        #[cfg(not(windows))]
580        let absolute_path = "/etc/evil.js";
581        #[cfg(windows)]
582        let absolute_path = "C:/etc/evil.js";
583        assert_script_tool_load_err(
584            "abs",
585            &format!("---\nkind: script\nname: x\npath: {absolute_path}\n---\n"),
586        );
587        assert_script_tool_load_err(
588            "dotdot",
589            "---\nkind: script\nname: x\npath: ../../escape.js\n---\n",
590        );
591    }
592
593    #[test]
594    fn script_tool_out_of_range_limits_are_an_error() {
595        // Zero disables the tool; u64::MAX disables the sandbox timeout. Both rejected.
596        assert_script_tool_load_err(
597            "zero",
598            "---\nkind: script\nname: x\npath: a.js\nlimits:\n  timeoutMs: 0\n---\n",
599        );
600        assert_script_tool_load_err(
601            "huge",
602            "---\nkind: script\nname: x\npath: a.js\nlimits:\n  timeoutMs: 18446744073709551615\n---\n",
603        );
604        assert_script_tool_load_err(
605            "calls",
606            "---\nkind: script\nname: x\npath: a.js\nlimits:\n  maxToolCalls: 0\n---\n",
607        );
608    }
609
610    #[test]
611    fn unknown_tool_kind_is_an_error() {
612        let base =
613            std::env::temp_dir().join(format!("a3s-agentdir-toolkind-{}", std::process::id()));
614        let _ = std::fs::remove_dir_all(&base);
615        std::fs::create_dir_all(base.join("tools")).unwrap();
616        std::fs::write(base.join("instructions.md"), "role").unwrap();
617        std::fs::write(base.join("tools/x.md"), "---\nkind: wat\nname: x\n---\n").unwrap();
618        assert!(AgentDir::load(&base).is_err());
619        let _ = std::fs::remove_dir_all(&base);
620    }
621
622    #[test]
623    fn duplicate_tool_name_is_an_error() {
624        let base =
625            std::env::temp_dir().join(format!("a3s-agentdir-tooldup-{}", std::process::id()));
626        let _ = std::fs::remove_dir_all(&base);
627        std::fs::create_dir_all(base.join("tools")).unwrap();
628        std::fs::write(base.join("instructions.md"), "role").unwrap();
629        let spec = "---\nkind: mcp\nname: dup\ntransport: stdio\ncommand: echo\n---\n";
630        std::fs::write(base.join("tools/a.md"), spec).unwrap();
631        std::fs::write(base.join("tools/b.md"), spec).unwrap();
632        assert!(AgentDir::load(&base).is_err());
633        let _ = std::fs::remove_dir_all(&base);
634    }
635
636    #[test]
637    fn script_tool_accepts_mjs_and_frontmatter_description_wins_over_body() {
638        let base = std::env::temp_dir().join(format!("a3s-agentdir-mjs-{}", std::process::id()));
639        let _ = std::fs::remove_dir_all(&base);
640        std::fs::create_dir_all(base.join("tools")).unwrap();
641        std::fs::write(base.join("instructions.md"), "role").unwrap();
642        std::fs::write(
643            base.join("tools/x.md"),
644            "---\nkind: script\nname: x\npath: a.mjs\ndescription: from frontmatter\n---\nbody description\n",
645        )
646        .unwrap();
647
648        let agent = AgentDir::load(&base).unwrap();
649        let ToolSpec::Script(s) = &agent.tools[0] else {
650            panic!("expected script tool");
651        };
652        assert_eq!(s.path, PathBuf::from("a.mjs"), ".mjs is accepted");
653        assert_eq!(
654            s.description, "from frontmatter",
655            "frontmatter description takes precedence over the body"
656        );
657        let _ = std::fs::remove_dir_all(&base);
658    }
659
660    #[test]
661    fn script_tool_omitted_allow_list_fails_closed_to_empty() {
662        // A directory script with no `allowed_tools` must default to an EMPTY
663        // allow-list (no tools), not "all tools". Session governance remains in
664        // force, but the independent script boundary must not grant a tool merely
665        // because the session policy would allow it.
666        let base =
667            std::env::temp_dir().join(format!("a3s-agentdir-noallow-{}", std::process::id()));
668        let _ = std::fs::remove_dir_all(&base);
669        std::fs::create_dir_all(base.join("tools")).unwrap();
670        std::fs::write(base.join("instructions.md"), "role").unwrap();
671        std::fs::write(
672            base.join("tools/x.md"),
673            "---\nkind: script\nname: x\npath: a.js\n---\n",
674        )
675        .unwrap();
676
677        let agent = AgentDir::load(&base).unwrap();
678        let ToolSpec::Script(s) = &agent.tools[0] else {
679            panic!("expected script tool");
680        };
681        assert_eq!(
682            s.allowed_tools.as_deref(),
683            Some([].as_slice()),
684            "omitted allowed_tools must fail closed to an empty list, not None/all"
685        );
686        let _ = std::fs::remove_dir_all(&base);
687    }
688
689    #[test]
690    fn missing_instructions_is_an_error() {
691        let base = std::env::temp_dir().join(format!("a3s-agentdir-empty-{}", std::process::id()));
692        let _ = std::fs::remove_dir_all(&base);
693        std::fs::create_dir_all(&base).unwrap();
694        assert!(AgentDir::load(&base).is_err());
695        let _ = std::fs::remove_dir_all(&base);
696    }
697
698    #[test]
699    fn frontmatter_split_handles_no_frontmatter() {
700        let (f, b) = split_frontmatter("no frontmatter here");
701        assert!(f.is_none());
702        assert_eq!(b, "no frontmatter here");
703    }
704}