diff --git a/crates/goose/src/recipe/build_recipe/mod.rs b/crates/goose/src/recipe/build_recipe/mod.rs index 8a8584be61d4..220148742a37 100644 --- a/crates/goose/src/recipe/build_recipe/mod.rs +++ b/crates/goose/src/recipe/build_recipe/mod.rs @@ -1,7 +1,8 @@ -use crate::recipe::read_recipe_file_content::RecipeFile; +use crate::recipe::read_recipe_file_content::{read_parameter_file_content, RecipeFile}; use crate::recipe::template_recipe::{parse_recipe_content, render_recipe_content_with_params}; use crate::recipe::{ - Recipe, RecipeParameter, RecipeParameterRequirement, BUILT_IN_RECIPE_DIR_PARAM, + Recipe, RecipeParameter, RecipeParameterInputType, RecipeParameterRequirement, + BUILT_IN_RECIPE_DIR_PARAM, }; use anyhow::Result; use std::collections::{HashMap, HashSet}; @@ -146,9 +147,20 @@ fn validate_parameters_in_template( } fn validate_optional_parameters(parameters: &Option>) -> Result<()> { - let optional_params_without_default_values: Vec = parameters - .as_ref() - .unwrap_or(&vec![]) + let empty_params = vec![]; + let params = parameters.as_ref().unwrap_or(&empty_params); + + let file_params_with_defaults: Vec = params + .iter() + .filter(|p| matches!(p.input_type, RecipeParameterInputType::File) && p.default.is_some()) + .map(|p| p.key.clone()) + .collect(); + + if !file_params_with_defaults.is_empty() { + return Err(anyhow::anyhow!("File parameters cannot have default values to avoid importing sensitive user files: {}", file_params_with_defaults.join(", "))); + } + + let optional_params_without_default_values: Vec = params .iter() .filter(|p| { matches!(p.requirement, RecipeParameterRequirement::Optional) && p.default.is_none() @@ -192,6 +204,10 @@ where None } }; + } else if matches!(param.input_type, RecipeParameterInputType::File) { + let file_path = param_map.get(¶m.key).unwrap(); + let file_content = read_parameter_file_content(file_path)?; + param_map.insert(param.key.clone(), file_content); } } Ok((param_map, missing_params)) diff --git a/crates/goose/src/recipe/build_recipe/tests.rs b/crates/goose/src/recipe/build_recipe/tests.rs index a1574683e759..52c2834cf0d4 100644 --- a/crates/goose/src/recipe/build_recipe/tests.rs +++ b/crates/goose/src/recipe/build_recipe/tests.rs @@ -33,6 +33,34 @@ mod tests { (temp_dir, recipe_file) } + fn setup_test_file(temp_dir: &TempDir, filename: &str, content: &str) -> std::path::PathBuf { + let file_path = temp_dir.path().join(filename); + std::fs::write(&file_path, content).unwrap(); + file_path + } + + fn setup_yaml_recipe_file(instructions_and_parameters: &str) -> (TempDir, RecipeFile) { + let recipe_content = format!( + r#"version: "1.0.0" +title: "Test Recipe" +description: "A test recipe" +{}"#, + instructions_and_parameters + ); + let temp_dir = tempfile::tempdir().unwrap(); + let recipe_path = temp_dir.path().join("test_recipe.yaml"); + + std::fs::write(&recipe_path, recipe_content).unwrap(); + + let recipe_file = RecipeFile { + content: std::fs::read_to_string(&recipe_path).unwrap(), + parent_dir: temp_dir.path().to_path_buf(), + file_path: recipe_path, + }; + + (temp_dir, recipe_file) + } + fn setup_yaml_recipe_files( parent_content: &str, child_content: &str, @@ -439,4 +467,86 @@ instructions: Child instructions ); } } + + mod file_parameter_tests { + use super::*; + + #[test] + fn test_build_recipe_file_parameter_valid_paths() { + let instructions_and_parameters = r#"instructions: "Test file content: {{ FILE_PARAM }}" +parameters: + - key: FILE_PARAM + input_type: file + requirement: required + description: A file parameter"#; + + let (temp_dir, recipe_file) = setup_yaml_recipe_file(instructions_and_parameters); + + let test_content = "Hello from file!\nThis is line 2\n Indented line 3"; + let test_file_path = setup_test_file(&temp_dir, "test_file.txt", test_content); + + let params = vec![( + "FILE_PARAM".to_string(), + test_file_path.to_string_lossy().to_string(), + )]; + let result = build_recipe_from_template(recipe_file, params, NO_USER_PROMPT); + + assert!(result.is_ok()); + let recipe = result.unwrap(); + + let instructions = recipe.instructions.as_ref().unwrap(); + assert!(instructions.contains("Hello from file!")); + assert!(instructions.contains("Test file content:")); + } + + #[test] + fn test_build_recipe_file_parameter_nonexistent_file() { + let instructions_and_parameters = r#"instructions: "Test file content: {{ FILE_PARAM }}" +parameters: + - key: FILE_PARAM + input_type: file + requirement: required + description: A file parameter"#; + + let (_temp_dir, recipe_file) = setup_yaml_recipe_file(instructions_and_parameters); + + let params = vec![( + "FILE_PARAM".to_string(), + "/nonexistent/path/file.txt".to_string(), + )]; + let result = build_recipe_from_template(recipe_file, params, NO_USER_PROMPT); + + assert!(result.is_err()); + if let Err(RecipeError::TemplateRendering { source }) = result { + assert!(source.to_string().contains("Failed to read parameter file")); + } else { + panic!("Expected TemplateRendering error"); + } + } + + #[test] + fn test_build_recipe_file_parameter_with_default_rejected() { + let instructions_and_parameters = r#"instructions: "Test file content: {{ FILE_PARAM }}" +parameters: + - key: FILE_PARAM + input_type: file + requirement: required + description: A file parameter + default: "/etc/passwd""#; + + let (_temp_dir, recipe_file) = setup_yaml_recipe_file(instructions_and_parameters); + + let params = vec![]; + let result = build_recipe_from_template(recipe_file, params, NO_USER_PROMPT); + + assert!(result.is_err()); + if let Err(RecipeError::TemplateRendering { source }) = result { + assert!(source + .to_string() + .contains("File parameters cannot have default values")); + } else { + panic!("Expected TemplateRendering error for file parameter with default"); + } + } + } } diff --git a/crates/goose/src/recipe/mod.rs b/crates/goose/src/recipe/mod.rs index f3ad0c25566c..a7a6bc63c7b6 100644 --- a/crates/goose/src/recipe/mod.rs +++ b/crates/goose/src/recipe/mod.rs @@ -206,6 +206,8 @@ pub enum RecipeParameterInputType { Number, Boolean, Date, + /// File parameter that imports content from a file path. + /// Cannot have default values to prevent importing sensitive user files. File, Select, } diff --git a/crates/goose/src/recipe/read_recipe_file_content.rs b/crates/goose/src/recipe/read_recipe_file_content.rs index 61992b616457..61b740304a04 100644 --- a/crates/goose/src/recipe/read_recipe_file_content.rs +++ b/crates/goose/src/recipe/read_recipe_file_content.rs @@ -58,3 +58,43 @@ fn convert_path_with_tilde_expansion(path: &Path) -> PathBuf { } PathBuf::from(path) } + +pub fn read_parameter_file_content>(file_path: P) -> Result { + let raw_path = file_path.as_ref(); + let path = convert_path_with_tilde_expansion(raw_path); + + let content = fs::read_to_string(&path) + .map_err(|e| anyhow!("Failed to read parameter file {}: {}", path.display(), e))?; + + Ok(content) +} + +#[cfg(test)] +mod tests { + use super::*; + use tempfile::TempDir; + + #[test] + fn test_read_parameter_file_content_success() { + let temp_dir = TempDir::new().unwrap(); + let file_path = temp_dir.path().join("test_file.txt"); + let content = "Hello World\nSecond line\n Third line"; + std::fs::write(&file_path, content).unwrap(); + + let result = read_parameter_file_content(&file_path); + assert!(result.is_ok()); + + let expected = "Hello World\nSecond line\n Third line"; + assert_eq!(result.unwrap(), expected); + } + + #[test] + fn test_read_parameter_file_content_nonexistent_file() { + let result = read_parameter_file_content("/nonexistent/path/file.txt"); + assert!(result.is_err()); + assert!(result + .unwrap_err() + .to_string() + .contains("Failed to read parameter file")); + } +}