Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion crates/goose-server/src/openapi.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand Down
30 changes: 30 additions & 0 deletions crates/goose-server/src/routes/recipe.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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<ScanRecipeRequest>,
) -> Result<Json<ScanRecipeResponse>, StatusCode> {
let has_security_warnings = request.recipe.check_for_security_warnings();

Ok(Json(ScanRecipeResponse {
has_security_warnings,
}))
}

pub fn routes(state: Arc<AppState>) -> 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)
}

Expand Down
57 changes: 57 additions & 0 deletions crates/goose/src/recipe/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -746,4 +766,41 @@ isGlobal: true"#;
let extensions = recipe.extensions.unwrap();
assert_eq!(extensions.len(), 0);
}

#[test]
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this test strikes me as too trivial to do any good

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

just making sure we're not bypassing any of the places we want to look into

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());
}
}
27 changes: 22 additions & 5 deletions crates/goose/src/utils.rs
Original file line number Diff line number Diff line change
@@ -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()
}

Expand Down Expand Up @@ -45,6 +51,17 @@ pub fn is_token_cancelled(cancellation_token: &Option<CancellationToken>) -> 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
Expand Down
52 changes: 52 additions & 0 deletions ui/desktop/openapi.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": [
Expand Down Expand Up @@ -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": [
Expand Down
13 changes: 12 additions & 1 deletion ui/desktop/src/api/sdk.gen.ts
Original file line number Diff line number Diff line change
@@ -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<TData extends TDataShape = TDataShape, ThrowOnError extends boolean = boolean> = ClientOptions<TData, ThrowOnError> & {
Expand Down Expand Up @@ -245,6 +245,17 @@ export const encodeRecipe = <ThrowOnError extends boolean = false>(options: Opti
});
};

export const scanRecipe = <ThrowOnError extends boolean = false>(options: Options<ScanRecipeData, ThrowOnError>) => {
return (options.client ?? _heyApiClient).post<ScanRecipeResponses, unknown, ThrowOnError>({
url: '/recipes/scan',
...options,
headers: {
'Content-Type': 'application/json',
...options.headers
}
});
};

export const createSchedule = <ThrowOnError extends boolean = false>(options: Options<CreateScheduleData, ThrowOnError>) => {
return (options.client ?? _heyApiClient).post<CreateScheduleResponses, CreateScheduleErrors, ThrowOnError>({
url: '/schedule/create',
Expand Down
24 changes: 24 additions & 0 deletions ui/desktop/src/api/types.gen.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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;
Expand Down
2 changes: 2 additions & 0 deletions ui/desktop/src/components/BaseChat.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -200,6 +200,7 @@ function BaseChatContent({
recipeAccepted,
handleRecipeAccept,
handleRecipeCancel,
hasSecurityWarnings,
} = useRecipeManager(messages, location.state);

// Reset recipe usage tracking when recipe changes
Expand Down Expand Up @@ -573,6 +574,7 @@ function BaseChatContent({
description: recipeConfig?.description,
instructions: recipeConfig?.instructions || undefined,
}}
hasSecurityWarnings={hasSecurityWarnings}
/>

{/* Recipe Parameter Modal */}
Expand Down
28 changes: 25 additions & 3 deletions ui/desktop/src/components/ui/RecipeWarningModal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -18,25 +18,47 @@ interface RecipeWarningModalProps {
description?: string;
instructions?: string;
};
hasSecurityWarnings?: boolean;
}

export function RecipeWarningModal({
isOpen,
onConfirm,
onCancel,
recipeDetails,
hasSecurityWarnings = false,
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

why the default?

Copy link
Collaborator Author

@amed-xyz amed-xyz Aug 15, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

assume there's no security warnings until we run the check and find any

}: RecipeWarningModalProps) {
return (
<Dialog open={isOpen} onOpenChange={(open) => !open && onCancel()}>
<DialogContent className="sm:max-w-[80vw] max-h-[80vh] flex flex-col p-0">
<DialogHeader className="flex-shrink-0 p-6 pb-0">
<DialogTitle>⚠️ New Recipe Warning</DialogTitle>
<DialogTitle>
{hasSecurityWarnings ? '⚠️ Security Warning' : '⚠️ New Recipe Warning'}
</DialogTitle>
<DialogDescription>
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.
</DialogDescription>
</DialogHeader>

{hasSecurityWarnings && (
<div className="px-6">
<div className="bg-yellow-50 dark:bg-yellow-900/20 border border-yellow-200 dark:border-yellow-800 rounded-lg p-4">
<div className="flex items-start">
<div className="ml-3">
<div className="mt-2 text-sm text-yellow-700 dark:text-yellow-300">
<p>
This recipe contains hidden characters that will be ignored for your safety,
as they could be used for malicious purposes.
</p>
</div>
</div>
</div>
</div>
</div>
)}

<div className="flex-1 overflow-y-auto p-6 pt-4">
<div className="bg-background-muted p-4 rounded-lg">
<h3 className="font-medium mb-3 text-text-standard">Recipe Preview:</h3>
Expand Down
Loading
Loading