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 McpServer {
94 server: crate::manifest::McpServerConfig,
95 },
96 Policy {
97 policy: crate::policy::PolicyConfig,
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::McpServer { .. } => CapabilityType::McpServer,
108 Self::Policy { .. } => CapabilityType::Policy,
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
123pub fn resolve_capability(manifest: &CapabilityManifest) -> Result<ResolvedCapability> {
124 let source_files = manifest.read_source_contents_with_names()?;
125 let kind =
126 match manifest.capability_type {
127 CapabilityType::Skill => CapabilityKind::Skill,
128 CapabilityType::Tool => CapabilityKind::Tool {
129 parameters: manifest.parameters.clone().ok_or_else(|| {
130 TuffError::usage("tool capability requires [parameters] section")
131 })?,
132 implementation: manifest.implementation.clone().ok_or_else(|| {
133 TuffError::usage("tool capability requires [implementation] section")
134 })?,
135 },
136 CapabilityType::Hook => {
137 CapabilityKind::Hook {
138 hook: HookDefinition::Command(manifest.hook.clone().ok_or_else(|| {
139 TuffError::usage("hook capability requires [hook] section")
140 })?),
141 }
142 }
143 CapabilityType::Policy => CapabilityKind::Policy {
144 policy: manifest.policy.clone().ok_or_else(|| {
145 TuffError::usage("policy capability requires a [policy] section")
146 })?,
147 },
148 CapabilityType::McpServer => CapabilityKind::McpServer {
149 server: manifest.server.clone().ok_or_else(|| {
150 TuffError::usage("mcp-server capability requires [server] section")
151 })?,
152 },
153 };
154 Ok(ResolvedCapability {
155 id: manifest.id.clone(),
156 capability_type: manifest.capability_type,
157 version: manifest.version.clone(),
158 description: manifest.description.clone(),
159 source_files,
160 source_dir: manifest.root.clone(),
161 kind,
162 })
163}
164
165pub trait AgentAdapter {
175 fn id(&self) -> &'static str;
176 fn display_name(&self) -> &'static str;
177 fn dir_prefix(&self) -> &'static str;
178 fn mcp_config_relpath(&self) -> &'static str;
179 fn supported_agents(&self) -> &[&'static str];
180 fn hook_compatibility(&self) -> &'static CompatibilityMatrix;
181 fn hook_settings_relpath(&self) -> &'static str;
182 fn hook_settings_shape(&self) -> HookSettingsShape;
188 fn scaffold_hook_event(&self) -> &'static str;
190 fn hook_filename(&self) -> &'static str {
191 "run.sh"
192 }
193 fn hook_file_content(&self, hook_cfg: &crate::manifest::HookConfig) -> Result<Vec<u8>> {
194 render_hook_script(hook_cfg)
195 }
196 fn render_standard_hook(&self, context: HookRenderContext<'_>) -> Result<HookRenderPlan> {
197 let matrix = self.hook_compatibility();
198 let Some(entry) = matrix.find_event(&context.hook.event) else {
199 return Err(TuffError::unsupported(format!(
200 "{} does not support hook event '{}'. A manifest can use: {}",
201 self.display_name(),
202 context.hook.event,
203 matrix.accepted_events_summary()
204 )));
205 };
206 let Some(native_event) = entry.native_event_name() else {
207 let suffix = entry
208 .caveat
209 .map(|caveat| format!(": {}", caveat.trim_end_matches('.')))
210 .unwrap_or_default();
211 return Err(TuffError::unsupported(format!(
212 "{} does not support hook event '{}'{}. A manifest can use: {}",
213 self.display_name(),
214 context.hook.event,
215 suffix,
216 matrix.accepted_events_summary()
217 )));
218 };
219
220 if let Some((listed, _)) = context
225 .source_files
226 .iter()
227 .find(|(relative, _)| relative == self.hook_filename())
228 {
229 return Err(TuffError::refused(format!(
230 "hook '{}' lists a file installed as '{listed}', which is the wrapper Tuff generates to run its command",
231 context.capability_id
232 ))
233 .with_hint(format!(
234 "rename that file; '{}' is reserved in the hook directory",
235 self.hook_filename()
236 )));
237 }
238
239 let command = format!(
240 "sh {}/hooks/{}/{}",
241 self.dir_prefix(),
242 context.capability_id,
243 self.hook_filename()
244 );
245 let target_path = context
246 .repo_root
247 .join(self.dir_prefix())
248 .join("hooks")
249 .join(context.capability_id)
250 .join(self.hook_filename());
251 let script = self.hook_file_content(context.hook)?;
252 let settings_relpath = self.hook_settings_relpath();
253 let fragment = self.command_hook_fragment(native_event, &command);
254 let settings_path = context.repo_root.join(settings_relpath);
255 let existing = if settings_path.is_file() {
256 Some(std::fs::read(&settings_path)?)
257 } else {
258 None
259 };
260 let merged = self.merge_hook_fragment(existing.as_deref(), &fragment)?;
261
262 let mut files = vec![PlannedFile::new(
263 relative_or_absolute_fs(&target_path, context.repo_root),
264 script,
265 )];
266 for (relative, content) in context.source_files {
267 let path = context
268 .repo_root
269 .join(self.dir_prefix())
270 .join("hooks")
271 .join(context.capability_id)
272 .join(relative);
273 files.push(PlannedFile::new(
274 relative_or_absolute_fs(&path, context.repo_root),
275 content.clone(),
276 ));
277 }
278 files.push(PlannedFile::mergeable(
279 relative_or_absolute_fs(&settings_path, context.repo_root),
280 merged,
281 ));
282
283 let mut diagnostics = Vec::new();
284 if entry.coverage == CoverageLevel::Partial {
285 let scope = if entry.scope.is_empty() {
286 "partial coverage".to_string()
287 } else {
288 format!("scope: {}", entry.scope.join(", "))
289 };
290 let caveat = entry
291 .caveat
292 .map(|caveat| format!("; {caveat}"))
293 .unwrap_or_default();
294 diagnostics.push(HookRenderDiagnostic {
295 level: HookRenderDiagnosticLevel::Warning,
296 message: format!(
297 "{} renders '{}' with partial compatibility ({scope}{caveat})",
298 self.display_name(),
299 entry.event
300 ),
301 });
302 }
303
304 let managed_hooks = if context.track_managed_hooks {
305 crate::lockfile::managed_hooks_from_fragment_with_canonical(
306 context.repo_root,
307 settings_relpath,
308 &fragment,
309 Some(entry.event.as_str()),
310 )?
311 } else {
312 Vec::new()
313 };
314
315 Ok(HookRenderPlan {
316 files,
317 managed_hooks,
318 diagnostics,
319 })
320 }
321 fn command_hook_fragment(&self, native_event: &str, command: &str) -> serde_json::Value {
323 self.hook_settings_shape()
324 .command_fragment(native_event, command)
325 }
326 fn merge_hook_fragment(
328 &self,
329 existing: Option<&[u8]>,
330 fragment: &serde_json::Value,
331 ) -> Result<Vec<u8>> {
332 self.hook_settings_shape()
333 .merge_fragment(self.hook_settings_relpath(), existing, fragment)
334 }
335 fn remove_hook_settings(
338 &self,
339 repo_root: &Path,
340 managed_hooks: &[crate::lockfile::ManagedHook],
341 ) -> Result<()> {
342 crate::hook_settings::remove_registrations(
343 self.hook_settings_relpath(),
344 self.display_name(),
345 repo_root,
346 managed_hooks,
347 )
348 }
349 fn detect(&self, repo_root: &Path) -> bool;
351
352 fn policy_compatibility(&self) -> Vec<crate::policy::PolicyCoverageEntry> {
357 crate::policy::not_implemented_matrix()
358 }
359
360 fn permissions_settings_relpath(&self) -> Option<&'static str> {
363 None
364 }
365
366 fn permission_relpath_for(
370 &self,
371 _subject: crate::policy::PolicySubjectKind,
372 ) -> Option<&'static str> {
373 self.permissions_settings_relpath()
374 }
375
376 fn policy_rule_gap(&self, _rule: &crate::policy::PolicyRule) -> Result<Option<String>> {
379 Ok(None)
380 }
381
382 fn native_permission_rules(
386 &self,
387 _rule: &crate::policy::PolicyRule,
388 ) -> Result<Option<Vec<String>>> {
389 Ok(None)
390 }
391
392 fn policy_hook_use(
395 &self,
396 _rule: &crate::policy::PolicyRule,
397 ) -> Result<crate::policy_eval::PolicyHookUse> {
398 Ok(crate::policy_eval::PolicyHookUse::Never)
399 }
400
401 fn policy_hook_fragment(
405 &self,
406 _command: &str,
407 _subjects: &[crate::policy::PolicySubjectKind],
408 ) -> Option<serde_json::Value> {
409 None
410 }
411
412 fn policy_hook_request(
415 &self,
416 _input: &serde_json::Value,
417 ) -> Result<crate::policy_eval::PolicyHookRequest> {
418 Err(TuffError::unsupported(format!(
419 "{} does not run policy hooks",
420 self.display_name()
421 )))
422 }
423
424 fn policy_hook_answer(
427 &self,
428 _event: &str,
429 verdict: crate::policy_eval::PolicyVerdict<'_>,
430 ) -> crate::policy_eval::PolicyHookAnswer {
431 use crate::policy_eval::{PolicyHookAnswer, PolicyVerdict};
432 match verdict {
433 PolicyVerdict::NoMatch => PolicyHookAnswer {
434 stdout: String::new(),
435 exit_code: 0,
436 },
437 PolicyVerdict::Matched(decision) => PolicyHookAnswer {
438 stdout: decision.message(),
439 exit_code: 2,
440 },
441 PolicyVerdict::Failed(message) => PolicyHookAnswer {
442 stdout: message.to_string(),
443 exit_code: 2,
444 },
445 }
446 }
447
448 fn kinds_supported(&self) -> &[CapabilityType];
449
450 fn supports(&self, capability_type: CapabilityType) -> bool {
451 self.kinds_supported().contains(&capability_type)
452 }
453
454 fn native_hook_event(&self, raw_event: &str) -> Result<&'static str> {
455 let matrix = self.hook_compatibility();
456 let Some(entry) = matrix.find_event(raw_event) else {
457 return Err(TuffError::unsupported(format!(
458 "{} does not support hook event '{}'. A manifest can use: {}",
459 self.display_name(),
460 raw_event,
461 matrix.accepted_events_summary()
462 )));
463 };
464 entry.native_event_name().ok_or_else(|| {
465 let suffix = entry
466 .caveat
467 .map(|caveat| format!(": {}", caveat.trim_end_matches('.')))
468 .unwrap_or_default();
469 TuffError::unsupported(format!(
470 "{} does not support hook event '{}'{}. A manifest can use: {}",
471 self.display_name(),
472 raw_event,
473 suffix,
474 matrix.accepted_events_summary()
475 ))
476 })
477 }
478
479 fn canonical_hook_event(&self, raw_event: &str) -> Result<&'static str> {
480 let matrix = self.hook_compatibility();
481 let Some(entry) = matrix.find_event(raw_event) else {
482 return Err(TuffError::unsupported(format!(
483 "{} does not support hook event '{}'",
484 self.display_name(),
485 raw_event
486 )));
487 };
488 entry
489 .coverage
490 .is_supported()
491 .then_some(entry.event.as_str())
492 .ok_or_else(|| {
493 let suffix = entry
494 .caveat
495 .map(|caveat| format!(": {caveat}"))
496 .unwrap_or_default();
497 TuffError::unsupported(format!(
498 "{} does not support hook event '{}'{}",
499 self.display_name(),
500 raw_event,
501 suffix
502 ))
503 })
504 }
505
506 fn ensure_project_dir(&self, repo_root: &Path) -> std::io::Result<()> {
507 std::fs::create_dir_all(repo_root.join(self.dir_prefix()))
508 }
509
510 fn mcp_env_reference(&self, var: &str) -> String {
513 format!("${{{var}}}")
514 }
515
516 fn mcp_server_entry(&self, server: &crate::manifest::McpServerConfig) -> serde_json::Value {
519 use crate::manifest::McpTransport;
520 match server.transport {
521 McpTransport::Stdio => {
522 let mut entry = serde_json::json!({
523 "command": server.command.clone().unwrap_or_default(),
524 "args": server.args,
525 });
526 if !server.env.is_empty() {
527 let env: serde_json::Map<String, serde_json::Value> = server
528 .env
529 .iter()
530 .map(|(name, reference)| {
531 (
532 name.clone(),
533 serde_json::Value::String(
534 self.mcp_env_reference(&reference.from_env),
535 ),
536 )
537 })
538 .collect();
539 entry["env"] = serde_json::Value::Object(env);
540 }
541 entry
542 }
543 McpTransport::Http => {
544 let mut entry = serde_json::Map::new();
545 if self.mcp_http_declares_type() {
546 entry.insert("type".into(), serde_json::Value::String("http".into()));
547 }
548 entry.insert(
549 "url".into(),
550 serde_json::Value::String(server.url.clone().unwrap_or_default()),
551 );
552 if !server.headers.is_empty() {
553 let headers: serde_json::Map<String, serde_json::Value> = server
554 .headers
555 .iter()
556 .map(|(name, reference)| {
557 let value =
558 reference.render(&self.mcp_env_reference(&reference.from_env));
559 (name.clone(), serde_json::Value::String(value))
560 })
561 .collect();
562 entry.insert("headers".into(), serde_json::Value::Object(headers));
563 }
564 serde_json::Value::Object(entry)
565 }
566 }
567 }
568
569 fn mcp_http_declares_type(&self) -> bool {
573 true
574 }
575
576 fn mcp_server_entry_checked(
581 &self,
582 server: &crate::manifest::McpServerConfig,
583 ) -> Result<serde_json::Value> {
584 Ok(self.mcp_server_entry(server))
585 }
586
587 fn install_note(&self, _kind: CapabilityType) -> Option<&'static str> {
592 None
593 }
594
595 fn plan(&self, capability: &ResolvedCapability, repo_root: &Path) -> Result<Vec<PlannedFile>> {
596 match capability.capability_type {
597 CapabilityType::Tool => self.plan_tool(capability, repo_root),
598 CapabilityType::Hook => self.plan_hook(capability, repo_root),
599 CapabilityType::McpServer => self.plan_mcp_server(capability, repo_root),
600 CapabilityType::Policy => self.plan_policy(capability, repo_root),
601 CapabilityType::Skill => self.plan_skill(capability, repo_root),
602 }
603 }
604
605 fn remove(
606 &self,
607 primitive_id: &str,
608 repo_root: &Path,
609 managed_hooks: &[crate::lockfile::ManagedHook],
610 ) -> Result<()> {
611 self.remove_hook_settings(repo_root, managed_hooks)?;
618 crate::mcp::remove_tool(&repo_root.join(self.mcp_config_relpath()), primitive_id)?;
619 let prefix = self.dir_prefix();
620 for kind in &["skills", "tools", "hooks", "mcp-servers", "policies"] {
621 self.remove_dir(repo_root, prefix, kind, primitive_id)?;
622 }
623 Ok(())
624 }
625
626 fn plan_skill(
629 &self,
630 capability: &ResolvedCapability,
631 repo_root: &Path,
632 ) -> Result<Vec<PlannedFile>> {
633 if capability.source_files.is_empty() {
634 return Err(TuffError::usage("no source files to emit"));
635 }
636
637 let mut files = Vec::new();
638 for (rel_path, content) in &capability.source_files {
639 let target_path = repo_root
640 .join(self.dir_prefix())
641 .join("skills")
642 .join(&capability.id)
643 .join(rel_path);
644
645 files.push(PlannedFile::new(
646 relative_or_absolute_fs(&target_path, repo_root),
647 content.clone(),
648 ));
649 }
650 Ok(files)
651 }
652
653 fn plan_tool(
654 &self,
655 capability: &ResolvedCapability,
656 repo_root: &Path,
657 ) -> Result<Vec<PlannedFile>> {
658 let mut files = Vec::new();
659
660 for (rel_path, content) in &capability.source_files {
661 let target_path = repo_root
662 .join(self.dir_prefix())
663 .join("tools")
664 .join(&capability.id)
665 .join(rel_path);
666
667 files.push(PlannedFile::new(
668 relative_or_absolute_fs(&target_path, repo_root),
669 content.clone(),
670 ));
671 }
672
673 if capability.source_files.is_empty() {
674 let placeholder = repo_root
675 .join(self.dir_prefix())
676 .join("tools")
677 .join(&capability.id)
678 .join(".gitkeep");
679 files.push(PlannedFile::new(
680 relative_or_absolute_fs(&placeholder, repo_root),
681 vec![],
682 ));
683 }
684
685 Ok(files)
686 }
687
688 fn plan_hook(
689 &self,
690 capability: &ResolvedCapability,
691 repo_root: &Path,
692 ) -> Result<Vec<PlannedFile>> {
693 let CapabilityKind::Hook { hook } = &capability.kind else {
694 return Err(TuffError::new("plan_hook called on non-hook capability"));
695 };
696
697 match hook {
698 HookDefinition::Command(hook_cfg) => {
699 let render = self.render_standard_hook(HookRenderContext {
700 capability_id: &capability.id,
701 hook: hook_cfg,
702 source_files: &capability.source_files,
703 repo_root,
704 track_managed_hooks: false,
705 })?;
706 Ok(render.files)
707 }
708 HookDefinition::Native(native) => self.plan_native_hook(capability, native, repo_root),
709 }
710 }
711
712 fn plan_native_hook(
713 &self,
714 capability: &ResolvedCapability,
715 native: &NativeHookConfig,
716 repo_root: &Path,
717 ) -> Result<Vec<PlannedFile>> {
718 let hook_root = repo_root
719 .join(self.dir_prefix())
720 .join("hooks")
721 .join(&capability.id);
722 let hook_root_rel = relative_or_absolute_fs(&hook_root, repo_root);
723 let in_harness_source =
724 path_is_under(&capability.source_dir, &repo_root.join(self.dir_prefix()));
725
726 let mut files = Vec::new();
727 if in_harness_source {
728 for (rel_path, content) in &native.source_files {
729 let target_path = capability.source_dir.join(rel_path);
730 files.push(PlannedFile::mergeable(
731 relative_or_absolute_fs(&target_path, repo_root),
732 content.clone(),
733 ));
734 }
735 } else {
736 for (rel_path, content) in &native.source_files {
737 let target_path = hook_root.join(rel_path);
738 files.push(PlannedFile::new(
739 relative_or_absolute_fs(&target_path, repo_root),
740 content.clone(),
741 ));
742 }
743 }
744
745 let fragment = replace_hook_dir_placeholder(native.fragment.clone(), &hook_root_rel);
746 let settings_relpath = self.hook_settings_relpath();
747 let settings_path = repo_root.join(settings_relpath);
748 let existing = if settings_path.is_file() {
749 Some(std::fs::read(&settings_path)?)
750 } else {
751 None
752 };
753 let merged = self.merge_hook_fragment(existing.as_deref(), &fragment)?;
754 files.push(PlannedFile::mergeable(
755 relative_or_absolute_fs(&settings_path, repo_root),
756 merged,
757 ));
758 Ok(files)
759 }
760
761 fn plan_mcp_server(
766 &self,
767 capability: &ResolvedCapability,
768 repo_root: &Path,
769 ) -> Result<Vec<PlannedFile>> {
770 let CapabilityKind::McpServer { server } = &capability.kind else {
771 return Err(TuffError::new(
772 "plan_mcp_server called on non-mcp-server capability",
773 ));
774 };
775
776 let target_path = repo_root
777 .join(self.dir_prefix())
778 .join("mcp-servers")
779 .join(&capability.id)
780 .join("server.toml");
781
782 let content = serialize_mcp_server(capability, server)?;
783
784 Ok(vec![PlannedFile::new(
785 relative_or_absolute_fs(&target_path, repo_root),
786 content,
787 )])
788 }
789
790 fn plan_policy(
795 &self,
796 capability: &ResolvedCapability,
797 repo_root: &Path,
798 ) -> Result<Vec<PlannedFile>> {
799 let CapabilityKind::Policy { policy } = &capability.kind else {
800 return Err(TuffError::new(
801 "plan_policy called on non-policy capability",
802 ));
803 };
804 let target_path = repo_root
805 .join(self.dir_prefix())
806 .join("policies")
807 .join(&capability.id)
808 .join("policy.toml");
809 let content = serialize_policy(capability, policy)?;
810 Ok(vec![PlannedFile::new(
811 relative_or_absolute_fs(&target_path, repo_root),
812 content,
813 )])
814 }
815
816 fn remove_dir(
817 &self,
818 repo_root: &Path,
819 base: &str,
820 kind: &str,
821 primitive_id: &str,
822 ) -> Result<()> {
823 let dir = repo_root.join(base).join(kind).join(primitive_id);
824
825 if dir.exists() {
826 std::fs::remove_dir_all(&dir)?;
827 }
828
829 let kind_dir = dir.parent().expect("kind dir should have parent");
830 if kind_dir.exists() {
831 let mut rd = match std::fs::read_dir(kind_dir) {
832 Ok(rd) => rd,
833 Err(_) => return Ok(()),
834 };
835 if rd.next().is_none() {
836 std::fs::remove_dir(kind_dir)?;
837 }
838 }
839
840 let base_dir = kind_dir.parent().expect("base dir should have parent");
841 if base_dir.exists() {
842 let mut rd = match std::fs::read_dir(base_dir) {
843 Ok(rd) => rd,
844 Err(_) => return Ok(()),
845 };
846 if rd.next().is_none() {
847 std::fs::remove_dir(base_dir)?;
848 }
849 }
850
851 Ok(())
852 }
853}
854
855fn render_hook_script(hook_cfg: &HookConfig) -> Result<Vec<u8>> {
856 let working_directory = shell_single_quote(&hook_cfg.working_directory)?;
857 let command = shell_single_quote(&hook_cfg.command)?;
858 Ok(format!(
859 "#!/usr/bin/env bash\nset -euo pipefail\ncd -- {working_directory}\nexec bash -euo pipefail -c {command}\n"
860 )
861 .into_bytes())
862}
863
864fn shell_single_quote(value: &str) -> Result<String> {
865 if value.contains('\0') {
866 return Err(TuffError::usage(
867 "hook working directory and command cannot contain NUL bytes",
868 ));
869 }
870 Ok(format!("'{}'", value.replace('\'', "'\"'\"'")))
871}
872
873#[derive(Serialize)]
874struct McpServerDocument<'a> {
875 id: &'a str,
876 version: &'a str,
877 #[serde(rename = "type")]
878 capability_type: CapabilityType,
879 description: &'a str,
880 server: &'a crate::manifest::McpServerConfig,
881}
882
883fn serialize_mcp_server(
884 capability: &ResolvedCapability,
885 server: &crate::manifest::McpServerConfig,
886) -> Result<Vec<u8>> {
887 let document = McpServerDocument {
888 id: &capability.id,
889 version: &capability.version,
890 capability_type: capability.capability_type,
891 description: &capability.description,
892 server,
893 };
894 let mut content = toml::to_string_pretty(&document)?;
895 if !content.ends_with('\n') {
896 content.push('\n');
897 }
898 Ok(content.into_bytes())
899}
900
901#[derive(Serialize)]
902struct PolicyDocument<'a> {
903 id: &'a str,
904 version: &'a str,
905 #[serde(rename = "type")]
906 capability_type: CapabilityType,
907 description: &'a str,
908 policy: &'a crate::policy::PolicyConfig,
909}
910
911fn serialize_policy(
912 capability: &ResolvedCapability,
913 policy: &crate::policy::PolicyConfig,
914) -> Result<Vec<u8>> {
915 let document = PolicyDocument {
916 id: &capability.id,
917 version: &capability.version,
918 capability_type: capability.capability_type,
919 description: &capability.description,
920 policy,
921 };
922 let mut content = toml::to_string_pretty(&document)?;
923 if !content.ends_with('\n') {
924 content.push('\n');
925 }
926 Ok(content.into_bytes())
927}
928
929fn path_is_under(path: &Path, root: &Path) -> bool {
930 let canonical_root = root.canonicalize().unwrap_or_else(|_| root.to_path_buf());
931 let canonical_path = path.canonicalize().unwrap_or_else(|_| path.to_path_buf());
932 canonical_path.starts_with(canonical_root)
933}
934
935pub fn replace_hook_dir_placeholder(
936 mut value: serde_json::Value,
937 hook_dir: &str,
938) -> serde_json::Value {
939 match &mut value {
940 serde_json::Value::String(s) => {
941 *s = s.replace("{{hook_dir}}", hook_dir);
942 }
943 serde_json::Value::Array(items) => {
944 for item in items {
945 *item = replace_hook_dir_placeholder(item.take(), hook_dir);
946 }
947 }
948 serde_json::Value::Object(map) => {
949 for item in map.values_mut() {
950 *item = replace_hook_dir_placeholder(item.take(), hook_dir);
951 }
952 }
953 _ => {}
954 }
955 value
956}
957
958fn relative_or_absolute_fs(path: &Path, repo_root: &Path) -> String {
959 crate::lockfile::relative_or_absolute_fs(path, repo_root)
960}
961
962#[cfg(test)]
963mod tests {
964 use super::*;
965
966 #[cfg(unix)]
967 #[test]
968 fn hook_script_preserves_shell_sensitive_values() {
969 use std::process::Command;
970
971 let temp = tempfile::tempdir().expect("tempdir");
972 let working_directory = temp.path().join("directory with ' quote");
973 std::fs::create_dir(&working_directory).expect("create working directory");
974 let hook = HookConfig {
975 event: "stop".to_string(),
976 command: "printf '%s\\n' 'safe; $HOME `literal`' > result.txt".to_string(),
977 working_directory: working_directory.to_string_lossy().into_owned(),
978 };
979 let script_path = temp.path().join("run.sh");
980 std::fs::write(
981 &script_path,
982 render_hook_script(&hook).expect("render script"),
983 )
984 .expect("write script");
985
986 let syntax = Command::new("bash")
987 .arg("-n")
988 .arg(&script_path)
989 .status()
990 .expect("check script syntax");
991 assert!(syntax.success());
992 let executed = Command::new("bash")
993 .arg(&script_path)
994 .status()
995 .expect("execute script");
996 assert!(executed.success());
997 assert_eq!(
998 std::fs::read_to_string(working_directory.join("result.txt"))
999 .expect("read command output"),
1000 "safe; $HOME `literal`\n"
1001 );
1002 }
1003
1004 #[test]
1005 fn hook_script_rejects_nul_bytes() {
1006 let hook = HookConfig {
1007 event: "stop".to_string(),
1008 command: "printf '\0'".to_string(),
1009 working_directory: ".".to_string(),
1010 };
1011
1012 assert!(render_hook_script(&hook).is_err());
1013 }
1014}