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