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}
100
101impl CapabilityKind {
102 pub fn capability_type(&self) -> CapabilityType {
103 match self {
104 Self::Skill => CapabilityType::Skill,
105 Self::Tool { .. } => CapabilityType::Tool,
106 Self::Hook { .. } => CapabilityType::Hook,
107 Self::Workflow { .. } => CapabilityType::Workflow,
108 Self::McpServer { .. } => CapabilityType::McpServer,
109 }
110 }
111}
112
113pub struct ResolvedCapability {
114 pub id: String,
115 pub capability_type: CapabilityType,
116 pub version: String,
117 pub description: String,
118 pub source_files: Vec<(String, Vec<u8>)>,
119 pub source_dir: PathBuf,
120 pub kind: CapabilityKind,
121}
122
123#[derive(Serialize)]
124struct WorkflowDocument<'a> {
125 id: &'a str,
126 version: &'a str,
127 #[serde(rename = "type")]
128 capability_type: CapabilityType,
129 description: &'a str,
130 workflow: &'a crate::manifest::WorkflowConfig,
131}
132
133pub fn resolve_capability(manifest: &CapabilityManifest) -> Result<ResolvedCapability> {
134 let source_files = manifest.read_source_contents_with_names()?;
135 let kind =
136 match manifest.capability_type {
137 CapabilityType::Skill => CapabilityKind::Skill,
138 CapabilityType::Tool => CapabilityKind::Tool {
139 parameters: manifest.parameters.clone().ok_or_else(|| {
140 TuffError::usage("tool capability requires [parameters] section")
141 })?,
142 implementation: manifest.implementation.clone().ok_or_else(|| {
143 TuffError::usage("tool capability requires [implementation] section")
144 })?,
145 },
146 CapabilityType::Hook => {
147 CapabilityKind::Hook {
148 hook: HookDefinition::Command(manifest.hook.clone().ok_or_else(|| {
149 TuffError::usage("hook capability requires [hook] section")
150 })?),
151 }
152 }
153 CapabilityType::Workflow => CapabilityKind::Workflow {
154 workflow: manifest.workflow.clone().ok_or_else(|| {
155 TuffError::usage("workflow capability requires [workflow] section")
156 })?,
157 },
158 CapabilityType::Policy => {
159 return Err(TuffError::unsupported(
160 "policy capabilities are not installable yet",
161 ));
162 }
163 CapabilityType::McpServer => CapabilityKind::McpServer {
164 server: manifest.server.clone().ok_or_else(|| {
165 TuffError::usage("mcp-server capability requires [server] section")
166 })?,
167 },
168 };
169 Ok(ResolvedCapability {
170 id: manifest.id.clone(),
171 capability_type: manifest.capability_type,
172 version: manifest.version.clone(),
173 description: manifest.description.clone(),
174 source_files,
175 source_dir: manifest.root.clone(),
176 kind,
177 })
178}
179
180pub trait AgentAdapter {
190 fn id(&self) -> &'static str;
191 fn display_name(&self) -> &'static str;
192 fn dir_prefix(&self) -> &'static str;
193 fn mcp_config_relpath(&self) -> &'static str;
194 fn supported_agents(&self) -> &[&'static str];
195 fn hook_compatibility(&self) -> &'static CompatibilityMatrix;
196 fn hook_settings_relpath(&self) -> &'static str;
197 fn hook_settings_shape(&self) -> HookSettingsShape;
203 fn scaffold_hook_event(&self) -> &'static str;
205 fn hook_filename(&self) -> &'static str {
206 "run.sh"
207 }
208 fn hook_file_content(&self, hook_cfg: &crate::manifest::HookConfig) -> Result<Vec<u8>> {
209 render_hook_script(hook_cfg)
210 }
211 fn render_standard_hook(&self, context: HookRenderContext<'_>) -> Result<HookRenderPlan> {
212 let matrix = self.hook_compatibility();
213 let Some(entry) = matrix.find_event(&context.hook.event) else {
214 return Err(TuffError::unsupported(format!(
215 "{} does not support hook event '{}'. Supported events: {}",
216 self.display_name(),
217 context.hook.event,
218 matrix.supported_native_events().join(", ")
219 )));
220 };
221 let Some(native_event) = entry.native_event_name() else {
222 let suffix = entry
223 .caveat
224 .map(|caveat| format!(": {caveat}"))
225 .unwrap_or_default();
226 return Err(TuffError::unsupported(format!(
227 "{} does not support hook event '{}'{}",
228 self.display_name(),
229 context.hook.event,
230 suffix
231 )));
232 };
233
234 let command = format!(
235 "sh {}/hooks/{}/{}",
236 self.dir_prefix(),
237 context.capability_id,
238 self.hook_filename()
239 );
240 let target_path = context
241 .repo_root
242 .join(self.dir_prefix())
243 .join("hooks")
244 .join(context.capability_id)
245 .join(self.hook_filename());
246 let script = self.hook_file_content(context.hook)?;
247 let settings_relpath = self.hook_settings_relpath();
248 let fragment = self.command_hook_fragment(native_event, &command);
249 let settings_path = context.repo_root.join(settings_relpath);
250 let existing = if settings_path.is_file() {
251 Some(std::fs::read(&settings_path)?)
252 } else {
253 None
254 };
255 let merged = self.merge_hook_fragment(existing.as_deref(), &fragment)?;
256
257 let mut files = vec![PlannedFile::new(
258 relative_or_absolute_fs(&target_path, context.repo_root),
259 script,
260 )];
261 for (relative, content) in context.source_files {
262 let path = context
263 .repo_root
264 .join(self.dir_prefix())
265 .join("hooks")
266 .join(context.capability_id)
267 .join(relative);
268 files.push(PlannedFile::new(
269 relative_or_absolute_fs(&path, context.repo_root),
270 content.clone(),
271 ));
272 }
273 files.push(PlannedFile::mergeable(
274 relative_or_absolute_fs(&settings_path, context.repo_root),
275 merged,
276 ));
277
278 let mut diagnostics = Vec::new();
279 if entry.coverage == CoverageLevel::Partial {
280 let scope = if entry.scope.is_empty() {
281 "partial coverage".to_string()
282 } else {
283 format!("scope: {}", entry.scope.join(", "))
284 };
285 let caveat = entry
286 .caveat
287 .map(|caveat| format!("; {caveat}"))
288 .unwrap_or_default();
289 diagnostics.push(HookRenderDiagnostic {
290 level: HookRenderDiagnosticLevel::Warning,
291 message: format!(
292 "{} renders '{}' with partial compatibility ({scope}{caveat})",
293 self.display_name(),
294 entry.event
295 ),
296 });
297 }
298
299 let managed_hooks = if context.track_managed_hooks {
300 crate::lockfile::managed_hooks_from_fragment_with_canonical(
301 context.repo_root,
302 settings_relpath,
303 &fragment,
304 Some(entry.event.as_str()),
305 )?
306 } else {
307 Vec::new()
308 };
309
310 Ok(HookRenderPlan {
311 files,
312 managed_hooks,
313 diagnostics,
314 })
315 }
316 fn command_hook_fragment(&self, native_event: &str, command: &str) -> serde_json::Value {
318 self.hook_settings_shape()
319 .command_fragment(native_event, command)
320 }
321 fn merge_hook_fragment(
323 &self,
324 existing: Option<&[u8]>,
325 fragment: &serde_json::Value,
326 ) -> Result<Vec<u8>> {
327 self.hook_settings_shape()
328 .merge_fragment(self.hook_settings_relpath(), existing, fragment)
329 }
330 fn remove_hook_settings(
333 &self,
334 repo_root: &Path,
335 managed_hooks: &[crate::lockfile::ManagedHook],
336 ) -> Result<()> {
337 crate::hook_settings::remove_registrations(
338 self.hook_settings_relpath(),
339 self.display_name(),
340 repo_root,
341 managed_hooks,
342 )
343 }
344 fn detect(&self, repo_root: &Path) -> bool;
346
347 fn kinds_supported(&self) -> &[CapabilityType];
348
349 fn supports(&self, capability_type: CapabilityType) -> bool {
350 self.kinds_supported().contains(&capability_type)
351 }
352
353 fn native_hook_event(&self, raw_event: &str) -> Result<&'static str> {
354 let matrix = self.hook_compatibility();
355 let Some(entry) = matrix.find_event(raw_event) else {
356 return Err(TuffError::unsupported(format!(
357 "{} does not support hook event '{}'. Supported events: {}",
358 self.display_name(),
359 raw_event,
360 matrix.supported_native_events().join(", ")
361 )));
362 };
363 entry.native_event_name().ok_or_else(|| {
364 let suffix = entry
365 .caveat
366 .map(|caveat| format!(": {caveat}"))
367 .unwrap_or_default();
368 TuffError::unsupported(format!(
369 "{} does not support hook event '{}'{}",
370 self.display_name(),
371 raw_event,
372 suffix
373 ))
374 })
375 }
376
377 fn canonical_hook_event(&self, raw_event: &str) -> Result<&'static str> {
378 let matrix = self.hook_compatibility();
379 let Some(entry) = matrix.find_event(raw_event) else {
380 return Err(TuffError::unsupported(format!(
381 "{} does not support hook event '{}'",
382 self.display_name(),
383 raw_event
384 )));
385 };
386 entry
387 .coverage
388 .is_supported()
389 .then_some(entry.event.as_str())
390 .ok_or_else(|| {
391 let suffix = entry
392 .caveat
393 .map(|caveat| format!(": {caveat}"))
394 .unwrap_or_default();
395 TuffError::unsupported(format!(
396 "{} does not support hook event '{}'{}",
397 self.display_name(),
398 raw_event,
399 suffix
400 ))
401 })
402 }
403
404 fn ensure_project_dir(&self, repo_root: &Path) -> std::io::Result<()> {
405 std::fs::create_dir_all(repo_root.join(self.dir_prefix()))
406 }
407
408 fn mcp_env_reference(&self, var: &str) -> String {
411 format!("${{{var}}}")
412 }
413
414 fn mcp_server_entry(&self, server: &crate::manifest::McpServerConfig) -> serde_json::Value {
417 use crate::manifest::McpTransport;
418 match server.transport {
419 McpTransport::Stdio => {
420 let mut entry = serde_json::json!({
421 "command": server.command.clone().unwrap_or_default(),
422 "args": server.args,
423 });
424 if !server.env.is_empty() {
425 let env: serde_json::Map<String, serde_json::Value> = server
426 .env
427 .iter()
428 .map(|(name, reference)| {
429 (
430 name.clone(),
431 serde_json::Value::String(
432 self.mcp_env_reference(&reference.from_env),
433 ),
434 )
435 })
436 .collect();
437 entry["env"] = serde_json::Value::Object(env);
438 }
439 entry
440 }
441 McpTransport::Http => {
442 let mut entry = serde_json::Map::new();
443 if self.mcp_http_declares_type() {
444 entry.insert("type".into(), serde_json::Value::String("http".into()));
445 }
446 entry.insert(
447 "url".into(),
448 serde_json::Value::String(server.url.clone().unwrap_or_default()),
449 );
450 if !server.headers.is_empty() {
451 let headers: serde_json::Map<String, serde_json::Value> = server
452 .headers
453 .iter()
454 .map(|(name, reference)| {
455 let value =
456 reference.render(&self.mcp_env_reference(&reference.from_env));
457 (name.clone(), serde_json::Value::String(value))
458 })
459 .collect();
460 entry.insert("headers".into(), serde_json::Value::Object(headers));
461 }
462 serde_json::Value::Object(entry)
463 }
464 }
465 }
466
467 fn mcp_http_declares_type(&self) -> bool {
471 true
472 }
473
474 fn plan(&self, capability: &ResolvedCapability, repo_root: &Path) -> Result<Vec<PlannedFile>> {
475 match capability.capability_type {
476 CapabilityType::Tool => self.plan_tool(capability, repo_root),
477 CapabilityType::Hook => self.plan_hook(capability, repo_root),
478 CapabilityType::Workflow => self.plan_workflow(capability, repo_root),
479 CapabilityType::McpServer => self.plan_mcp_server(capability, repo_root),
480 CapabilityType::Policy => Err(TuffError::unsupported(
481 "policy capabilities are not installable yet",
482 )),
483 CapabilityType::Skill => self.plan_skill(capability, repo_root),
484 }
485 }
486
487 fn remove(
488 &self,
489 primitive_id: &str,
490 repo_root: &Path,
491 managed_hooks: &[crate::lockfile::ManagedHook],
492 ) -> Result<()> {
493 let prefix = self.dir_prefix();
494 for kind in &["skills", "tools", "hooks", "workflows", "mcp-servers"] {
495 self.remove_dir(repo_root, prefix, kind, primitive_id)?;
496 }
497 crate::mcp::remove_tool(&repo_root.join(self.mcp_config_relpath()), primitive_id)?;
498 self.remove_hook_settings(repo_root, managed_hooks)?;
499 Ok(())
500 }
501
502 fn plan_skill(
505 &self,
506 capability: &ResolvedCapability,
507 repo_root: &Path,
508 ) -> Result<Vec<PlannedFile>> {
509 if capability.source_files.is_empty() {
510 return Err(TuffError::usage("no source files to emit"));
511 }
512
513 let mut files = Vec::new();
514 for (rel_path, content) in &capability.source_files {
515 let target_path = repo_root
516 .join(self.dir_prefix())
517 .join("skills")
518 .join(&capability.id)
519 .join(rel_path);
520
521 files.push(PlannedFile::new(
522 relative_or_absolute_fs(&target_path, repo_root),
523 content.clone(),
524 ));
525 }
526 Ok(files)
527 }
528
529 fn plan_tool(
530 &self,
531 capability: &ResolvedCapability,
532 repo_root: &Path,
533 ) -> Result<Vec<PlannedFile>> {
534 let mut files = Vec::new();
535
536 for (rel_path, content) in &capability.source_files {
537 let target_path = repo_root
538 .join(self.dir_prefix())
539 .join("tools")
540 .join(&capability.id)
541 .join(rel_path);
542
543 files.push(PlannedFile::new(
544 relative_or_absolute_fs(&target_path, repo_root),
545 content.clone(),
546 ));
547 }
548
549 if capability.source_files.is_empty() {
550 let placeholder = repo_root
551 .join(self.dir_prefix())
552 .join("tools")
553 .join(&capability.id)
554 .join(".gitkeep");
555 files.push(PlannedFile::new(
556 relative_or_absolute_fs(&placeholder, repo_root),
557 vec![],
558 ));
559 }
560
561 Ok(files)
562 }
563
564 fn plan_hook(
565 &self,
566 capability: &ResolvedCapability,
567 repo_root: &Path,
568 ) -> Result<Vec<PlannedFile>> {
569 let CapabilityKind::Hook { hook } = &capability.kind else {
570 return Err(TuffError::new("plan_hook called on non-hook capability"));
571 };
572
573 match hook {
574 HookDefinition::Command(hook_cfg) => {
575 let render = self.render_standard_hook(HookRenderContext {
576 capability_id: &capability.id,
577 hook: hook_cfg,
578 source_files: &capability.source_files,
579 repo_root,
580 track_managed_hooks: false,
581 })?;
582 Ok(render.files)
583 }
584 HookDefinition::Native(native) => self.plan_native_hook(capability, native, repo_root),
585 }
586 }
587
588 fn plan_native_hook(
589 &self,
590 capability: &ResolvedCapability,
591 native: &NativeHookConfig,
592 repo_root: &Path,
593 ) -> Result<Vec<PlannedFile>> {
594 let hook_root = repo_root
595 .join(self.dir_prefix())
596 .join("hooks")
597 .join(&capability.id);
598 let hook_root_rel = relative_or_absolute_fs(&hook_root, repo_root);
599 let in_harness_source =
600 path_is_under(&capability.source_dir, &repo_root.join(self.dir_prefix()));
601
602 let mut files = Vec::new();
603 if in_harness_source {
604 for (rel_path, content) in &native.source_files {
605 let target_path = capability.source_dir.join(rel_path);
606 files.push(PlannedFile::mergeable(
607 relative_or_absolute_fs(&target_path, repo_root),
608 content.clone(),
609 ));
610 }
611 } else {
612 for (rel_path, content) in &native.source_files {
613 let target_path = hook_root.join(rel_path);
614 files.push(PlannedFile::new(
615 relative_or_absolute_fs(&target_path, repo_root),
616 content.clone(),
617 ));
618 }
619 }
620
621 let fragment = replace_hook_dir_placeholder(native.fragment.clone(), &hook_root_rel);
622 let settings_relpath = self.hook_settings_relpath();
623 let settings_path = repo_root.join(settings_relpath);
624 let existing = if settings_path.is_file() {
625 Some(std::fs::read(&settings_path)?)
626 } else {
627 None
628 };
629 let merged = self.merge_hook_fragment(existing.as_deref(), &fragment)?;
630 files.push(PlannedFile::mergeable(
631 relative_or_absolute_fs(&settings_path, repo_root),
632 merged,
633 ));
634 Ok(files)
635 }
636
637 fn plan_workflow(
638 &self,
639 capability: &ResolvedCapability,
640 repo_root: &Path,
641 ) -> Result<Vec<PlannedFile>> {
642 let CapabilityKind::Workflow { workflow: wf } = &capability.kind else {
643 return Err(TuffError::new(
644 "plan_workflow called on non-workflow capability",
645 ));
646 };
647
648 let target_path = repo_root
649 .join(self.dir_prefix())
650 .join("workflows")
651 .join(&capability.id)
652 .join("workflow.toml");
653
654 let content = serialize_workflow(capability, wf)?;
655
656 Ok(vec![PlannedFile::new(
657 relative_or_absolute_fs(&target_path, repo_root),
658 content,
659 )])
660 }
661
662 fn plan_mcp_server(
667 &self,
668 capability: &ResolvedCapability,
669 repo_root: &Path,
670 ) -> Result<Vec<PlannedFile>> {
671 let CapabilityKind::McpServer { server } = &capability.kind else {
672 return Err(TuffError::new(
673 "plan_mcp_server called on non-mcp-server capability",
674 ));
675 };
676
677 let target_path = repo_root
678 .join(self.dir_prefix())
679 .join("mcp-servers")
680 .join(&capability.id)
681 .join("server.toml");
682
683 let content = serialize_mcp_server(capability, server)?;
684
685 Ok(vec![PlannedFile::new(
686 relative_or_absolute_fs(&target_path, repo_root),
687 content,
688 )])
689 }
690
691 fn remove_dir(
692 &self,
693 repo_root: &Path,
694 base: &str,
695 kind: &str,
696 primitive_id: &str,
697 ) -> Result<()> {
698 let dir = repo_root.join(base).join(kind).join(primitive_id);
699
700 if dir.exists() {
701 std::fs::remove_dir_all(&dir)?;
702 }
703
704 let kind_dir = dir.parent().expect("kind dir should have parent");
705 if kind_dir.exists() {
706 let mut rd = match std::fs::read_dir(kind_dir) {
707 Ok(rd) => rd,
708 Err(_) => return Ok(()),
709 };
710 if rd.next().is_none() {
711 std::fs::remove_dir(kind_dir)?;
712 }
713 }
714
715 let base_dir = kind_dir.parent().expect("base dir should have parent");
716 if base_dir.exists() {
717 let mut rd = match std::fs::read_dir(base_dir) {
718 Ok(rd) => rd,
719 Err(_) => return Ok(()),
720 };
721 if rd.next().is_none() {
722 std::fs::remove_dir(base_dir)?;
723 }
724 }
725
726 Ok(())
727 }
728}
729
730fn render_hook_script(hook_cfg: &HookConfig) -> Result<Vec<u8>> {
731 let working_directory = shell_single_quote(&hook_cfg.working_directory)?;
732 let command = shell_single_quote(&hook_cfg.command)?;
733 Ok(format!(
734 "#!/usr/bin/env bash\nset -euo pipefail\ncd -- {working_directory}\nexec bash -euo pipefail -c {command}\n"
735 )
736 .into_bytes())
737}
738
739fn shell_single_quote(value: &str) -> Result<String> {
740 if value.contains('\0') {
741 return Err(TuffError::usage(
742 "hook working directory and command cannot contain NUL bytes",
743 ));
744 }
745 Ok(format!("'{}'", value.replace('\'', "'\"'\"'")))
746}
747
748fn serialize_workflow(
749 capability: &ResolvedCapability,
750 workflow: &crate::manifest::WorkflowConfig,
751) -> Result<Vec<u8>> {
752 let document = WorkflowDocument {
753 id: &capability.id,
754 version: &capability.version,
755 capability_type: capability.capability_type,
756 description: &capability.description,
757 workflow,
758 };
759 let mut content = toml::to_string_pretty(&document)?;
760 if !content.ends_with('\n') {
761 content.push('\n');
762 }
763 Ok(content.into_bytes())
764}
765
766#[derive(Serialize)]
767struct McpServerDocument<'a> {
768 id: &'a str,
769 version: &'a str,
770 #[serde(rename = "type")]
771 capability_type: CapabilityType,
772 description: &'a str,
773 server: &'a crate::manifest::McpServerConfig,
774}
775
776fn serialize_mcp_server(
777 capability: &ResolvedCapability,
778 server: &crate::manifest::McpServerConfig,
779) -> Result<Vec<u8>> {
780 let document = McpServerDocument {
781 id: &capability.id,
782 version: &capability.version,
783 capability_type: capability.capability_type,
784 description: &capability.description,
785 server,
786 };
787 let mut content = toml::to_string_pretty(&document)?;
788 if !content.ends_with('\n') {
789 content.push('\n');
790 }
791 Ok(content.into_bytes())
792}
793
794fn path_is_under(path: &Path, root: &Path) -> bool {
795 let canonical_root = root.canonicalize().unwrap_or_else(|_| root.to_path_buf());
796 let canonical_path = path.canonicalize().unwrap_or_else(|_| path.to_path_buf());
797 canonical_path.starts_with(canonical_root)
798}
799
800pub fn replace_hook_dir_placeholder(
801 mut value: serde_json::Value,
802 hook_dir: &str,
803) -> serde_json::Value {
804 match &mut value {
805 serde_json::Value::String(s) => {
806 *s = s.replace("{{hook_dir}}", hook_dir);
807 }
808 serde_json::Value::Array(items) => {
809 for item in items {
810 *item = replace_hook_dir_placeholder(item.take(), hook_dir);
811 }
812 }
813 serde_json::Value::Object(map) => {
814 for item in map.values_mut() {
815 *item = replace_hook_dir_placeholder(item.take(), hook_dir);
816 }
817 }
818 _ => {}
819 }
820 value
821}
822
823fn relative_or_absolute_fs(path: &Path, repo_root: &Path) -> String {
824 crate::lockfile::relative_or_absolute_fs(path, repo_root)
825}
826
827#[cfg(test)]
828mod tests {
829 use super::*;
830 use crate::manifest::{Requirement, WorkflowConfig};
831
832 #[cfg(unix)]
833 #[test]
834 fn hook_script_preserves_shell_sensitive_values() {
835 use std::process::Command;
836
837 let temp = tempfile::tempdir().expect("tempdir");
838 let working_directory = temp.path().join("directory with ' quote");
839 std::fs::create_dir(&working_directory).expect("create working directory");
840 let hook = HookConfig {
841 event: "stop".to_string(),
842 command: "printf '%s\\n' 'safe; $HOME `literal`' > result.txt".to_string(),
843 working_directory: working_directory.to_string_lossy().into_owned(),
844 };
845 let script_path = temp.path().join("run.sh");
846 std::fs::write(
847 &script_path,
848 render_hook_script(&hook).expect("render script"),
849 )
850 .expect("write script");
851
852 let syntax = Command::new("bash")
853 .arg("-n")
854 .arg(&script_path)
855 .status()
856 .expect("check script syntax");
857 assert!(syntax.success());
858 let executed = Command::new("bash")
859 .arg(&script_path)
860 .status()
861 .expect("execute script");
862 assert!(executed.success());
863 assert_eq!(
864 std::fs::read_to_string(working_directory.join("result.txt"))
865 .expect("read command output"),
866 "safe; $HOME `literal`\n"
867 );
868 }
869
870 #[test]
871 fn hook_script_rejects_nul_bytes() {
872 let hook = HookConfig {
873 event: "stop".to_string(),
874 command: "printf '\0'".to_string(),
875 working_directory: ".".to_string(),
876 };
877
878 assert!(render_hook_script(&hook).is_err());
879 }
880
881 #[test]
882 fn workflow_serialization_escapes_manifest_values() {
883 let workflow = WorkflowConfig {
884 requires: vec![Requirement {
885 id: "dependency\"\\name".to_string(),
886 capability_type: CapabilityType::Skill,
887 }],
888 };
889 let capability = ResolvedCapability {
890 id: "workflow\"id".to_string(),
891 capability_type: CapabilityType::Workflow,
892 version: "1.0.0".to_string(),
893 description: "first line\nsecond \"line\" \\ value".to_string(),
894 source_files: Vec::new(),
895 source_dir: PathBuf::new(),
896 kind: CapabilityKind::Workflow {
897 workflow: workflow.clone(),
898 },
899 };
900
901 let bytes = serialize_workflow(&capability, &workflow).expect("serialize workflow");
902 let parsed: toml::Value = toml::from_slice(&bytes).expect("parse emitted workflow");
903
904 assert_eq!(parsed["id"].as_str(), Some("workflow\"id"));
905 assert_eq!(
906 parsed["description"].as_str(),
907 Some("first line\nsecond \"line\" \\ value")
908 );
909 assert_eq!(
910 parsed["workflow"]["requires"][0]["id"].as_str(),
911 Some("dependency\"\\name")
912 );
913 }
914}