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