1use 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
66pub type HookAuthorization = CommandAuthorization;
68
69pub 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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
81pub enum ExtensionPackageKind {
82 Skill,
83 Plugin,
84}
85
86#[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#[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
106pub 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
124pub 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 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 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 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 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 #[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#[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(¤t)?
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 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(¬ices);
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}