Skip to main content

mobius/middleware/
extensions.rs

1//! Standalone skills and activated Agent Plugin packages.
2
3use std::collections::BTreeMap;
4use std::collections::BTreeSet;
5use std::env;
6use std::fs::File;
7use std::io::{Read as _, Write as _};
8use std::path::Component;
9use std::path::Path;
10use std::path::PathBuf;
11use std::sync::Arc;
12
13use cap_std::ambient_authority;
14use cap_std::fs::Dir;
15use serde_json::Value;
16
17use super::CompactContext;
18use super::MessageSubmitContext;
19use super::Middleware;
20use super::PermissionRequestContext;
21use super::PostToolUseContext;
22use super::PreToolUseContext;
23use super::PromptSection;
24use super::RuntimeContext;
25use super::SessionStartContext;
26use super::SessionStartSource;
27use super::StopContext;
28use super::manifest::MiddlewareManifest;
29use crate::BoxFuture;
30use crate::Error;
31use crate::Result;
32use crate::agent::AgentRole;
33use crate::backend::model::internal_user_message;
34use crate::backend::sandbox::ApprovalPolicy;
35use crate::backend::sandbox::CommandAuthorization;
36use crate::backend::sandbox::SandboxBackend;
37use crate::protocol::EventMsg;
38use crate::protocol::FrontendContribution;
39use crate::protocol::FrontendReference;
40use crate::protocol::FrontendSlot;
41use crate::protocol::FrontendTone;
42use crate::protocol::FrontendWidget;
43use crate::protocol::WarningEvent;
44use crate::protocol::internal_message_kind;
45use crate::truncate_utf8;
46
47mod hooks;
48mod package;
49
50mod text {
51    pub const FALLBACK_SKILL_DESCRIPTION: &str = "Local workflow instructions.";
52    pub const MANIFEST_DESCRIPTION: &str =
53        "Load standalone Agent Skills and activated OpenAI plugin packages";
54    pub const MANIFEST_LABEL: &str = "Extensions";
55    pub const PROMPT_DEFAULT: &str = "When a skill is named or matches the task, use `read_file` to read its complete `SKILL.md` at the advertised location before following it. Resolve referenced paths relative to that skill directory and use their absolute paths with normal tools.";
56}
57const MAX_SKILLS: usize = 64;
58const MAX_SKILL_BYTES: u64 = 40_000;
59const MAX_PLUGINS: usize = 32;
60const MAX_PLUGIN_ID_BYTES: usize = 128;
61const MAX_HOOK_CONTEXT_BYTES: usize = 40_000;
62const MAX_HOOK_NOTICES: usize = 32;
63const SKILL_FILE: &str = "SKILL.md";
64const SESSION_HOOK_CONTEXT_KIND: &str = "extension_session_hook";
65
66/// Fail-closed authorization checked immediately before each plugin hook command starts.
67pub type HookAuthorization = CommandAuthorization;
68
69/// Configuration and presentation metadata for installed extensions.
70pub const MANIFEST: MiddlewareManifest = MiddlewareManifest {
71    id: "extensions",
72    label: text::MANIFEST_LABEL,
73    description: text::MANIFEST_DESCRIPTION,
74    required: false,
75    default_enabled: false,
76    settings: &[],
77};
78
79/// One validated package format understood by the extensions middleware.
80#[derive(Debug, Clone, Copy, PartialEq, Eq)]
81pub enum ExtensionPackageKind {
82    Skill,
83    Plugin,
84}
85
86/// Frontend-safe metadata read from one extension package root.
87#[derive(Debug, Clone, PartialEq, Eq)]
88pub struct ExtensionPackage {
89    pub kind: ExtensionPackageKind,
90    pub name: String,
91    pub version: Option<String>,
92    pub description: String,
93    pub skills: Vec<String>,
94    pub hooks: Vec<ExtensionHook>,
95}
96
97/// One executable hook shown to an owner before trust is granted.
98#[derive(Debug, Clone, PartialEq, Eq)]
99pub struct ExtensionHook {
100    pub event: String,
101    pub matcher: Option<String>,
102    pub command: String,
103    pub timeout_seconds: u64,
104}
105
106/// Validates and inspects one standalone Agent Skill or Agent Plugin package.
107pub fn inspect_package(root: impl AsRef<Path>) -> Result<ExtensionPackage> {
108    let root = package::canonical_root(root.as_ref())?;
109    Ok(package::load(root)?.metadata)
110}
111
112#[derive(Clone)]
113struct Skill {
114    name: String,
115    description: String,
116    location: PathBuf,
117}
118
119struct AuthorizedHooks {
120    set: hooks::HookSet,
121    authorization: HookAuthorization,
122}
123
124/// Discovers bounded skill extensions and advertises their resource locations.
125pub struct Extensions {
126    skills: BTreeMap<String, Skill>,
127    plugins: BTreeSet<String>,
128    hooks: Vec<AuthorizedHooks>,
129    hook_runtime: Option<hooks::HookRuntime>,
130    prompt: String,
131}
132
133impl Extensions {
134    /// Discovers direct child `SKILL.md` files under each root.
135    pub fn discover(roots: impl IntoIterator<Item = PathBuf>) -> Result<Self> {
136        let mut skills = BTreeMap::new();
137        discover_roots(roots, &mut skills, None, false)?;
138        Ok(Self {
139            skills,
140            plugins: BTreeSet::new(),
141            hooks: Vec::new(),
142            hook_runtime: None,
143            prompt: text::PROMPT_DEFAULT.into(),
144        })
145    }
146
147    /// Adds user-installed skills after the explicit roots.
148    pub fn discover_installed(roots: impl IntoIterator<Item = PathBuf>) -> Result<Self> {
149        let mut discovered = Self::discover(roots)?;
150        discover_roots(installed_skill_roots(), &mut discovered.skills, None, true)?;
151        Ok(discovered)
152    }
153
154    /// Activates plugin snapshots and their declared contributions.
155    ///
156    /// The optional predicate authorizes command hooks for that snapshot and is checked
157    /// immediately before every launch. Bundled skills remain available without it. Callers
158    /// must pass immutable snapshots rather than paths discovered from a workspace.
159    pub fn activate_plugins(
160        mut self,
161        roots: impl IntoIterator<Item = (PathBuf, Option<HookAuthorization>)>,
162        workspace: impl AsRef<Path>,
163        backend: Arc<dyn SandboxBackend>,
164    ) -> Result<Self> {
165        if !self.plugins.is_empty() {
166            return Err(Error::Config("plugins were activated twice".into()));
167        }
168        let roots = roots.into_iter().collect::<Vec<_>>();
169        if roots.is_empty() {
170            return Ok(self);
171        }
172        let workspace = canonical_directory(workspace.as_ref(), "plugin workspace")?;
173        let workspace_dir = Dir::open_ambient_dir(&workspace, ambient_authority())?;
174        let data_root = workspace.join(".mobius/extensions");
175        workspace_dir.create_dir_all(".mobius/extensions")?;
176        let data_root_dir = workspace_dir.open_dir(".mobius/extensions")?;
177        let mut ignore = cap_std::fs::OpenOptions::new();
178        ignore.write(true).create_new(true);
179        match data_root_dir.open_with(".gitignore", &ignore) {
180            Ok(mut file) => file.write_all(b"*\n")?,
181            Err(error) if error.kind() == std::io::ErrorKind::AlreadyExists => {}
182            Err(error) => return Err(error.into()),
183        }
184        let data_root = canonical_directory(&data_root, "plugin data root")?;
185        if !data_root.starts_with(&workspace) {
186            return Err(Error::Config(
187                "plugin data root escapes the workspace".into(),
188            ));
189        }
190        for (root, authorization) in roots {
191            if self.plugins.len() == MAX_PLUGINS {
192                return Err(Error::Config(format!("plugin count exceeds {MAX_PLUGINS}")));
193            }
194            let root = package::canonical_root(&root)?;
195            if root.starts_with(&workspace) || workspace.starts_with(&root) {
196                return Err(Error::Config(
197                    "plugin snapshot and writable workspace must not overlap".into(),
198                ));
199            }
200            let package::LoadedPackage {
201                root,
202                metadata: package,
203                skills,
204                hooks,
205            } = package::load(root)?;
206            if package.kind != ExtensionPackageKind::Plugin {
207                return Err(Error::Config("activated extension is not a plugin".into()));
208            }
209            if !self.plugins.insert(package.name.clone()) {
210                return Err(Error::Duplicate(format!("plugin `{}`", package.name)));
211            }
212            let data = data_root.join(&package.name);
213            data_root_dir.create_dir_all(&package.name)?;
214            let data = canonical_directory(&data, "plugin data directory")?;
215            if !data.starts_with(&data_root) {
216                return Err(Error::Config(format!(
217                    "plugin `{}` data directory escapes its root",
218                    package.name
219                )));
220            }
221            for (name, skill) in skills {
222                if self.skills.contains_key(&name) {
223                    return Err(Error::Duplicate(format!("skill `{name}`")));
224                }
225                if self.skills.len() == MAX_SKILLS {
226                    return Err(Error::Config(format!("skill count exceeds {MAX_SKILLS}")));
227                }
228                self.skills.insert(name, skill);
229            }
230            let hooks = hooks
231                .map(|definitions| hooks::HookSet::new(root, data, definitions))
232                .transpose()?;
233            if let (Some(authorization), Some(hooks)) = (authorization, hooks)
234                && !hooks.is_empty()
235            {
236                self.hooks.push(AuthorizedHooks {
237                    set: hooks,
238                    authorization,
239                });
240            }
241        }
242        if !self.hooks.is_empty() {
243            self.hook_runtime = Some(hooks::HookRuntime::new(backend, workspace)?);
244        }
245        Ok(self)
246    }
247
248    /// Overrides the instruction placed before discovered skill metadata.
249    pub fn prompt(mut self, prompt: impl Into<String>) -> Result<Self> {
250        let prompt = prompt.into();
251        if prompt.trim().is_empty() {
252            return Err(Error::Config("extensions prompt cannot be empty".into()));
253        }
254        self.prompt = prompt;
255        Ok(self)
256    }
257
258    /// Returns the canonical skill directories that generic read tools may access.
259    #[must_use]
260    pub fn resource_roots(&self) -> Vec<PathBuf> {
261        self.skills
262            .values()
263            .filter_map(|skill| skill.location.parent().map(Path::to_path_buf))
264            .collect::<BTreeSet<_>>()
265            .into_iter()
266            .collect()
267    }
268
269    fn section(&self) -> Option<PromptSection> {
270        if self.skills.is_empty() {
271            return None;
272        }
273        let skills = self
274            .skills
275            .values()
276            .map(|skill| {
277                format!(
278                    "- name: {}\n  description: {}\n  location: {}",
279                    prompt_value(&skill.name),
280                    prompt_value(&skill.description),
281                    prompt_value(&skill.location.display().to_string())
282                )
283            })
284            .collect::<Vec<_>>()
285            .join("\n");
286        Some(PromptSection::new(format!(
287            "{}\n\n{skills}",
288            self.prompt.trim()
289        )))
290    }
291
292    async fn run_hooks(
293        &self,
294        event: hooks::HookEvent,
295        input: Value,
296        matcher_subjects: &[&str],
297    ) -> Vec<hooks::HookOutcome> {
298        match &self.hook_runtime {
299            Some(runtime) => match runtime
300                .run_all(&self.hooks, event, input, matcher_subjects)
301                .await
302            {
303                Ok(outcomes) => outcomes,
304                Err(error) => vec![hooks::HookOutcome::failed(error.to_string())],
305            },
306            None => Vec::new(),
307        }
308    }
309
310    async fn run_compact_hook(
311        &self,
312        event: hooks::HookEvent,
313        context: &mut CompactContext<'_>,
314    ) -> Result<()> {
315        let mut input = hook_input(
316            context.session_id,
317            context.model,
318            None,
319            Some(context.turn_id),
320        );
321        input.insert("trigger".into(), Value::String("auto".into()));
322        let outcomes = self.run_hooks(event, Value::Object(input), &["auto"]).await;
323        push_hook_notices(context.events, &outcomes);
324        if let Some(outcome) = outcomes
325            .iter()
326            .find(|outcome| outcome.continue_session == Some(false))
327        {
328            context.stop(hook_stop_reason(outcome))?;
329        }
330        Ok(())
331    }
332}
333
334/// Returns whether a package name is canonical for the supported extension formats.
335#[must_use]
336pub fn valid_package_name(name: &str) -> bool {
337    !name.is_empty()
338        && name.len() <= MAX_PLUGIN_ID_BYTES
339        && name.bytes().all(|byte| {
340            byte.is_ascii_lowercase() || byte.is_ascii_digit() || matches!(byte, b'-' | b'.')
341        })
342        && name
343            .as_bytes()
344            .first()
345            .is_some_and(u8::is_ascii_alphanumeric)
346        && name
347            .as_bytes()
348            .last()
349            .is_some_and(u8::is_ascii_alphanumeric)
350        && !name.contains("--")
351        && !name.contains("..")
352}
353
354fn canonical_directory(path: &Path, name: &str) -> Result<PathBuf> {
355    let path = std::fs::canonicalize(path)?;
356    if !path.is_dir() {
357        return Err(Error::Config(format!(
358            "{name} is not a directory: {}",
359            path.display()
360        )));
361    }
362    Ok(path)
363}
364
365fn confined_path(root: &Path, value: &str, require_dot_prefix: bool) -> Result<PathBuf> {
366    if value.is_empty() || (require_dot_prefix && !value.starts_with("./")) {
367        return Err(Error::Config(format!(
368            "plugin path `{value}` must start with `./`"
369        )));
370    }
371    let relative = Path::new(value);
372    if relative.is_absolute()
373        || relative
374            .components()
375            .any(|part| !matches!(part, Component::Normal(_) | Component::CurDir))
376    {
377        return Err(Error::Config(format!(
378            "plugin path `{value}` is not confined to its package"
379        )));
380    }
381    let mut current = root.to_path_buf();
382    for part in relative.components() {
383        let Component::Normal(part) = part else {
384            continue;
385        };
386        current.push(part);
387        if std::fs::symlink_metadata(&current)?
388            .file_type()
389            .is_symlink()
390        {
391            return Err(Error::Config(format!(
392                "plugin path `{value}` contains a symlink"
393            )));
394        }
395    }
396    let path = std::fs::canonicalize(current)?;
397    if !path.starts_with(root) {
398        return Err(Error::Config(format!(
399            "plugin path `{value}` escapes its package"
400        )));
401    }
402    Ok(path)
403}
404
405fn read_bounded_file(path: &Path, max_bytes: u64, name: &str) -> Result<Vec<u8>> {
406    if !std::fs::symlink_metadata(path)?.file_type().is_file() {
407        return Err(Error::Config(format!(
408            "{name} is not a regular file: {}",
409            path.display()
410        )));
411    }
412    let file = File::open(path)?;
413    if !file.metadata()?.is_file() {
414        return Err(Error::Config(format!(
415            "{name} is not a regular file: {}",
416            path.display()
417        )));
418    }
419    let mut bytes = Vec::new();
420    file.take(max_bytes + 1).read_to_end(&mut bytes)?;
421    if bytes.len() as u64 > max_bytes {
422        return Err(Error::Config(format!("{name} exceeds {max_bytes} bytes")));
423    }
424    Ok(bytes)
425}
426
427fn discover_roots(
428    roots: impl IntoIterator<Item = PathBuf>,
429    skills: &mut BTreeMap<String, Skill>,
430    namespace: Option<&str>,
431    keep_existing: bool,
432) -> Result<()> {
433    for root in roots {
434        let root_path = match std::fs::canonicalize(&root) {
435            Ok(path) => path,
436            Err(error) if error.kind() == std::io::ErrorKind::NotFound => continue,
437            Err(error) => return Err(error.into()),
438        };
439        let root = match Dir::open_ambient_dir(&root_path, ambient_authority()) {
440            Ok(root) => root,
441            Err(error) if error.kind() == std::io::ErrorKind::NotFound => continue,
442            Err(error) => return Err(error.into()),
443        };
444        let mut directories = root
445            .entries()?
446            .map(|entry| entry.map(|entry| PathBuf::from(entry.file_name())))
447            .collect::<std::io::Result<Vec<_>>>()?;
448        directories.sort();
449        for directory_path in directories {
450            let directory = match root.open_dir(&directory_path) {
451                Ok(directory) => directory,
452                Err(error)
453                    if matches!(
454                        error.kind(),
455                        std::io::ErrorKind::NotFound | std::io::ErrorKind::NotADirectory
456                    ) =>
457                {
458                    continue;
459                }
460                Err(error) => return Err(error.into()),
461            };
462            let metadata = match directory.metadata(SKILL_FILE) {
463                Ok(metadata) => metadata,
464                Err(error)
465                    if matches!(
466                        error.kind(),
467                        std::io::ErrorKind::NotFound | std::io::ErrorKind::NotADirectory
468                    ) =>
469                {
470                    continue;
471                }
472                Err(error) => return Err(error.into()),
473            };
474            if !metadata.is_file() {
475                continue;
476            }
477            let content = read_skill_resource(&directory, Path::new(SKILL_FILE))?;
478            let skill_path = root_path.join(&directory_path).join(SKILL_FILE);
479            let (name, description) = skill_metadata(&skill_path, &content);
480            let name = namespace.map_or(name.clone(), |namespace| format!("{namespace}:{name}"));
481            if skills.contains_key(&name) {
482                if keep_existing {
483                    continue;
484                }
485                return Err(Error::Duplicate(format!("skill `{name}`")));
486            }
487            if skills.len() == MAX_SKILLS {
488                return Err(Error::Config(format!("skill count exceeds {MAX_SKILLS}")));
489            }
490            skills.insert(
491                name.clone(),
492                Skill {
493                    name,
494                    description,
495                    location: skill_path,
496                },
497            );
498        }
499    }
500    Ok(())
501}
502
503fn prompt_value(value: &str) -> String {
504    Value::String(value.into()).to_string()
505}
506
507fn installed_skill_roots() -> Vec<PathBuf> {
508    let home = env::var_os("HOME")
509        .or_else(|| env::var_os("USERPROFILE"))
510        .map(PathBuf::from);
511    let codex_home = env::var_os("CODEX_HOME").map(PathBuf::from);
512    installed_skill_roots_from(home, codex_home)
513}
514
515fn installed_skill_roots_from(home: Option<PathBuf>, codex_home: Option<PathBuf>) -> Vec<PathBuf> {
516    let codex_home = codex_home.or_else(|| home.as_ref().map(|path| path.join(".codex")));
517    let mut roots = Vec::new();
518    if let Some(home) = home {
519        roots.push(home.join(".agents/skills"));
520    }
521    if let Some(codex_home) = codex_home {
522        roots.push(codex_home.join("skills"));
523        roots.push(codex_home.join("skills/.system"));
524    }
525    #[cfg(unix)]
526    roots.push(PathBuf::from("/etc/codex/skills"));
527    roots
528}
529
530fn hook_input(
531    session_id: &str,
532    model: &str,
533    approval_policy: Option<ApprovalPolicy>,
534    turn_id: Option<&str>,
535) -> serde_json::Map<String, Value> {
536    let mut input = serde_json::Map::from_iter([
537        ("session_id".into(), Value::String(session_id.into())),
538        ("transcript_path".into(), Value::Null),
539        ("model".into(), Value::String(model.into())),
540    ]);
541    if let Some(approval_policy) = approval_policy {
542        input.insert(
543            "permission_mode".into(),
544            Value::String(hooks::permission_mode(approval_policy).into()),
545        );
546    }
547    if let Some(turn_id) = turn_id {
548        input.insert("turn_id".into(), Value::String(turn_id.into()));
549    }
550    input
551}
552
553fn hook_notices(outcomes: &[hooks::HookOutcome]) -> Vec<String> {
554    let mut messages = Vec::new();
555    for outcome in outcomes {
556        for message in [&outcome.failure, &outcome.system_message]
557            .into_iter()
558            .flatten()
559        {
560            if messages.len() == MAX_HOOK_NOTICES {
561                messages.push("additional extension hook notices were omitted".into());
562                return messages;
563            }
564            messages.push(message.clone());
565        }
566    }
567    messages
568}
569
570fn push_hook_notices(events: &mut Vec<EventMsg>, outcomes: &[hooks::HookOutcome]) {
571    events.extend(
572        hook_notices(outcomes)
573            .into_iter()
574            .map(|message| EventMsg::Warning(WarningEvent { message })),
575    );
576}
577
578fn publish_hook_notices(runtime: &RuntimeContext, outcomes: &[hooks::HookOutcome]) -> Result<()> {
579    for message in hook_notices(outcomes) {
580        for event in
581            super::MiddlewareCommandOutput::render(MANIFEST.id, message, FrontendTone::Warning)
582                .events
583        {
584            (runtime.frontend)(event)?;
585        }
586    }
587    Ok(())
588}
589
590fn hook_context(outcomes: &[hooks::HookOutcome]) -> Option<String> {
591    let context = outcomes
592        .iter()
593        .filter_map(|outcome| outcome.additional_context.as_deref())
594        .collect::<Vec<_>>()
595        .join("\n\n");
596    (!context.is_empty()).then(|| truncate_utf8(&context, MAX_HOOK_CONTEXT_BYTES).into())
597}
598
599fn hook_stop_reason(outcome: &hooks::HookOutcome) -> String {
600    [
601        outcome.failure.as_deref(),
602        outcome.reason.as_deref(),
603        outcome.stop_reason.as_deref(),
604        outcome.additional_context.as_deref(),
605    ]
606    .into_iter()
607    .flatten()
608    .find(|value| !value.trim().is_empty())
609    .unwrap_or("stopped by extension hook")
610    .into()
611}
612
613impl Middleware for Extensions {
614    fn name(&self) -> &'static str {
615        MANIFEST.id
616    }
617
618    fn prompt_section(&self, _runtime: &super::RuntimeContext) -> Result<Option<PromptSection>> {
619        Ok(self.section())
620    }
621
622    fn session_start<'a>(
623        &'a self,
624        context: &'a mut SessionStartContext<'_>,
625    ) -> BoxFuture<'a, Result<()>> {
626        Box::pin(async move {
627            let source = match context.source() {
628                SessionStartSource::Startup => "startup",
629                SessionStartSource::Resume => "resume",
630                SessionStartSource::Compact => "compact",
631            };
632            let (event, input, subjects) = match &context.runtime.role {
633                AgentRole::Main => {
634                    let mut input = hook_input(
635                        &context.runtime.session_id,
636                        &context.runtime.model,
637                        Some(context.runtime.approval_policy),
638                        None,
639                    );
640                    input.insert("source".into(), Value::String(source.into()));
641                    (hooks::HookEvent::SessionStart, input, vec![source])
642                }
643                AgentRole::Subagent { .. } if context.source() == SessionStartSource::Compact => {
644                    return Ok(());
645                }
646                AgentRole::Subagent {
647                    parent_session_id,
648                    parent_turn_id,
649                } => {
650                    let mut input = hook_input(
651                        parent_session_id,
652                        &context.runtime.model,
653                        Some(context.runtime.approval_policy),
654                        Some(parent_turn_id),
655                    );
656                    input.extend([
657                        (
658                            "agent_id".into(),
659                            Value::String(context.runtime.session_id.clone()),
660                        ),
661                        ("agent_type".into(), Value::String("subagent".into())),
662                    ]);
663                    (hooks::HookEvent::SubagentStart, input, vec!["subagent"])
664                }
665            };
666            let outcomes = self.run_hooks(event, Value::Object(input), &subjects).await;
667            publish_hook_notices(context.runtime, &outcomes)?;
668            context.retain_input(|item| {
669                internal_message_kind(item) != Some(SESSION_HOOK_CONTEXT_KIND)
670            });
671            if let Some(additional) = hook_context(&outcomes) {
672                context.push_input(internal_user_message(
673                    SESSION_HOOK_CONTEXT_KIND,
674                    &additional,
675                ));
676            }
677            if let Some(outcome) = outcomes
678                .iter()
679                .find(|outcome| outcome.continue_session == Some(false))
680            {
681                context.stop(hook_stop_reason(outcome))?;
682            }
683            Ok(())
684        })
685    }
686
687    fn message_submit<'a>(
688        &'a self,
689        context: &'a mut MessageSubmitContext<'_>,
690    ) -> BoxFuture<'a, Result<()>> {
691        Box::pin(async move {
692            if !matches!(context.author, crate::protocol::MessageAuthor::User) {
693                return Ok(());
694            }
695            let mut input = hook_input(
696                context.turn.session_id,
697                context.turn.model,
698                Some(context.turn.approval_policy),
699                Some(context.turn.turn_id),
700            );
701            input.extend([("prompt".into(), Value::String(context.message.into()))]);
702            let outcomes = self
703                .run_hooks(
704                    hooks::HookEvent::UserPromptSubmit,
705                    Value::Object(input),
706                    &[],
707                )
708                .await;
709            push_hook_notices(context.events, &outcomes);
710            if let Some(outcome) = outcomes.iter().find(|outcome| {
711                outcome.decision == Some(hooks::HookDecision::Block)
712                    || outcome.continue_session == Some(false)
713            }) {
714                context.reject(hook_stop_reason(outcome))?;
715            } else if let Some(additional) = hook_context(&outcomes) {
716                context.push_input(internal_user_message("extension_prompt_hook", &additional));
717            }
718            Ok(())
719        })
720    }
721
722    fn pre_tool_use<'a>(
723        &'a self,
724        context: &'a mut PreToolUseContext<'_>,
725    ) -> BoxFuture<'a, Result<()>> {
726        Box::pin(async move {
727            let original_name = context.call().name.clone();
728            let tool = context.tools.hook_tool(context.call(), None);
729            let mut input = hook_input(
730                context.turn.session_id,
731                context.turn.model,
732                Some(context.turn.approval_policy),
733                Some(context.turn.turn_id),
734            );
735            input.extend([
736                ("tool_name".into(), Value::String(tool.name)),
737                (
738                    "tool_use_id".into(),
739                    Value::String(context.call().call_id.clone()),
740                ),
741                ("tool_input".into(), tool.input),
742            ]);
743            let subjects = tool.subjects.iter().map(String::as_str).collect::<Vec<_>>();
744            let outcomes = self
745                .run_hooks(
746                    hooks::HookEvent::PreToolUse,
747                    Value::Object(input),
748                    &subjects,
749                )
750                .await;
751            push_hook_notices(context.events, &outcomes);
752            if let Some(additional) = hook_context(&outcomes) {
753                context.push_input(internal_user_message("extension_tool_hook", &additional));
754            }
755            if let Some(outcome) = outcomes.iter().find(|outcome| {
756                outcome.failure.is_some()
757                    || outcome.permission_decision == Some(hooks::PermissionDecision::Deny)
758                    || outcome.decision == Some(hooks::HookDecision::Block)
759            }) {
760                return context.deny(hook_stop_reason(outcome));
761            }
762            let mut rewrites = outcomes
763                .iter()
764                .filter_map(|outcome| outcome.updated_input.clone());
765            let Some(rewrite) = rewrites.next() else {
766                return Ok(());
767            };
768            if rewrites.any(|candidate| candidate != rewrite) {
769                return context.deny("conflicting extension hook tool rewrites");
770            }
771            match context
772                .tools
773                .rewrite_hook_input(&original_name, rewrite)
774                .and_then(|arguments| context.replace(original_name, arguments))
775            {
776                Ok(()) => Ok(()),
777                Err(error) => context.deny(error.to_string()),
778            }
779        })
780    }
781
782    fn permission_request<'a>(
783        &'a self,
784        context: &'a mut PermissionRequestContext<'_>,
785    ) -> BoxFuture<'a, Result<()>> {
786        Box::pin(async move {
787            let mut all_allowed = !context.requested_call_ids.is_empty();
788            for call_id in context.requested_call_ids {
789                let call = context
790                    .calls
791                    .iter()
792                    .find(|call| &call.call_id == call_id)
793                    .ok_or_else(|| Error::Config("approval hook call is missing".into()))?;
794                let tool = context.tools.hook_tool(call, Some(context.reason));
795                let mut input = hook_input(
796                    context.turn.session_id,
797                    context.turn.model,
798                    Some(context.turn.approval_policy),
799                    Some(context.turn.turn_id),
800                );
801                input.extend([
802                    ("tool_name".into(), Value::String(tool.name)),
803                    ("tool_input".into(), tool.input),
804                ]);
805                let subjects = tool.subjects.iter().map(String::as_str).collect::<Vec<_>>();
806                let outcomes = self
807                    .run_hooks(
808                        hooks::HookEvent::PermissionRequest,
809                        Value::Object(input),
810                        &subjects,
811                    )
812                    .await;
813                push_hook_notices(context.events, &outcomes);
814                if let Some(outcome) = outcomes.iter().find(|outcome| {
815                    outcome.failure.is_some()
816                        || outcome.permission_decision == Some(hooks::PermissionDecision::Deny)
817                }) {
818                    return context.deny(hook_stop_reason(outcome));
819                }
820                all_allowed &= outcomes.iter().any(|outcome| {
821                    outcome.permission_decision == Some(hooks::PermissionDecision::Allow)
822                });
823            }
824            if all_allowed {
825                context.allow();
826            }
827            Ok(())
828        })
829    }
830
831    fn post_tool_use<'a>(
832        &'a self,
833        context: &'a mut PostToolUseContext<'_>,
834    ) -> BoxFuture<'a, Result<()>> {
835        Box::pin(async move {
836            let tool = context.tools.hook_tool(context.call, None);
837            let mut input = hook_input(
838                context.turn.session_id,
839                context.turn.model,
840                Some(context.turn.approval_policy),
841                Some(context.turn.turn_id),
842            );
843            input.extend([
844                ("tool_name".into(), Value::String(tool.name)),
845                (
846                    "tool_use_id".into(),
847                    Value::String(context.call.call_id.clone()),
848                ),
849                ("tool_input".into(), tool.input),
850                (
851                    "tool_response".into(),
852                    Value::String(context.result().output.clone()),
853                ),
854            ]);
855            let subjects = tool.subjects.iter().map(String::as_str).collect::<Vec<_>>();
856            let outcomes = self
857                .run_hooks(
858                    hooks::HookEvent::PostToolUse,
859                    Value::Object(input),
860                    &subjects,
861                )
862                .await;
863            push_hook_notices(context.events, &outcomes);
864            if let Some(outcome) = outcomes.iter().find(|outcome| {
865                outcome.decision == Some(hooks::HookDecision::Block)
866                    || outcome.continue_session == Some(false)
867            }) {
868                context.replace(hook_stop_reason(outcome));
869            }
870            if let Some(additional) = hook_context(&outcomes) {
871                context.push_input(internal_user_message("extension_tool_hook", &additional));
872            }
873            Ok(())
874        })
875    }
876
877    fn pre_compact<'a>(&'a self, context: &'a mut CompactContext<'_>) -> BoxFuture<'a, Result<()>> {
878        Box::pin(async move {
879            self.run_compact_hook(hooks::HookEvent::PreCompact, context)
880                .await
881        })
882    }
883
884    fn post_compact<'a>(
885        &'a self,
886        context: &'a mut CompactContext<'_>,
887    ) -> BoxFuture<'a, Result<()>> {
888        Box::pin(async move {
889            self.run_compact_hook(hooks::HookEvent::PostCompact, context)
890                .await
891        })
892    }
893
894    fn stop<'a>(&'a self, context: &'a mut StopContext<'_>) -> BoxFuture<'a, Result<()>> {
895        Box::pin(async move {
896            let (event, mut input, subjects) = match context.role() {
897                AgentRole::Main => (
898                    hooks::HookEvent::Stop,
899                    hook_input(
900                        context.turn.session_id,
901                        context.turn.model,
902                        Some(context.turn.approval_policy),
903                        Some(context.turn.turn_id),
904                    ),
905                    Vec::new(),
906                ),
907                AgentRole::Subagent {
908                    parent_session_id,
909                    parent_turn_id,
910                } => {
911                    let mut input = hook_input(
912                        parent_session_id,
913                        context.turn.model,
914                        Some(context.turn.approval_policy),
915                        Some(parent_turn_id),
916                    );
917                    input.extend([
918                        (
919                            "agent_id".into(),
920                            Value::String(context.turn.session_id.into()),
921                        ),
922                        ("agent_type".into(), Value::String("subagent".into())),
923                        ("agent_transcript_path".into(), Value::Null),
924                    ]);
925                    (hooks::HookEvent::SubagentStop, input, vec!["subagent"])
926                }
927            };
928            input.extend([
929                (
930                    "stop_hook_active".into(),
931                    Value::Bool(context.stop_hook_active()),
932                ),
933                (
934                    "last_assistant_message".into(),
935                    context
936                        .last_assistant_message()
937                        .map_or(Value::Null, |message| Value::String(message.into())),
938                ),
939            ]);
940            let outcomes = self.run_hooks(event, Value::Object(input), &subjects).await;
941            push_hook_notices(context.events, &outcomes);
942            if outcomes
943                .iter()
944                .any(|outcome| outcome.continue_session == Some(false))
945            {
946                return Ok(());
947            }
948            if let Some(outcome) = outcomes
949                .iter()
950                .find(|outcome| outcome.decision == Some(hooks::HookDecision::Block))
951            {
952                if context.stop_hook_active() {
953                    context.events.push(EventMsg::Warning(WarningEvent {
954                        message: "extension stop hook cannot continue a turn twice".into(),
955                    }));
956                } else {
957                    context.continue_with(hook_stop_reason(outcome))?;
958                }
959            }
960            Ok(())
961        })
962    }
963
964    fn session_end<'a>(&'a self, runtime: &'a RuntimeContext) -> BoxFuture<'a, Result<()>> {
965        Box::pin(async move {
966            if runtime.role == AgentRole::Main {
967                let mut input = hook_input(&runtime.session_id, &runtime.model, None, None);
968                input.insert("reason".into(), Value::String("other".into()));
969                let outcomes = self
970                    .run_hooks(
971                        hooks::HookEvent::SessionEnd,
972                        Value::Object(input),
973                        &["other"],
974                    )
975                    .await;
976                publish_hook_notices(runtime, &outcomes)?;
977            }
978            Ok(())
979        })
980    }
981
982    fn frontend(&self) -> FrontendContribution {
983        let count = self.plugins.len()
984            + self
985                .skills
986                .keys()
987                .filter(|name| {
988                    name.split_once(':')
989                        .is_none_or(|(plugin, _)| !self.plugins.contains(plugin))
990                })
991                .count();
992        FrontendContribution {
993            capability: self.name().into(),
994            accepts_file_attachments: false,
995            count: Some(count),
996            commands: Vec::new(),
997            widgets: vec![FrontendWidget {
998                id: "count".into(),
999                slot: FrontendSlot::Header,
1000                text: format!("extensions {count}"),
1001                tone: FrontendTone::Neutral,
1002                symbol: None,
1003                icon_only: false,
1004                progress: None,
1005                content: None,
1006                action: None,
1007            }],
1008            references: self
1009                .skills
1010                .values()
1011                .map(|skill| FrontendReference {
1012                    trigger: '$',
1013                    value: skill.name.clone(),
1014                    description: skill.description.clone(),
1015                })
1016                .collect(),
1017        }
1018    }
1019}
1020
1021fn read_skill_resource(directory: &Dir, path: &Path) -> Result<String> {
1022    if path.as_os_str().is_empty()
1023        || path.is_absolute()
1024        || path
1025            .components()
1026            .any(|part| !matches!(part, Component::Normal(_) | Component::CurDir))
1027    {
1028        return Err(unavailable_skill_resource());
1029    }
1030    // Avoid blocking on static special files, then verify the opened handle again.
1031    if !directory
1032        .metadata(path)
1033        .map_err(|_| unavailable_skill_resource())?
1034        .is_file()
1035    {
1036        return Err(unavailable_skill_resource());
1037    }
1038    let file = directory
1039        .open(path)
1040        .map_err(|_| unavailable_skill_resource())?;
1041    if !file
1042        .metadata()
1043        .map_err(|_| unavailable_skill_resource())?
1044        .is_file()
1045    {
1046        return Err(unavailable_skill_resource());
1047    }
1048    let mut bytes = Vec::new();
1049    file.take(MAX_SKILL_BYTES + 1)
1050        .read_to_end(&mut bytes)
1051        .map_err(|_| unavailable_skill_resource())?;
1052    if bytes.len() as u64 > MAX_SKILL_BYTES {
1053        return Err(Error::Tool(format!(
1054            "skill resource exceeds {MAX_SKILL_BYTES} bytes"
1055        )));
1056    }
1057    String::from_utf8(bytes).map_err(|_| Error::Tool("skill resource is not valid UTF-8".into()))
1058}
1059
1060fn unavailable_skill_resource() -> Error {
1061    Error::Tool("skill resource is unavailable".into())
1062}
1063
1064fn skill_metadata(path: &std::path::Path, content: &str) -> (String, String) {
1065    let fallback = path
1066        .parent()
1067        .and_then(std::path::Path::file_name)
1068        .map_or_else(
1069            || "skill".into(),
1070            |name| name.to_string_lossy().into_owned(),
1071        );
1072    let frontmatter = content
1073        .strip_prefix("---\n")
1074        .and_then(|content| content.split("\n---").next());
1075    let name = frontmatter.and_then(|value| frontmatter_value(value, "name"));
1076    let description = frontmatter.and_then(|value| frontmatter_value(value, "description"));
1077    (
1078        name.filter(|value| !value.is_empty()).unwrap_or(fallback),
1079        description
1080            .filter(|value| !value.is_empty())
1081            .unwrap_or_else(|| text::FALLBACK_SKILL_DESCRIPTION.into())
1082            .chars()
1083            .take(500)
1084            .collect(),
1085    )
1086}
1087
1088fn frontmatter_value(frontmatter: &str, key: &str) -> Option<String> {
1089    let lines = frontmatter.lines().collect::<Vec<_>>();
1090    let (index, value) = lines.iter().enumerate().find_map(|(index, line)| {
1091        line.strip_prefix(key)
1092            .and_then(|line| line.strip_prefix(':'))
1093            .map(|value| (index, value.trim()))
1094    })?;
1095    if !matches!(value, ">" | ">-" | ">+" | "|" | "|-" | "|+") {
1096        return Some(unquote(value));
1097    }
1098    let literal = value.starts_with('|');
1099    let values = lines[index + 1..]
1100        .iter()
1101        .take_while(|line| line.is_empty() || line.starts_with(' ') || line.starts_with('\t'))
1102        .map(|line| line.trim())
1103        .filter(|line| !line.is_empty())
1104        .collect::<Vec<_>>();
1105    Some(if literal {
1106        values.join("\n")
1107    } else {
1108        values.join(" ")
1109    })
1110}
1111
1112fn unquote(value: &str) -> String {
1113    value
1114        .trim()
1115        .trim_matches(|character| character == '"' || character == '\'')
1116        .to_string()
1117}
1118
1119#[cfg(test)]
1120mod tests {
1121    use super::*;
1122    use crate::middleware::TurnIdentity;
1123    use std::sync::Mutex;
1124
1125    fn trusted_hooks() -> Option<HookAuthorization> {
1126        Some(Arc::new(|launch| launch()))
1127    }
1128
1129    #[test]
1130    fn prompt_section_is_absent_without_skills() {
1131        let temporary = tempfile::tempdir().expect("temporary skills");
1132        let extensions =
1133            Extensions::discover([temporary.path().to_path_buf()]).expect("empty skills");
1134
1135        assert_eq!(extensions.section(), None);
1136    }
1137
1138    #[test]
1139    fn prompt_section_advertises_locations_in_skill_name_order() {
1140        let temporary = tempfile::tempdir().expect("temporary skills");
1141        let root = temporary.path().join("skills");
1142        write_skill(&root, "second", "zebra", "Last alphabetically");
1143        write_skill(&root, "first", "alpha", "First alphabetically");
1144        let root = std::fs::canonicalize(root).expect("canonical skills");
1145        let extensions = Extensions::discover([root.clone()]).expect("skills");
1146        let alpha = root.join("first/SKILL.md");
1147        let zebra = root.join("second/SKILL.md");
1148
1149        assert_eq!(
1150            extensions.section(),
1151            Some(PromptSection::new(format!(
1152                "{}\n\n- name: \"alpha\"\n  description: \"First alphabetically\"\n  location: {}\n- name: \"zebra\"\n  description: \"Last alphabetically\"\n  location: {}",
1153                text::PROMPT_DEFAULT,
1154                prompt_value(&alpha.display().to_string()),
1155                prompt_value(&zebra.display().to_string())
1156            )))
1157        );
1158        assert_eq!(
1159            extensions.resource_roots(),
1160            vec![root.join("first"), root.join("second")]
1161        );
1162    }
1163
1164    #[test]
1165    fn blank_hook_stop_fields_use_the_default_reason() {
1166        for outcome in [
1167            hooks::HookOutcome {
1168                reason: Some(" \n".into()),
1169                ..Default::default()
1170            },
1171            hooks::HookOutcome {
1172                stop_reason: Some("\t".into()),
1173                ..Default::default()
1174            },
1175            hooks::HookOutcome {
1176                additional_context: Some("  ".into()),
1177                ..Default::default()
1178            },
1179        ] {
1180            assert_eq!(hook_stop_reason(&outcome), "stopped by extension hook");
1181        }
1182    }
1183
1184    #[test]
1185    fn duplicate_skill_names_are_rejected() {
1186        let temporary = tempfile::tempdir().expect("temporary skills");
1187        let first = temporary.path().join("first");
1188        let second = temporary.path().join("second");
1189        write_skill(&first, "shared", "shared", "first");
1190        write_skill(&second, "shared", "shared", "second");
1191
1192        let error = match Extensions::discover([first, second]) {
1193            Ok(_) => panic!("duplicate skill was accepted"),
1194            Err(error) => error,
1195        };
1196
1197        assert!(matches!(error, Error::Duplicate(message) if message == "skill `shared`"));
1198    }
1199
1200    #[test]
1201    fn installed_skills_do_not_replace_explicit_skills() {
1202        let temporary = tempfile::tempdir().expect("temporary skills");
1203        let explicit = temporary.path().join("explicit");
1204        let installed = temporary.path().join("installed");
1205        write_skill(&explicit, "shared", "shared", "explicit");
1206        write_skill(&installed, "shared", "shared", "installed");
1207        write_skill(&installed, "global", "global", "installed");
1208        let mut discovered = Extensions::discover([explicit]).expect("explicit skills");
1209
1210        discover_roots([installed], &mut discovered.skills, None, true).expect("installed skills");
1211
1212        assert_eq!(
1213            discovered
1214                .skills
1215                .iter()
1216                .map(|(name, skill)| (name.as_str(), skill.description.as_str()))
1217                .collect::<Vec<_>>(),
1218            vec![("global", "installed"), ("shared", "explicit")]
1219        );
1220    }
1221
1222    #[test]
1223    fn activated_plugin_namespaces_bundled_skills_and_folds_descriptions() {
1224        use crate::backend::sandbox::local::LocalSandbox;
1225
1226        let temporary = tempfile::tempdir().expect("temporary extensions");
1227        let workspace = temporary.path().join("workspace");
1228        std::fs::create_dir(&workspace).expect("workspace");
1229        let plugin = temporary.path().join("ponytail");
1230        std::fs::create_dir_all(plugin.join(".codex-plugin")).expect("plugin manifest directory");
1231        std::fs::write(
1232            plugin.join(".codex-plugin/plugin.json"),
1233            r#"{
1234                "name": "ponytail",
1235                "version": "4.9.0",
1236                "description": "Minimal coding workflows",
1237                "skills": "./skills/"
1238            }"#,
1239        )
1240        .expect("plugin manifest");
1241        let skill = plugin.join("skills/review");
1242        std::fs::create_dir_all(&skill).expect("plugin skill");
1243        std::fs::write(
1244            skill.join(SKILL_FILE),
1245            "---\nname: review\ndescription: >\n  Find unnecessary abstractions and\n  remove them.\n---\n",
1246        )
1247        .expect("plugin skill manifest");
1248        let backend = Arc::new(LocalSandbox::new(&workspace).expect("sandbox"));
1249
1250        let extensions = Extensions::discover(Vec::<PathBuf>::new())
1251            .expect("extensions")
1252            .activate_plugins([(plugin, trusted_hooks())], &workspace, backend)
1253            .expect("activate plugin");
1254
1255        let skill = extensions
1256            .skills
1257            .get("ponytail:review")
1258            .expect("namespaced skill");
1259        assert_eq!(
1260            skill.description,
1261            "Find unnecessary abstractions and remove them."
1262        );
1263        assert_eq!(extensions.plugins, BTreeSet::from(["ponytail".into()]));
1264    }
1265
1266    #[test]
1267    fn untrusted_plugin_keeps_bundled_skills_without_loading_hooks() {
1268        use crate::backend::sandbox::local::LocalSandbox;
1269
1270        let temporary = tempfile::tempdir().expect("temporary extensions");
1271        let workspace = temporary.path().join("workspace");
1272        std::fs::create_dir(&workspace).expect("workspace");
1273        let plugin = temporary.path().join("ponytail");
1274        std::fs::create_dir_all(plugin.join(".codex-plugin")).expect("manifest directory");
1275        std::fs::create_dir_all(plugin.join("skills/review")).expect("plugin skill");
1276        std::fs::create_dir_all(plugin.join("hooks")).expect("hooks directory");
1277        std::fs::write(
1278            plugin.join(".codex-plugin/plugin.json"),
1279            r#"{"name":"ponytail","skills":"./skills","hooks":"./hooks/hooks.json"}"#,
1280        )
1281        .expect("plugin manifest");
1282        std::fs::write(
1283            plugin.join("skills/review/SKILL.md"),
1284            "---\nname: review\ndescription: Review code.\n---\n",
1285        )
1286        .expect("skill manifest");
1287        std::fs::write(
1288            plugin.join("hooks/hooks.json"),
1289            r#"{"hooks":{"SessionStart":[{"hooks":[{"type":"command","command":"true"}]}]}}"#,
1290        )
1291        .expect("hook manifest");
1292        let backend = Arc::new(LocalSandbox::new(&workspace).expect("sandbox"));
1293
1294        let extensions = Extensions::discover(Vec::<PathBuf>::new())
1295            .expect("extensions")
1296            .activate_plugins([(plugin, None)], &workspace, backend)
1297            .expect("activate plugin skills");
1298
1299        assert!(extensions.skills.contains_key("ponytail:review"));
1300        assert!(extensions.hooks.is_empty());
1301    }
1302
1303    #[tokio::test]
1304    async fn ponytail_shaped_session_hook_adds_hidden_context() {
1305        use crate::backend::checkpoint::sqlite::SqliteCheckpoint;
1306        use crate::backend::sandbox::local::LocalSandbox;
1307        use crate::protocol::SessionContext;
1308
1309        let temporary = tempfile::tempdir_in(std::env::current_dir().expect("current directory"))
1310            .expect("temporary extensions");
1311        let workspace = temporary.path().join("workspace");
1312        std::fs::create_dir(&workspace).expect("workspace");
1313        let plugin = temporary.path().join("ponytail");
1314        std::fs::create_dir_all(plugin.join(".codex-plugin")).expect("manifest directory");
1315        std::fs::create_dir_all(plugin.join("hooks")).expect("hooks directory");
1316        std::fs::write(
1317            plugin.join(".codex-plugin/plugin.json"),
1318            r#"{"name":"ponytail","version":"4.9.0","hooks":"./hooks/hooks.json"}"#,
1319        )
1320        .expect("plugin manifest");
1321        std::fs::write(
1322            plugin.join("hooks/hooks.json"),
1323            r#"{"description":"Ponytail activation","hooks":{"SessionStart":[{"matcher":"startup|resume|compact","hooks":[{"type":"command","command":"sh \"${CLAUDE_PLUGIN_ROOT}/hooks/activate.sh\"","timeout":5}]}]}}"#,
1324        )
1325        .expect("hook manifest");
1326        std::fs::write(
1327            plugin.join("hooks/activate.sh"),
1328            r#"#!/bin/sh
1329payload=$(cat)
1330printf '%s' "$payload" | grep -q '"source":"startup"' || exit 1
1331printf '%s' "$payload" | grep -q '"model":"model"' || exit 1
1332printf '%s' "$payload" | grep -q '"permission_mode":"default"' || exit 1
1333printf '%s\n' '{"systemMessage":"PONYTAIL:FULL","hookSpecificOutput":{"hookEventName":"SessionStart","additionalContext":"Ponytail rules active."}}'
1334"#,
1335        )
1336        .expect("hook script");
1337
1338        let backend = Arc::new(LocalSandbox::new(&workspace).expect("sandbox"));
1339        let extensions = Extensions::discover(Vec::<PathBuf>::new())
1340            .expect("extensions")
1341            .activate_plugins([(plugin, trusted_hooks())], &workspace, backend)
1342            .expect("activate plugin");
1343        let notices = Arc::new(Mutex::new(Vec::new()));
1344        let captured = Arc::clone(&notices);
1345        let runtime = RuntimeContext {
1346            sender: crate::agent::test_sender(),
1347            checkpoints: Arc::new(
1348                SqliteCheckpoint::new(temporary.path().join("checkpoints.sqlite3"))
1349                    .expect("checkpoints"),
1350            ),
1351            session_id: "session".into(),
1352            model_route: "model".into(),
1353            model: "model".into(),
1354            approval_policy: ApprovalPolicy::Ask,
1355            session_context: SessionContext::default(),
1356            metadata: BTreeMap::new(),
1357            role: AgentRole::Main,
1358            frontend: Arc::new(move |event| {
1359                captured.lock().expect("notices").push(event);
1360                Ok(())
1361            }),
1362        };
1363        let mut input = Vec::new();
1364        let mut context = SessionStartContext {
1365            runtime: &runtime,
1366            source: SessionStartSource::Startup,
1367            queued_messages: Default::default(),
1368            input: &mut input,
1369            input_changed: false,
1370            stop_reason: None,
1371        };
1372
1373        extensions
1374            .session_start(&mut context)
1375            .await
1376            .expect("session hook");
1377
1378        assert_eq!(
1379            input
1380                .iter()
1381                .filter(|item| {
1382                    crate::protocol::internal_message_kind(item) == Some(SESSION_HOOK_CONTEXT_KIND)
1383                })
1384                .count(),
1385            1
1386        );
1387        assert!(input[0].to_string().contains("Ponytail rules active."));
1388        assert_eq!(notices.lock().expect("notices").len(), 1);
1389    }
1390
1391    #[tokio::test]
1392    async fn pre_tool_failures_deny_automatically_approved_calls() {
1393        let cases = [
1394            r#"{"PreToolUse":[{"hooks":[{"type":"command","command":"printf '{'","timeout":1}]}]}"#,
1395            r#"{"PreToolUse":[{"hooks":[{"type":"command","command":"sleep 2","timeout":1}]}]}"#,
1396            r#"{"PreToolUse":[{"hooks":[{"type":"command","command":"exit 1","timeout":1}]}]}"#,
1397            r#"{"PreToolUse":[{"hooks":[{"type":"command","command":"printf '%s' '{\"hookSpecificOutput\":{\"hookEventName\":\"PreToolUse\",\"permissionDecision\":\"allow\",\"updatedInput\":{}}}'","timeout":1}]}]}"#,
1398        ];
1399
1400        for hooks in cases {
1401            let temporary = tempfile::tempdir().expect("temporary extensions");
1402            let extensions = extension_with_hooks(&temporary, hooks);
1403            let tools = coding_catalog(&temporary);
1404            let original = crate::backend::model::ToolCall {
1405                call_id: "call".into(),
1406                name: "bash".into(),
1407                arguments: serde_json::json!({"command": "touch marker"}),
1408            };
1409            let (denial, call) = run_pre_tool(&extensions, &tools, original.clone()).await;
1410
1411            assert!(denial.is_some());
1412            assert_eq!(call, original);
1413        }
1414    }
1415
1416    #[tokio::test]
1417    async fn permission_allow_does_not_override_a_failed_hook() {
1418        let temporary = tempfile::tempdir().expect("temporary extensions");
1419        let extensions = extension_with_hooks(
1420            &temporary,
1421            r#"{"PermissionRequest":[{"hooks":[{"type":"command","command":"printf '%s' '{\"hookSpecificOutput\":{\"hookEventName\":\"PermissionRequest\",\"decision\":{\"behavior\":\"allow\"}}}'","timeout":1},{"type":"command","command":"printf '{'","timeout":1}]}]}"#,
1422        );
1423        let tools = crate::middleware::tools::Catalog::default();
1424        let calls = [crate::backend::model::ToolCall {
1425            call_id: "call".into(),
1426            name: "tool".into(),
1427            arguments: serde_json::json!({}),
1428        }];
1429        let requested_call_ids = ["call".into()];
1430        let mut events = Vec::new();
1431        let mut context = PermissionRequestContext {
1432            turn: TurnIdentity {
1433                session_id: "session",
1434                turn_id: "turn",
1435                model: "model",
1436                approval_policy: ApprovalPolicy::Allow,
1437            },
1438            calls: &calls,
1439            requested_call_ids: &requested_call_ids,
1440            reason: "test",
1441            events: &mut events,
1442            tools: &tools,
1443            decision: None,
1444        };
1445
1446        extensions
1447            .permission_request(&mut context)
1448            .await
1449            .expect("failed hook should become a denial");
1450
1451        assert!(matches!(
1452            context.decision(),
1453            Some(crate::protocol::ReviewDecision::Denied { .. })
1454        ));
1455    }
1456
1457    fn extension_with_hooks(temporary: &tempfile::TempDir, hooks: &str) -> Extensions {
1458        use crate::backend::sandbox::local::LocalSandbox;
1459
1460        let workspace = temporary.path().join("workspace");
1461        let plugin = temporary.path().join("plugin");
1462        std::fs::create_dir(&workspace).expect("workspace");
1463        std::fs::create_dir_all(plugin.join(".codex-plugin")).expect("manifest directory");
1464        std::fs::create_dir_all(plugin.join("hooks")).expect("hooks directory");
1465        std::fs::write(
1466            plugin.join(".codex-plugin/plugin.json"),
1467            r#"{"name":"plugin","hooks":"./hooks/hooks.json"}"#,
1468        )
1469        .expect("plugin manifest");
1470        std::fs::write(
1471            plugin.join("hooks/hooks.json"),
1472            format!(r#"{{"hooks":{hooks}}}"#),
1473        )
1474        .expect("hook manifest");
1475        let backend = Arc::new(LocalSandbox::new(&workspace).expect("sandbox"));
1476
1477        Extensions::discover(Vec::<PathBuf>::new())
1478            .expect("extensions")
1479            .activate_plugins([(plugin, trusted_hooks())], &workspace, backend)
1480            .expect("activate plugin")
1481    }
1482
1483    fn coding_catalog(temporary: &tempfile::TempDir) -> crate::middleware::tools::Catalog {
1484        use crate::backend::checkpoint::sqlite::SqliteCheckpoint;
1485        use crate::middleware::tools::Tools;
1486        use crate::protocol::SessionContext;
1487
1488        let runtime = RuntimeContext {
1489            sender: crate::agent::test_sender(),
1490            checkpoints: Arc::new(
1491                SqliteCheckpoint::new(temporary.path().join("checkpoints.sqlite3"))
1492                    .expect("checkpoints"),
1493            ),
1494            session_id: "session".into(),
1495            model_route: "model".into(),
1496            model: "model".into(),
1497            approval_policy: ApprovalPolicy::Allow,
1498            session_context: SessionContext::default(),
1499            metadata: BTreeMap::new(),
1500            role: AgentRole::Main,
1501            frontend: Arc::new(|_| Ok(())),
1502        };
1503        let mut catalog = crate::middleware::tools::Catalog::default();
1504        Tools::coding()
1505            .register(&mut catalog, &runtime)
1506            .expect("coding tools");
1507        catalog
1508    }
1509
1510    async fn run_pre_tool(
1511        extensions: &Extensions,
1512        tools: &crate::middleware::tools::Catalog,
1513        mut call: crate::backend::model::ToolCall,
1514    ) -> (Option<String>, crate::backend::model::ToolCall) {
1515        let mut events = Vec::new();
1516        let mut context = PreToolUseContext {
1517            turn: TurnIdentity {
1518                session_id: "session",
1519                turn_id: "turn",
1520                model: "model",
1521                approval_policy: ApprovalPolicy::Allow,
1522            },
1523            events: &mut events,
1524            tools,
1525            call: &mut call,
1526            input: Vec::new(),
1527            denial: None,
1528        };
1529        extensions
1530            .pre_tool_use(&mut context)
1531            .await
1532            .expect("failed hook should become a denial");
1533        let denial = context.denial().map(str::to_owned);
1534        drop(context);
1535        (denial, call)
1536    }
1537
1538    #[test]
1539    fn plugin_snapshot_cannot_share_the_writable_workspace() {
1540        use crate::backend::sandbox::local::LocalSandbox;
1541
1542        let workspace = tempfile::tempdir().expect("workspace");
1543        let plugin = workspace.path().join("plugin");
1544        std::fs::create_dir(&plugin).expect("plugin");
1545        let backend = Arc::new(LocalSandbox::new(workspace.path()).expect("sandbox"));
1546
1547        let error = Extensions::discover(Vec::<PathBuf>::new())
1548            .expect("extensions")
1549            .activate_plugins([(plugin, trusted_hooks())], workspace.path(), backend)
1550            .err()
1551            .expect("overlapping plugin must fail");
1552
1553        assert!(error.to_string().contains("must not overlap"));
1554    }
1555
1556    #[cfg(unix)]
1557    #[test]
1558    fn plugin_data_root_rejects_symlink_without_writing_outside() {
1559        use std::os::unix::fs::symlink;
1560
1561        use crate::backend::sandbox::local::LocalSandbox;
1562
1563        let temporary = tempfile::tempdir().expect("temporary extensions");
1564        let workspace = temporary.path().join("workspace");
1565        let outside = temporary.path().join("outside");
1566        let plugin = temporary.path().join("plugin");
1567        std::fs::create_dir(&workspace).expect("workspace");
1568        std::fs::create_dir(&outside).expect("outside directory");
1569        std::fs::create_dir(&plugin).expect("plugin");
1570        symlink(&outside, workspace.join(".mobius")).expect("symlink data root");
1571        let backend = Arc::new(LocalSandbox::new(&workspace).expect("sandbox"));
1572
1573        let result = Extensions::discover(Vec::<PathBuf>::new())
1574            .expect("extensions")
1575            .activate_plugins([(plugin, trusted_hooks())], &workspace, backend);
1576
1577        assert!(result.is_err());
1578        assert!(!outside.join("extensions").exists());
1579    }
1580
1581    #[test]
1582    fn non_directory_entries_are_ignored() {
1583        let temporary = tempfile::tempdir().expect("temporary skills");
1584        let root = temporary.path().join("skills");
1585        write_skill(&root, "valid", "valid", "valid");
1586        std::fs::write(root.join(".installed"), "").expect("write marker");
1587
1588        let discovered = Extensions::discover([root]).expect("discover skills");
1589
1590        assert_eq!(discovered.skills.keys().collect::<Vec<_>>(), vec!["valid"]);
1591    }
1592
1593    #[test]
1594    fn skill_resource_rejects_parent_escape() {
1595        let temporary = tempfile::tempdir().expect("temporary skills");
1596        let skill = temporary.path().join("skill");
1597        std::fs::create_dir(&skill).expect("create skill");
1598        std::fs::write(temporary.path().join("outside.md"), "outside").expect("write outside");
1599        let directory = Dir::open_ambient_dir(&skill, ambient_authority()).expect("open skill");
1600
1601        assert!(read_skill_resource(&directory, Path::new("../outside.md")).is_err());
1602    }
1603
1604    #[cfg(unix)]
1605    #[test]
1606    fn skill_resource_rejects_symlink_escape() {
1607        use std::os::unix::fs::symlink;
1608
1609        let temporary = tempfile::tempdir().expect("temporary skills");
1610        let skill = temporary.path().join("skill");
1611        std::fs::create_dir(&skill).expect("create skill");
1612        let outside = temporary.path().join("outside.md");
1613        std::fs::write(&outside, "outside").expect("write outside");
1614        symlink(outside, skill.join("escape.md")).expect("create escape");
1615        let directory = Dir::open_ambient_dir(&skill, ambient_authority()).expect("open skill");
1616
1617        assert!(read_skill_resource(&directory, Path::new("escape.md")).is_err());
1618    }
1619
1620    #[cfg(unix)]
1621    #[test]
1622    fn discovery_rejects_skill_directory_symlink_escape() {
1623        use std::os::unix::fs::symlink;
1624
1625        let temporary = tempfile::tempdir().expect("temporary skills");
1626        let root = temporary.path().join("root");
1627        let outside = temporary.path().join("outside");
1628        std::fs::create_dir(&root).expect("create root");
1629        write_skill(&outside, "escaped", "escaped", "escaped");
1630        symlink(outside.join("escaped"), root.join("escaped")).expect("create escape");
1631
1632        assert!(Extensions::discover([root]).is_err());
1633    }
1634
1635    fn write_skill(root: &std::path::Path, directory: &str, name: &str, description: &str) {
1636        let path = root.join(directory);
1637        std::fs::create_dir_all(&path).expect("create skill directory");
1638        std::fs::write(
1639            path.join("SKILL.md"),
1640            format!("---\nname: {name}\ndescription: {description}\n---\n"),
1641        )
1642        .expect("write skill");
1643    }
1644}