diff --git a/crates/goose-server/src/openapi.rs b/crates/goose-server/src/openapi.rs index e44a3362af9d..2174d13211bc 100644 --- a/crates/goose-server/src/openapi.rs +++ b/crates/goose-server/src/openapi.rs @@ -389,7 +389,8 @@ impl<'__s> ToSchema<'__s> for AnnotatedSchema { super::routes::schedule::sessions_handler, super::routes::recipe::create_recipe, super::routes::recipe::encode_recipe, - super::routes::recipe::decode_recipe + super::routes::recipe::decode_recipe, + super::routes::recipe::scan_recipe ), components(schemas( super::routes::config_management::UpsertConfigQuery, @@ -456,6 +457,8 @@ impl<'__s> ToSchema<'__s> for AnnotatedSchema { super::routes::recipe::EncodeRecipeResponse, super::routes::recipe::DecodeRecipeRequest, super::routes::recipe::DecodeRecipeResponse, + super::routes::recipe::ScanRecipeRequest, + super::routes::recipe::ScanRecipeResponse, goose::recipe::Recipe, goose::recipe::Author, goose::recipe::Settings, diff --git a/crates/goose-server/src/routes/recipe.rs b/crates/goose-server/src/routes/recipe.rs index 532245159fe8..899f3dfc666d 100644 --- a/crates/goose-server/src/routes/recipe.rs +++ b/crates/goose-server/src/routes/recipe.rs @@ -56,6 +56,16 @@ pub struct DecodeRecipeResponse { recipe: Recipe, } +#[derive(Debug, Deserialize, ToSchema)] +pub struct ScanRecipeRequest { + recipe: Recipe, +} + +#[derive(Debug, Serialize, ToSchema)] +pub struct ScanRecipeResponse { + has_security_warnings: bool, +} + #[utoipa::path( post, path = "/recipes/create", @@ -164,11 +174,31 @@ async fn decode_recipe( } } +#[utoipa::path( + post, + path = "/recipes/scan", + request_body = ScanRecipeRequest, + responses( + (status = 200, description = "Recipe scanned successfully", body = ScanRecipeResponse), + ), + tag = "Recipe Management" +)] +async fn scan_recipe( + Json(request): Json, +) -> Result, StatusCode> { + let has_security_warnings = request.recipe.check_for_security_warnings(); + + Ok(Json(ScanRecipeResponse { + has_security_warnings, + })) +} + pub fn routes(state: Arc) -> Router { Router::new() .route("/recipes/create", post(create_recipe)) .route("/recipes/encode", post(encode_recipe)) .route("/recipes/decode", post(decode_recipe)) + .route("/recipes/scan", post(scan_recipe)) .with_state(state) } diff --git a/crates/goose/src/recipe/mod.rs b/crates/goose/src/recipe/mod.rs index 8f50d8baed1e..f3ad0c25566c 100644 --- a/crates/goose/src/recipe/mod.rs +++ b/crates/goose/src/recipe/mod.rs @@ -5,6 +5,7 @@ use std::fmt; use crate::agents::extension::ExtensionConfig; use crate::agents::types::RetryConfig; +use crate::utils::contains_unicode_tags; use serde::de::Deserializer; use serde::{Deserialize, Serialize}; use utoipa::ToSchema; @@ -253,6 +254,25 @@ pub struct RecipeBuilder { } impl Recipe { + /// Returns true if harmful content is detected in instructions, prompt, or activities fields + pub fn check_for_security_warnings(&self) -> bool { + if [self.instructions.as_deref(), self.prompt.as_deref()] + .iter() + .flatten() + .any(|&field| contains_unicode_tags(field)) + { + return true; + } + + if let Some(activities) = &self.activities { + return activities + .iter() + .any(|activity| contains_unicode_tags(activity)); + } + + false + } + /// Creates a new RecipeBuilder to construct a Recipe instance /// /// # Example @@ -746,4 +766,41 @@ isGlobal: true"#; let extensions = recipe.extensions.unwrap(); assert_eq!(extensions.len(), 0); } + + #[test] + fn test_check_for_security_warnings() { + let mut recipe = Recipe { + version: "1.0.0".to_string(), + title: "Test".to_string(), + description: "Test".to_string(), + instructions: Some("clean instructions".to_string()), + prompt: Some("clean prompt".to_string()), + extensions: None, + context: None, + settings: None, + activities: Some(vec!["clean activity 1".to_string()]), + author: None, + parameters: None, + response: None, + sub_recipes: None, + retry: None, + }; + + assert!(!recipe.check_for_security_warnings()); + + // Malicious activities + recipe.activities = Some(vec![ + "clean activity".to_string(), + format!("malicious{}activity", '\u{E0041}'), + ]); + assert!(recipe.check_for_security_warnings()); + + // Malicious instructions + recipe.instructions = Some(format!("instructions{}", '\u{E0041}')); + assert!(recipe.check_for_security_warnings()); + + // Malicious prompt + recipe.prompt = Some(format!("prompt{}", '\u{E0042}')); + assert!(recipe.check_for_security_warnings()); + } } diff --git a/crates/goose/src/utils.rs b/crates/goose/src/utils.rs index 32ea5c259df7..c165928c8492 100644 --- a/crates/goose/src/utils.rs +++ b/crates/goose/src/utils.rs @@ -1,17 +1,23 @@ use tokio_util::sync::CancellationToken; use unicode_normalization::UnicodeNormalization; +/// Check if a character is in the Unicode Tags Block range (U+E0000-U+E007F) +/// These characters are invisible and can be used for steganographic attacks +fn is_in_unicode_tag_range(c: char) -> bool { + matches!(c, '\u{E0000}'..='\u{E007F}') +} + +pub fn contains_unicode_tags(text: &str) -> bool { + text.chars().any(is_in_unicode_tag_range) +} + /// Sanitize Unicode Tags Block characters from text -/// Used to prevent Unicode-based prompt injection attacks -/// -/// This function removes invisible Unicode Tags Block characters (U+E0000-U+E007F) -/// that can be used for steganographic attacks while preserving legitimate Unicode. pub fn sanitize_unicode_tags(text: &str) -> String { let normalized: String = text.nfc().collect(); normalized .chars() - .filter(|&c| !matches!(c, '\u{E0000}'..='\u{E007F}')) + .filter(|&c| !is_in_unicode_tag_range(c)) .collect() } @@ -45,6 +51,17 @@ pub fn is_token_cancelled(cancellation_token: &Option) -> boo mod tests { use super::*; + #[test] + fn test_contains_unicode_tags() { + // Test detection of Unicode Tags Block characters + assert!(contains_unicode_tags("Hello\u{E0041}world")); + assert!(contains_unicode_tags("\u{E0000}")); + assert!(contains_unicode_tags("\u{E007F}")); + assert!(!contains_unicode_tags("Hello world")); + assert!(!contains_unicode_tags("Hello 世界 🌍")); + assert!(!contains_unicode_tags("")); + } + #[test] fn test_sanitize_unicode_tags() { // Test that Unicode Tags Block characters are removed diff --git a/ui/desktop/openapi.json b/ui/desktop/openapi.json index 4bf61bc514a3..8a603cc5727f 100644 --- a/ui/desktop/openapi.json +++ b/ui/desktop/openapi.json @@ -780,6 +780,36 @@ } } }, + "/recipes/scan": { + "post": { + "tags": [ + "Recipe Management" + ], + "operationId": "scan_recipe", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ScanRecipeRequest" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "Recipe scanned successfully", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ScanRecipeResponse" + } + } + } + } + } + } + }, "/schedule/create": { "post": { "tags": [ @@ -2795,6 +2825,28 @@ } } }, + "ScanRecipeRequest": { + "type": "object", + "required": [ + "recipe" + ], + "properties": { + "recipe": { + "$ref": "#/components/schemas/Recipe" + } + } + }, + "ScanRecipeResponse": { + "type": "object", + "required": [ + "has_security_warnings" + ], + "properties": { + "has_security_warnings": { + "type": "boolean" + } + } + }, "ScheduledJob": { "type": "object", "required": [ diff --git a/ui/desktop/src/api/sdk.gen.ts b/ui/desktop/src/api/sdk.gen.ts index 29709f7448b8..839265ceb272 100644 --- a/ui/desktop/src/api/sdk.gen.ts +++ b/ui/desktop/src/api/sdk.gen.ts @@ -1,7 +1,7 @@ // This file is auto-generated by @hey-api/openapi-ts import type { Options as ClientOptions, TDataShape, Client } from './client'; -import type { AddSubRecipesData, AddSubRecipesResponses, AddSubRecipesErrors, ExtendPromptData, ExtendPromptResponses, ExtendPromptErrors, UpdateSessionConfigData, UpdateSessionConfigResponses, UpdateSessionConfigErrors, GetToolsData, GetToolsResponses, GetToolsErrors, UpdateAgentProviderData, UpdateAgentProviderResponses, UpdateAgentProviderErrors, UpdateRouterToolSelectorData, UpdateRouterToolSelectorResponses, UpdateRouterToolSelectorErrors, ReadAllConfigData, ReadAllConfigResponses, BackupConfigData, BackupConfigResponses, BackupConfigErrors, GetExtensionsData, GetExtensionsResponses, GetExtensionsErrors, AddExtensionData, AddExtensionResponses, AddExtensionErrors, RemoveExtensionData, RemoveExtensionResponses, RemoveExtensionErrors, InitConfigData, InitConfigResponses, InitConfigErrors, UpsertPermissionsData, UpsertPermissionsResponses, UpsertPermissionsErrors, ProvidersData, ProvidersResponses, ReadConfigData, ReadConfigResponses, ReadConfigErrors, RecoverConfigData, RecoverConfigResponses, RecoverConfigErrors, RemoveConfigData, RemoveConfigResponses, RemoveConfigErrors, UpsertConfigData, UpsertConfigResponses, UpsertConfigErrors, ValidateConfigData, ValidateConfigResponses, ValidateConfigErrors, ConfirmPermissionData, ConfirmPermissionResponses, ConfirmPermissionErrors, ManageContextData, ManageContextResponses, ManageContextErrors, CreateRecipeData, CreateRecipeResponses, CreateRecipeErrors, DecodeRecipeData, DecodeRecipeResponses, DecodeRecipeErrors, EncodeRecipeData, EncodeRecipeResponses, EncodeRecipeErrors, CreateScheduleData, CreateScheduleResponses, CreateScheduleErrors, DeleteScheduleData, DeleteScheduleResponses, DeleteScheduleErrors, ListSchedulesData, ListSchedulesResponses, ListSchedulesErrors, UpdateScheduleData, UpdateScheduleResponses, UpdateScheduleErrors, InspectRunningJobData, InspectRunningJobResponses, InspectRunningJobErrors, KillRunningJobData, KillRunningJobResponses, PauseScheduleData, PauseScheduleResponses, PauseScheduleErrors, RunNowHandlerData, RunNowHandlerResponses, RunNowHandlerErrors, SessionsHandlerData, SessionsHandlerResponses, SessionsHandlerErrors, UnpauseScheduleData, UnpauseScheduleResponses, UnpauseScheduleErrors, ListSessionsData, ListSessionsResponses, ListSessionsErrors, GetSessionHistoryData, GetSessionHistoryResponses, GetSessionHistoryErrors } from './types.gen'; +import type { AddSubRecipesData, AddSubRecipesResponses, AddSubRecipesErrors, ExtendPromptData, ExtendPromptResponses, ExtendPromptErrors, UpdateSessionConfigData, UpdateSessionConfigResponses, UpdateSessionConfigErrors, GetToolsData, GetToolsResponses, GetToolsErrors, UpdateAgentProviderData, UpdateAgentProviderResponses, UpdateAgentProviderErrors, UpdateRouterToolSelectorData, UpdateRouterToolSelectorResponses, UpdateRouterToolSelectorErrors, ReadAllConfigData, ReadAllConfigResponses, BackupConfigData, BackupConfigResponses, BackupConfigErrors, GetExtensionsData, GetExtensionsResponses, GetExtensionsErrors, AddExtensionData, AddExtensionResponses, AddExtensionErrors, RemoveExtensionData, RemoveExtensionResponses, RemoveExtensionErrors, InitConfigData, InitConfigResponses, InitConfigErrors, UpsertPermissionsData, UpsertPermissionsResponses, UpsertPermissionsErrors, ProvidersData, ProvidersResponses, ReadConfigData, ReadConfigResponses, ReadConfigErrors, RecoverConfigData, RecoverConfigResponses, RecoverConfigErrors, RemoveConfigData, RemoveConfigResponses, RemoveConfigErrors, UpsertConfigData, UpsertConfigResponses, UpsertConfigErrors, ValidateConfigData, ValidateConfigResponses, ValidateConfigErrors, ConfirmPermissionData, ConfirmPermissionResponses, ConfirmPermissionErrors, ManageContextData, ManageContextResponses, ManageContextErrors, CreateRecipeData, CreateRecipeResponses, CreateRecipeErrors, DecodeRecipeData, DecodeRecipeResponses, DecodeRecipeErrors, EncodeRecipeData, EncodeRecipeResponses, EncodeRecipeErrors, ScanRecipeData, ScanRecipeResponses, CreateScheduleData, CreateScheduleResponses, CreateScheduleErrors, DeleteScheduleData, DeleteScheduleResponses, DeleteScheduleErrors, ListSchedulesData, ListSchedulesResponses, ListSchedulesErrors, UpdateScheduleData, UpdateScheduleResponses, UpdateScheduleErrors, InspectRunningJobData, InspectRunningJobResponses, InspectRunningJobErrors, KillRunningJobData, KillRunningJobResponses, PauseScheduleData, PauseScheduleResponses, PauseScheduleErrors, RunNowHandlerData, RunNowHandlerResponses, RunNowHandlerErrors, SessionsHandlerData, SessionsHandlerResponses, SessionsHandlerErrors, UnpauseScheduleData, UnpauseScheduleResponses, UnpauseScheduleErrors, ListSessionsData, ListSessionsResponses, ListSessionsErrors, GetSessionHistoryData, GetSessionHistoryResponses, GetSessionHistoryErrors } from './types.gen'; import { client as _heyApiClient } from './client.gen'; export type Options = ClientOptions & { @@ -245,6 +245,17 @@ export const encodeRecipe = (options: Opti }); }; +export const scanRecipe = (options: Options) => { + return (options.client ?? _heyApiClient).post({ + url: '/recipes/scan', + ...options, + headers: { + 'Content-Type': 'application/json', + ...options.headers + } + }); +}; + export const createSchedule = (options: Options) => { return (options.client ?? _heyApiClient).post({ url: '/schedule/create', diff --git a/ui/desktop/src/api/types.gen.ts b/ui/desktop/src/api/types.gen.ts index bb3176502b2e..83938c589e16 100644 --- a/ui/desktop/src/api/types.gen.ts +++ b/ui/desktop/src/api/types.gen.ts @@ -588,6 +588,14 @@ export type RunNowResponse = { session_id: string; }; +export type ScanRecipeRequest = { + recipe: Recipe; +}; + +export type ScanRecipeResponse = { + has_security_warnings: boolean; +}; + export type ScheduledJob = { cron: string; current_session_id?: string | null; @@ -1434,6 +1442,22 @@ export type EncodeRecipeResponses = { export type EncodeRecipeResponse2 = EncodeRecipeResponses[keyof EncodeRecipeResponses]; +export type ScanRecipeData = { + body: ScanRecipeRequest; + path?: never; + query?: never; + url: '/recipes/scan'; +}; + +export type ScanRecipeResponses = { + /** + * Recipe scanned successfully + */ + 200: ScanRecipeResponse; +}; + +export type ScanRecipeResponse2 = ScanRecipeResponses[keyof ScanRecipeResponses]; + export type CreateScheduleData = { body: CreateScheduleRequest; path?: never; diff --git a/ui/desktop/src/components/BaseChat.tsx b/ui/desktop/src/components/BaseChat.tsx index d7ebf70593a4..3de831c6fef8 100644 --- a/ui/desktop/src/components/BaseChat.tsx +++ b/ui/desktop/src/components/BaseChat.tsx @@ -200,6 +200,7 @@ function BaseChatContent({ recipeAccepted, handleRecipeAccept, handleRecipeCancel, + hasSecurityWarnings, } = useRecipeManager(messages, location.state); // Reset recipe usage tracking when recipe changes @@ -573,6 +574,7 @@ function BaseChatContent({ description: recipeConfig?.description, instructions: recipeConfig?.instructions || undefined, }} + hasSecurityWarnings={hasSecurityWarnings} /> {/* Recipe Parameter Modal */} diff --git a/ui/desktop/src/components/ui/RecipeWarningModal.tsx b/ui/desktop/src/components/ui/RecipeWarningModal.tsx index 0bf291f2accf..553b514b8ab1 100644 --- a/ui/desktop/src/components/ui/RecipeWarningModal.tsx +++ b/ui/desktop/src/components/ui/RecipeWarningModal.tsx @@ -18,6 +18,7 @@ interface RecipeWarningModalProps { description?: string; instructions?: string; }; + hasSecurityWarnings?: boolean; } export function RecipeWarningModal({ @@ -25,18 +26,39 @@ export function RecipeWarningModal({ onConfirm, onCancel, recipeDetails, + hasSecurityWarnings = false, }: RecipeWarningModalProps) { return ( !open && onCancel()}> - ⚠️ New Recipe Warning + + {hasSecurityWarnings ? '⚠️ Security Warning' : '⚠️ New Recipe Warning'} + - You are about to execute a recipe that you haven't run before. Only proceed if you trust - the source of this recipe. + {!hasSecurityWarnings && + "You are about to execute a recipe that you haven't run before. "} + Only proceed if you trust the source of this recipe. + {hasSecurityWarnings && ( +
+
+
+
+
+

+ This recipe contains hidden characters that will be ignored for your safety, + as they could be used for malicious purposes. +

+
+
+
+
+
+ )} +

Recipe Preview:

diff --git a/ui/desktop/src/hooks/useRecipeManager.ts b/ui/desktop/src/hooks/useRecipeManager.ts index 17ed7c41bca2..6b2e3ccea51b 100644 --- a/ui/desktop/src/hooks/useRecipeManager.ts +++ b/ui/desktop/src/hooks/useRecipeManager.ts @@ -1,5 +1,5 @@ import { useEffect, useMemo, useState, useRef } from 'react'; -import { createRecipe, Recipe } from '../recipe'; +import { createRecipe, Recipe, scanRecipe } from '../recipe'; import { Message, createUserMessage } from '../types/message'; import { updateSystemPromptWithParameters } from '../utils/providerUtils'; import { useChatContext } from '../contexts/ChatContext'; @@ -17,6 +17,7 @@ export const useRecipeManager = (messages: Message[], locationState?: LocationSt const [recipeError, setRecipeError] = useState(null); const [isRecipeWarningModalOpen, setIsRecipeWarningModalOpen] = useState(false); const [recipeAccepted, setRecipeAccepted] = useState(false); + const [hasSecurityWarnings, setHasSecurityWarnings] = useState(false); // Get chat context to access persisted recipe and parameters const chatContext = useChatContext(); @@ -78,20 +79,23 @@ export const useRecipeManager = (messages: Message[], locationState?: LocationSt } }, [chatContext, locationState]); - // Check if recipe has been accepted before + // Check if recipe has been accepted before and scan for security warnings useEffect(() => { const checkRecipeAcceptance = async () => { if (recipeConfig) { try { const hasAccepted = await window.electron.hasAcceptedRecipeBefore(recipeConfig); + if (!hasAccepted) { + const securityScanResult = await scanRecipe(recipeConfig); + setHasSecurityWarnings(securityScanResult.has_security_warnings); + setIsRecipeWarningModalOpen(true); } else { setRecipeAccepted(true); } - } catch (error) { - console.error('Error checking recipe acceptance:', error); - // If there's an error, assume the recipe hasn't been accepted + } catch { + setHasSecurityWarnings(false); setIsRecipeWarningModalOpen(true); } } @@ -311,5 +315,6 @@ export const useRecipeManager = (messages: Message[], locationState?: LocationSt recipeAccepted, handleRecipeAccept, handleRecipeCancel, + hasSecurityWarnings, }; }; diff --git a/ui/desktop/src/recipe/index.ts b/ui/desktop/src/recipe/index.ts index 1a51c95d90ce..4e24119ef488 100644 --- a/ui/desktop/src/recipe/index.ts +++ b/ui/desktop/src/recipe/index.ts @@ -2,6 +2,7 @@ import { createRecipe as apiCreateRecipe, encodeRecipe as apiEncodeRecipe, decodeRecipe as apiDecodeRecipe, + scanRecipe as apiScanRecipe, } from '../api'; import type { CreateRecipeRequest as ApiCreateRecipeRequest, @@ -133,6 +134,23 @@ export async function decodeRecipe(deeplink: string): Promise { } } +export async function scanRecipe(recipe: Recipe): Promise<{ has_security_warnings: boolean }> { + try { + const response = await apiScanRecipe({ + body: { recipe }, + }); + + if (!response.data) { + throw new Error('No data returned from API'); + } + + return response.data; + } catch (error) { + console.error('Failed to scan recipe:', error); + throw error; + } +} + export async function generateDeepLink(recipe: Recipe): Promise { const encoded = await encodeRecipe(recipe); return `goose://recipe?config=${encoded}`;