1use std::path::{Path, PathBuf};
2
3use serde::{Deserialize, Serialize};
4
5use tuff_hooks_spec::{CompatibilityMatrix, CoverageLevel};
6
7use crate::error::{Result, TuffError};
8pub use crate::hook_settings::{HookSettingsShape, extend_hook_groups};
9use crate::manifest::{CapabilityManifest, CapabilityType, HookConfig};
10
11#[derive(Debug, Clone, Serialize, Deserialize)]
12pub struct EmittedFile {
13 pub path: String,
14 pub hash: String,
15 #[serde(rename = "baselineHash")]
16 pub baseline_hash: String,
17}
18
19#[derive(Debug, Clone)]
20pub struct PlannedFile {
21 pub path: String,
22 pub content: Vec<u8>,
23 pub allow_existing: bool,
24}
25
26impl PlannedFile {
27 pub fn new(path: String, content: Vec<u8>) -> Self {
28 Self {
29 path,
30 content,
31 allow_existing: false,
32 }
33 }
34
35 pub fn mergeable(path: String, content: Vec<u8>) -> Self {
36 Self {
37 path,
38 content,
39 allow_existing: true,
40 }
41 }
42}
43
44#[derive(Debug, Clone)]
45pub struct NativeHookConfig {
46 pub fragment: serde_json::Value,
47 pub source_files: Vec<(String, Vec<u8>)>,
48}
49
50#[derive(Debug, Clone)]
51pub enum HookRenderDiagnosticLevel {
52 Warning,
53}
54
55#[derive(Debug, Clone)]
56pub struct HookRenderDiagnostic {
57 pub level: HookRenderDiagnosticLevel,
58 pub message: String,
59}
60
61#[derive(Debug, Clone)]
62pub struct HookRenderContext<'a> {
63 pub capability_id: &'a str,
64 pub hook: &'a HookConfig,
65 pub source_files: &'a [(String, Vec<u8>)],
66 pub repo_root: &'a Path,
67 pub track_managed_hooks: bool,
68}
69
70#[derive(Debug, Clone)]
71pub struct HookRenderPlan {
72 pub files: Vec<PlannedFile>,
73 pub managed_hooks: Vec<crate::lockfile::ManagedHook>,
74 pub diagnostics: Vec<HookRenderDiagnostic>,
75}
76
77#[derive(Debug, Clone)]
78pub enum HookDefinition {
79 Command(crate::manifest::HookConfig),
80 Native(NativeHookConfig),
81}
82
83#[derive(Debug, Clone)]
84pub enum CapabilityKind {
85 Skill,
86 Tool {
87 parameters: serde_json::Value,
88 implementation: crate::manifest::ImplementationConfig,
89 },
90 Hook {
91 hook: HookDefinition,
92 },
93 Workflow {
94 workflow: crate::manifest::WorkflowConfig,
95 },
96 McpServer {
97 server: crate::manifest::McpServerConfig,
98 },
99 Policy {
100 policy: crate::policy::PolicyConfig,
101 },
102}
103
104impl CapabilityKind {
105 pub fn capability_type(&self) -> CapabilityType {
106 match self {
107 Self::Skill => CapabilityType::Skill,
108 Self::Tool { .. } => CapabilityType::Tool,
109 Self::Hook { .. } => CapabilityType::Hook,
110 Self::Workflow { .. } => CapabilityType::Workflow,
111 Self::McpServer { .. } => CapabilityType::McpServer,
112 Self::Policy { .. } => CapabilityType::Policy,
113 }
114 }
115}
116
117pub struct ResolvedCapability {
118 pub id: String,
119 pub capability_type: CapabilityType,
120 pub version: String,
121 pub description: String,
122 pub source_files: Vec<(String, Vec<u8>)>,
123 pub source_dir: PathBuf,
124 pub kind: CapabilityKind,
125}
126
127#[derive(Serialize)]
128struct WorkflowDocument<'a> {
129 id: &'a str,
130 version: &'a str,
131 #[serde(rename = "type")]
132 capability_type: CapabilityType,
133 description: &'a str,
134 workflow: &'a crate::manifest::WorkflowConfig,
135}
136
137pub fn resolve_capability(manifest: &CapabilityManifest) -> Result<ResolvedCapability> {
138 let source_files = manifest.read_source_contents_with_names()?;
139 let kind =
140 match manifest.capability_type {
141 CapabilityType::Skill => CapabilityKind::Skill,
142 CapabilityType::Tool => CapabilityKind::Tool {
143 parameters: manifest.parameters.clone().ok_or_else(|| {
144 TuffError::usage("tool capability requires [parameters] section")
145 })?,
146 implementation: manifest.implementation.clone().ok_or_else(|| {
147 TuffError::usage("tool capability requires [implementation] section")
148 })?,
149 },
150 CapabilityType::Hook => {
151 CapabilityKind::Hook {
152 hook: HookDefinition::Command(manifest.hook.clone().ok_or_else(|| {
153 TuffError::usage("hook capability requires [hook] section")
154 })?),
155 }
156 }
157 CapabilityType::Workflow => CapabilityKind::Workflow {
158 workflow: manifest.workflow.clone().ok_or_else(|| {
159 TuffError::usage("workflow capability requires [workflow] section")
160 })?,
161 },
162 CapabilityType::Policy => CapabilityKind::Policy {
163 policy: manifest.policy.clone().ok_or_else(|| {
164 TuffError::usage("policy capability requires a [policy] section")
165 })?,
166 },
167 CapabilityType::McpServer => CapabilityKind::McpServer {
168 server: manifest.server.clone().ok_or_else(|| {
169 TuffError::usage("mcp-server capability requires [server] section")
170 })?,
171 },
172 };
173 Ok(ResolvedCapability {
174 id: manifest.id.clone(),
175 capability_type: manifest.capability_type,
176 version: manifest.version.clone(),
177 description: manifest.description.clone(),
178 source_files,
179 source_dir: manifest.root.clone(),
180 kind,
181 })
182}
183
184pub trait AgentAdapter {
194 fn id(&self) -> &'static str;
195 fn display_name(&self) -> &'static str;
196 fn dir_prefix(&self) -> &'static str;
197 fn mcp_config_relpath(&self) -> &'static str;
198 fn supported_agents(&self) -> &[&'static str];
199 fn hook_compatibility(&self) -> &'static CompatibilityMatrix;
200 fn hook_settings_relpath(&self) -> &'static str;
201 fn hook_settings_shape(&self) -> HookSettingsShape;
207 fn scaffold_hook_event(&self) -> &'static str;
209 fn hook_filename(&self) -> &'static str {
210 "run.sh"
211 }
212 fn hook_file_content(&self, hook_cfg: &crate::manifest::HookConfig) -> Result<Vec<u8>> {
213 render_hook_script(hook_cfg)
214 }
215 fn render_standard_hook(&self, context: HookRenderContext<'_>) -> Result<HookRenderPlan> {
216 let matrix = self.hook_compatibility();
217 let Some(entry) = matrix.find_event(&context.hook.event) else {
218 return Err(TuffError::unsupported(format!(
219 "{} does not support hook event '{}'. A manifest can use: {}",
220 self.display_name(),
221 context.hook.event,
222 matrix.accepted_events_summary()
223 )));
224 };
225 let Some(native_event) = entry.native_event_name() else {
226 let suffix = entry
227 .caveat
228 .map(|caveat| format!(": {}", caveat.trim_end_matches('.')))
229 .unwrap_or_default();
230 return Err(TuffError::unsupported(format!(
231 "{} does not support hook event '{}'{}. A manifest can use: {}",
232 self.display_name(),
233 context.hook.event,
234 suffix,
235 matrix.accepted_events_summary()
236 )));
237 };
238
239 if let Some((listed, _)) = context
244 .source_files
245 .iter()
246 .find(|(relative, _)| relative == self.hook_filename())
247 {
248 return Err(TuffError::refused(format!(
249 "hook '{}' lists a file installed as '{listed}', which is the wrapper Tuff generates to run its command",
250 context.capability_id
251 ))
252 .with_hint(format!(
253 "rename that file; '{}' is reserved in the hook directory",
254 self.hook_filename()
255 )));
256 }
257
258 let command = format!(
259 "sh {}/hooks/{}/{}",
260 self.dir_prefix(),
261 context.capability_id,
262 self.hook_filename()
263 );
264 let target_path = context
265 .repo_root
266 .join(self.dir_prefix())
267 .join("hooks")
268 .join(context.capability_id)
269 .join(self.hook_filename());
270 let script = self.hook_file_content(context.hook)?;
271 let settings_relpath = self.hook_settings_relpath();
272 let fragment = self.command_hook_fragment(native_event, &command);
273 let settings_path = context.repo_root.join(settings_relpath);
274 let existing = if settings_path.is_file() {
275 Some(std::fs::read(&settings_path)?)
276 } else {
277 None
278 };
279 let merged = self.merge_hook_fragment(existing.as_deref(), &fragment)?;
280
281 let mut files = vec![PlannedFile::new(
282 relative_or_absolute_fs(&target_path, context.repo_root),
283 script,
284 )];
285 for (relative, content) in context.source_files {
286 let path = context
287 .repo_root
288 .join(self.dir_prefix())
289 .join("hooks")
290 .join(context.capability_id)
291 .join(relative);
292 files.push(PlannedFile::new(
293 relative_or_absolute_fs(&path, context.repo_root),
294 content.clone(),
295 ));
296 }
297 files.push(PlannedFile::mergeable(
298 relative_or_absolute_fs(&settings_path, context.repo_root),
299 merged,
300 ));
301
302 let mut diagnostics = Vec::new();
303 if entry.coverage == CoverageLevel::Partial {
304 let scope = if entry.scope.is_empty() {
305 "partial coverage".to_string()
306 } else {
307 format!("scope: {}", entry.scope.join(", "))
308 };
309 let caveat = entry
310 .caveat
311 .map(|caveat| format!("; {caveat}"))
312 .unwrap_or_default();
313 diagnostics.push(HookRenderDiagnostic {
314 level: HookRenderDiagnosticLevel::Warning,
315 message: format!(
316 "{} renders '{}' with partial compatibility ({scope}{caveat})",
317 self.display_name(),
318 entry.event
319 ),
320 });
321 }
322
323 let managed_hooks = if context.track_managed_hooks {
324 crate::lockfile::managed_hooks_from_fragment_with_canonical(
325 context.repo_root,
326 settings_relpath,
327 &fragment,
328 Some(entry.event.as_str()),
329 )?
330 } else {
331 Vec::new()
332 };
333
334 Ok(HookRenderPlan {
335 files,
336 managed_hooks,
337 diagnostics,
338 })
339 }
340 fn command_hook_fragment(&self, native_event: &str, command: &str) -> serde_json::Value {
342 self.hook_settings_shape()
343 .command_fragment(native_event, command)
344 }
345 fn merge_hook_fragment(
347 &self,
348 existing: Option<&[u8]>,
349 fragment: &serde_json::Value,
350 ) -> Result<Vec<u8>> {
351 self.hook_settings_shape()
352 .merge_fragment(self.hook_settings_relpath(), existing, fragment)
353 }
354 fn remove_hook_settings(
357 &self,
358 repo_root: &Path,
359 managed_hooks: &[crate::lockfile::ManagedHook],
360 ) -> Result<()> {
361 crate::hook_settings::remove_registrations(
362 self.hook_settings_relpath(),
363 self.display_name(),
364 repo_root,
365 managed_hooks,
366 )
367 }
368 fn detect(&self, repo_root: &Path) -> bool;
370
371 fn policy_compatibility(&self) -> Vec<crate::policy::PolicyCoverageEntry> {
376 crate::policy::not_implemented_matrix()
377 }
378
379 fn permissions_settings_relpath(&self) -> Option<&'static str> {
382 None
383 }
384
385 fn native_permission_rules(
389 &self,
390 _rule: &crate::policy::PolicyRule,
391 ) -> Result<Option<Vec<String>>> {
392 Ok(None)
393 }
394
395 fn kinds_supported(&self) -> &[CapabilityType];
396
397 fn supports(&self, capability_type: CapabilityType) -> bool {
398 self.kinds_supported().contains(&capability_type)
399 }
400
401 fn native_hook_event(&self, raw_event: &str) -> Result<&'static str> {
402 let matrix = self.hook_compatibility();
403 let Some(entry) = matrix.find_event(raw_event) else {
404 return Err(TuffError::unsupported(format!(
405 "{} does not support hook event '{}'. A manifest can use: {}",
406 self.display_name(),
407 raw_event,
408 matrix.accepted_events_summary()
409 )));
410 };
411 entry.native_event_name().ok_or_else(|| {
412 let suffix = entry
413 .caveat
414 .map(|caveat| format!(": {}", caveat.trim_end_matches('.')))
415 .unwrap_or_default();
416 TuffError::unsupported(format!(
417 "{} does not support hook event '{}'{}. A manifest can use: {}",
418 self.display_name(),
419 raw_event,
420 suffix,
421 matrix.accepted_events_summary()
422 ))
423 })
424 }
425
426 fn canonical_hook_event(&self, raw_event: &str) -> Result<&'static str> {
427 let matrix = self.hook_compatibility();
428 let Some(entry) = matrix.find_event(raw_event) else {
429 return Err(TuffError::unsupported(format!(
430 "{} does not support hook event '{}'",
431 self.display_name(),
432 raw_event
433 )));
434 };
435 entry
436 .coverage
437 .is_supported()
438 .then_some(entry.event.as_str())
439 .ok_or_else(|| {
440 let suffix = entry
441 .caveat
442 .map(|caveat| format!(": {caveat}"))
443 .unwrap_or_default();
444 TuffError::unsupported(format!(
445 "{} does not support hook event '{}'{}",
446 self.display_name(),
447 raw_event,
448 suffix
449 ))
450 })
451 }
452
453 fn ensure_project_dir(&self, repo_root: &Path) -> std::io::Result<()> {
454 std::fs::create_dir_all(repo_root.join(self.dir_prefix()))
455 }
456
457 fn mcp_env_reference(&self, var: &str) -> String {
460 format!("${{{var}}}")
461 }
462
463 fn mcp_server_entry(&self, server: &crate::manifest::McpServerConfig) -> serde_json::Value {
466 use crate::manifest::McpTransport;
467 match server.transport {
468 McpTransport::Stdio => {
469 let mut entry = serde_json::json!({
470 "command": server.command.clone().unwrap_or_default(),
471 "args": server.args,
472 });
473 if !server.env.is_empty() {
474 let env: serde_json::Map<String, serde_json::Value> = server
475 .env
476 .iter()
477 .map(|(name, reference)| {
478 (
479 name.clone(),
480 serde_json::Value::String(
481 self.mcp_env_reference(&reference.from_env),
482 ),
483 )
484 })
485 .collect();
486 entry["env"] = serde_json::Value::Object(env);
487 }
488 entry
489 }
490 McpTransport::Http => {
491 let mut entry = serde_json::Map::new();
492 if self.mcp_http_declares_type() {
493 entry.insert("type".into(), serde_json::Value::String("http".into()));
494 }
495 entry.insert(
496 "url".into(),
497 serde_json::Value::String(server.url.clone().unwrap_or_default()),
498 );
499 if !server.headers.is_empty() {
500 let headers: serde_json::Map<String, serde_json::Value> = server
501 .headers
502 .iter()
503 .map(|(name, reference)| {
504 let value =
505 reference.render(&self.mcp_env_reference(&reference.from_env));
506 (name.clone(), serde_json::Value::String(value))
507 })
508 .collect();
509 entry.insert("headers".into(), serde_json::Value::Object(headers));
510 }
511 serde_json::Value::Object(entry)
512 }
513 }
514 }
515
516 fn mcp_http_declares_type(&self) -> bool {
520 true
521 }
522
523 fn plan(&self, capability: &ResolvedCapability, repo_root: &Path) -> Result<Vec<PlannedFile>> {
524 match capability.capability_type {
525 CapabilityType::Tool => self.plan_tool(capability, repo_root),
526 CapabilityType::Hook => self.plan_hook(capability, repo_root),
527 CapabilityType::Workflow => self.plan_workflow(capability, repo_root),
528 CapabilityType::McpServer => self.plan_mcp_server(capability, repo_root),
529 CapabilityType::Policy => self.plan_policy(capability, repo_root),
530 CapabilityType::Skill => self.plan_skill(capability, repo_root),
531 }
532 }
533
534 fn remove(
535 &self,
536 primitive_id: &str,
537 repo_root: &Path,
538 managed_hooks: &[crate::lockfile::ManagedHook],
539 ) -> Result<()> {
540 self.remove_hook_settings(repo_root, managed_hooks)?;
547 crate::mcp::remove_tool(&repo_root.join(self.mcp_config_relpath()), primitive_id)?;
548 let prefix = self.dir_prefix();
549 for kind in &[
550 "skills",
551 "tools",
552 "hooks",
553 "workflows",
554 "mcp-servers",
555 "policies",
556 ] {
557 self.remove_dir(repo_root, prefix, kind, primitive_id)?;
558 }
559 Ok(())
560 }
561
562 fn plan_skill(
565 &self,
566 capability: &ResolvedCapability,
567 repo_root: &Path,
568 ) -> Result<Vec<PlannedFile>> {
569 if capability.source_files.is_empty() {
570 return Err(TuffError::usage("no source files to emit"));
571 }
572
573 let mut files = Vec::new();
574 for (rel_path, content) in &capability.source_files {
575 let target_path = repo_root
576 .join(self.dir_prefix())
577 .join("skills")
578 .join(&capability.id)
579 .join(rel_path);
580
581 files.push(PlannedFile::new(
582 relative_or_absolute_fs(&target_path, repo_root),
583 content.clone(),
584 ));
585 }
586 Ok(files)
587 }
588
589 fn plan_tool(
590 &self,
591 capability: &ResolvedCapability,
592 repo_root: &Path,
593 ) -> Result<Vec<PlannedFile>> {
594 let mut files = Vec::new();
595
596 for (rel_path, content) in &capability.source_files {
597 let target_path = repo_root
598 .join(self.dir_prefix())
599 .join("tools")
600 .join(&capability.id)
601 .join(rel_path);
602
603 files.push(PlannedFile::new(
604 relative_or_absolute_fs(&target_path, repo_root),
605 content.clone(),
606 ));
607 }
608
609 if capability.source_files.is_empty() {
610 let placeholder = repo_root
611 .join(self.dir_prefix())
612 .join("tools")
613 .join(&capability.id)
614 .join(".gitkeep");
615 files.push(PlannedFile::new(
616 relative_or_absolute_fs(&placeholder, repo_root),
617 vec![],
618 ));
619 }
620
621 Ok(files)
622 }
623
624 fn plan_hook(
625 &self,
626 capability: &ResolvedCapability,
627 repo_root: &Path,
628 ) -> Result<Vec<PlannedFile>> {
629 let CapabilityKind::Hook { hook } = &capability.kind else {
630 return Err(TuffError::new("plan_hook called on non-hook capability"));
631 };
632
633 match hook {
634 HookDefinition::Command(hook_cfg) => {
635 let render = self.render_standard_hook(HookRenderContext {
636 capability_id: &capability.id,
637 hook: hook_cfg,
638 source_files: &capability.source_files,
639 repo_root,
640 track_managed_hooks: false,
641 })?;
642 Ok(render.files)
643 }
644 HookDefinition::Native(native) => self.plan_native_hook(capability, native, repo_root),
645 }
646 }
647
648 fn plan_native_hook(
649 &self,
650 capability: &ResolvedCapability,
651 native: &NativeHookConfig,
652 repo_root: &Path,
653 ) -> Result<Vec<PlannedFile>> {
654 let hook_root = repo_root
655 .join(self.dir_prefix())
656 .join("hooks")
657 .join(&capability.id);
658 let hook_root_rel = relative_or_absolute_fs(&hook_root, repo_root);
659 let in_harness_source =
660 path_is_under(&capability.source_dir, &repo_root.join(self.dir_prefix()));
661
662 let mut files = Vec::new();
663 if in_harness_source {
664 for (rel_path, content) in &native.source_files {
665 let target_path = capability.source_dir.join(rel_path);
666 files.push(PlannedFile::mergeable(
667 relative_or_absolute_fs(&target_path, repo_root),
668 content.clone(),
669 ));
670 }
671 } else {
672 for (rel_path, content) in &native.source_files {
673 let target_path = hook_root.join(rel_path);
674 files.push(PlannedFile::new(
675 relative_or_absolute_fs(&target_path, repo_root),
676 content.clone(),
677 ));
678 }
679 }
680
681 let fragment = replace_hook_dir_placeholder(native.fragment.clone(), &hook_root_rel);
682 let settings_relpath = self.hook_settings_relpath();
683 let settings_path = repo_root.join(settings_relpath);
684 let existing = if settings_path.is_file() {
685 Some(std::fs::read(&settings_path)?)
686 } else {
687 None
688 };
689 let merged = self.merge_hook_fragment(existing.as_deref(), &fragment)?;
690 files.push(PlannedFile::mergeable(
691 relative_or_absolute_fs(&settings_path, repo_root),
692 merged,
693 ));
694 Ok(files)
695 }
696
697 fn plan_workflow(
698 &self,
699 capability: &ResolvedCapability,
700 repo_root: &Path,
701 ) -> Result<Vec<PlannedFile>> {
702 let CapabilityKind::Workflow { workflow: wf } = &capability.kind else {
703 return Err(TuffError::new(
704 "plan_workflow called on non-workflow capability",
705 ));
706 };
707
708 let target_path = repo_root
709 .join(self.dir_prefix())
710 .join("workflows")
711 .join(&capability.id)
712 .join("workflow.toml");
713
714 let content = serialize_workflow(capability, wf)?;
715
716 Ok(vec![PlannedFile::new(
717 relative_or_absolute_fs(&target_path, repo_root),
718 content,
719 )])
720 }
721
722 fn plan_mcp_server(
727 &self,
728 capability: &ResolvedCapability,
729 repo_root: &Path,
730 ) -> Result<Vec<PlannedFile>> {
731 let CapabilityKind::McpServer { server } = &capability.kind else {
732 return Err(TuffError::new(
733 "plan_mcp_server called on non-mcp-server capability",
734 ));
735 };
736
737 let target_path = repo_root
738 .join(self.dir_prefix())
739 .join("mcp-servers")
740 .join(&capability.id)
741 .join("server.toml");
742
743 let content = serialize_mcp_server(capability, server)?;
744
745 Ok(vec![PlannedFile::new(
746 relative_or_absolute_fs(&target_path, repo_root),
747 content,
748 )])
749 }
750
751 fn plan_policy(
756 &self,
757 capability: &ResolvedCapability,
758 repo_root: &Path,
759 ) -> Result<Vec<PlannedFile>> {
760 let CapabilityKind::Policy { policy } = &capability.kind else {
761 return Err(TuffError::new(
762 "plan_policy called on non-policy capability",
763 ));
764 };
765 let target_path = repo_root
766 .join(self.dir_prefix())
767 .join("policies")
768 .join(&capability.id)
769 .join("policy.toml");
770 let content = serialize_policy(capability, policy)?;
771 Ok(vec![PlannedFile::new(
772 relative_or_absolute_fs(&target_path, repo_root),
773 content,
774 )])
775 }
776
777 fn remove_dir(
778 &self,
779 repo_root: &Path,
780 base: &str,
781 kind: &str,
782 primitive_id: &str,
783 ) -> Result<()> {
784 let dir = repo_root.join(base).join(kind).join(primitive_id);
785
786 if dir.exists() {
787 std::fs::remove_dir_all(&dir)?;
788 }
789
790 let kind_dir = dir.parent().expect("kind dir should have parent");
791 if kind_dir.exists() {
792 let mut rd = match std::fs::read_dir(kind_dir) {
793 Ok(rd) => rd,
794 Err(_) => return Ok(()),
795 };
796 if rd.next().is_none() {
797 std::fs::remove_dir(kind_dir)?;
798 }
799 }
800
801 let base_dir = kind_dir.parent().expect("base dir should have parent");
802 if base_dir.exists() {
803 let mut rd = match std::fs::read_dir(base_dir) {
804 Ok(rd) => rd,
805 Err(_) => return Ok(()),
806 };
807 if rd.next().is_none() {
808 std::fs::remove_dir(base_dir)?;
809 }
810 }
811
812 Ok(())
813 }
814}
815
816fn render_hook_script(hook_cfg: &HookConfig) -> Result<Vec<u8>> {
817 let working_directory = shell_single_quote(&hook_cfg.working_directory)?;
818 let command = shell_single_quote(&hook_cfg.command)?;
819 Ok(format!(
820 "#!/usr/bin/env bash\nset -euo pipefail\ncd -- {working_directory}\nexec bash -euo pipefail -c {command}\n"
821 )
822 .into_bytes())
823}
824
825fn shell_single_quote(value: &str) -> Result<String> {
826 if value.contains('\0') {
827 return Err(TuffError::usage(
828 "hook working directory and command cannot contain NUL bytes",
829 ));
830 }
831 Ok(format!("'{}'", value.replace('\'', "'\"'\"'")))
832}
833
834fn serialize_workflow(
835 capability: &ResolvedCapability,
836 workflow: &crate::manifest::WorkflowConfig,
837) -> Result<Vec<u8>> {
838 let document = WorkflowDocument {
839 id: &capability.id,
840 version: &capability.version,
841 capability_type: capability.capability_type,
842 description: &capability.description,
843 workflow,
844 };
845 let mut content = toml::to_string_pretty(&document)?;
846 if !content.ends_with('\n') {
847 content.push('\n');
848 }
849 Ok(content.into_bytes())
850}
851
852#[derive(Serialize)]
853struct McpServerDocument<'a> {
854 id: &'a str,
855 version: &'a str,
856 #[serde(rename = "type")]
857 capability_type: CapabilityType,
858 description: &'a str,
859 server: &'a crate::manifest::McpServerConfig,
860}
861
862fn serialize_mcp_server(
863 capability: &ResolvedCapability,
864 server: &crate::manifest::McpServerConfig,
865) -> Result<Vec<u8>> {
866 let document = McpServerDocument {
867 id: &capability.id,
868 version: &capability.version,
869 capability_type: capability.capability_type,
870 description: &capability.description,
871 server,
872 };
873 let mut content = toml::to_string_pretty(&document)?;
874 if !content.ends_with('\n') {
875 content.push('\n');
876 }
877 Ok(content.into_bytes())
878}
879
880#[derive(Serialize)]
881struct PolicyDocument<'a> {
882 id: &'a str,
883 version: &'a str,
884 #[serde(rename = "type")]
885 capability_type: CapabilityType,
886 description: &'a str,
887 policy: &'a crate::policy::PolicyConfig,
888}
889
890fn serialize_policy(
891 capability: &ResolvedCapability,
892 policy: &crate::policy::PolicyConfig,
893) -> Result<Vec<u8>> {
894 let document = PolicyDocument {
895 id: &capability.id,
896 version: &capability.version,
897 capability_type: capability.capability_type,
898 description: &capability.description,
899 policy,
900 };
901 let mut content = toml::to_string_pretty(&document)?;
902 if !content.ends_with('\n') {
903 content.push('\n');
904 }
905 Ok(content.into_bytes())
906}
907
908fn path_is_under(path: &Path, root: &Path) -> bool {
909 let canonical_root = root.canonicalize().unwrap_or_else(|_| root.to_path_buf());
910 let canonical_path = path.canonicalize().unwrap_or_else(|_| path.to_path_buf());
911 canonical_path.starts_with(canonical_root)
912}
913
914pub fn replace_hook_dir_placeholder(
915 mut value: serde_json::Value,
916 hook_dir: &str,
917) -> serde_json::Value {
918 match &mut value {
919 serde_json::Value::String(s) => {
920 *s = s.replace("{{hook_dir}}", hook_dir);
921 }
922 serde_json::Value::Array(items) => {
923 for item in items {
924 *item = replace_hook_dir_placeholder(item.take(), hook_dir);
925 }
926 }
927 serde_json::Value::Object(map) => {
928 for item in map.values_mut() {
929 *item = replace_hook_dir_placeholder(item.take(), hook_dir);
930 }
931 }
932 _ => {}
933 }
934 value
935}
936
937fn relative_or_absolute_fs(path: &Path, repo_root: &Path) -> String {
938 crate::lockfile::relative_or_absolute_fs(path, repo_root)
939}
940
941#[cfg(test)]
942mod tests {
943 use super::*;
944 use crate::manifest::{Requirement, WorkflowConfig};
945
946 #[cfg(unix)]
947 #[test]
948 fn hook_script_preserves_shell_sensitive_values() {
949 use std::process::Command;
950
951 let temp = tempfile::tempdir().expect("tempdir");
952 let working_directory = temp.path().join("directory with ' quote");
953 std::fs::create_dir(&working_directory).expect("create working directory");
954 let hook = HookConfig {
955 event: "stop".to_string(),
956 command: "printf '%s\\n' 'safe; $HOME `literal`' > result.txt".to_string(),
957 working_directory: working_directory.to_string_lossy().into_owned(),
958 };
959 let script_path = temp.path().join("run.sh");
960 std::fs::write(
961 &script_path,
962 render_hook_script(&hook).expect("render script"),
963 )
964 .expect("write script");
965
966 let syntax = Command::new("bash")
967 .arg("-n")
968 .arg(&script_path)
969 .status()
970 .expect("check script syntax");
971 assert!(syntax.success());
972 let executed = Command::new("bash")
973 .arg(&script_path)
974 .status()
975 .expect("execute script");
976 assert!(executed.success());
977 assert_eq!(
978 std::fs::read_to_string(working_directory.join("result.txt"))
979 .expect("read command output"),
980 "safe; $HOME `literal`\n"
981 );
982 }
983
984 #[test]
985 fn hook_script_rejects_nul_bytes() {
986 let hook = HookConfig {
987 event: "stop".to_string(),
988 command: "printf '\0'".to_string(),
989 working_directory: ".".to_string(),
990 };
991
992 assert!(render_hook_script(&hook).is_err());
993 }
994
995 #[test]
996 fn workflow_serialization_escapes_manifest_values() {
997 let workflow = WorkflowConfig {
998 requires: vec![Requirement {
999 id: "dependency\"\\name".to_string(),
1000 capability_type: CapabilityType::Skill,
1001 }],
1002 };
1003 let capability = ResolvedCapability {
1004 id: "workflow\"id".to_string(),
1005 capability_type: CapabilityType::Workflow,
1006 version: "1.0.0".to_string(),
1007 description: "first line\nsecond \"line\" \\ value".to_string(),
1008 source_files: Vec::new(),
1009 source_dir: PathBuf::new(),
1010 kind: CapabilityKind::Workflow {
1011 workflow: workflow.clone(),
1012 },
1013 };
1014
1015 let bytes = serialize_workflow(&capability, &workflow).expect("serialize workflow");
1016 let parsed: toml::Value = toml::from_slice(&bytes).expect("parse emitted workflow");
1017
1018 assert_eq!(parsed["id"].as_str(), Some("workflow\"id"));
1019 assert_eq!(
1020 parsed["description"].as_str(),
1021 Some("first line\nsecond \"line\" \\ value")
1022 );
1023 assert_eq!(
1024 parsed["workflow"]["requires"][0]["id"].as_str(),
1025 Some("dependency\"\\name")
1026 );
1027 }
1028}