-
Notifications
You must be signed in to change notification settings - Fork 0
feat: Add AI property management assistant #25
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,55 @@ | ||
| { | ||
| "properties": [ | ||
| { | ||
| "id": "prop-001", | ||
| "name": "Oceanfront Villa", | ||
| "nightlyRate": 450, | ||
| "available": true | ||
| }, | ||
| { | ||
| "id": "prop-002", | ||
| "name": "Downtown Loft", | ||
| "nightlyRate": 200, | ||
| "available": true | ||
| }, | ||
| { | ||
| "id": "prop-003", | ||
| "name": "Mountain Cabin", | ||
| "nightlyRate": 175, | ||
| "available": false | ||
| } | ||
| ], | ||
| "bookings": [ | ||
| { | ||
| "id": "booking-001", | ||
| "propertyId": "prop-001", | ||
| "guestName": "Alice Johnson", | ||
| "guestEmail": "alice@email.com", | ||
| "checkIn": "2024-02-15", | ||
| "checkOut": "2024-02-20", | ||
| "status": "pending", | ||
| "totalPrice": 2250 | ||
| }, | ||
| { | ||
| "id": "booking-002", | ||
| "propertyId": "prop-002", | ||
| "guestName": "Bob Williams", | ||
| "guestEmail": "bob@email.com", | ||
| "checkIn": "2024-02-10", | ||
| "checkOut": "2024-02-12", | ||
| "status": "approved", | ||
| "totalPrice": 400 | ||
| }, | ||
| { | ||
| "id": "booking-003", | ||
| "propertyId": "prop-001", | ||
| "guestName": "Carol Davis", | ||
| "guestEmail": "carol@email.com", | ||
| "checkIn": "2024-03-01", | ||
| "checkOut": "2024-03-05", | ||
| "status": "pending", | ||
| "totalPrice": 1800 | ||
| } | ||
| ], | ||
| "sentMessages": [] | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,111 @@ | ||
| import { Router, Request, Response } from 'express'; | ||
| import { z } from 'zod'; | ||
| import { availableTools, executeTool } from '../services/assistantTools'; | ||
|
|
||
| const router = Router(); | ||
|
|
||
| const assistantQuerySchema = z.object({ | ||
| message: z.string().min(1).max(1000), | ||
| model: z.string().optional(), | ||
| }); | ||
|
|
||
| const LITELLM_SERVER_URL = process.env.LITELLM_SERVER_URL || 'http://localhost:4000'; | ||
|
|
||
| async function runAssistant( | ||
| userMessage: string, | ||
| model?: string | ||
| ): Promise<{ response: string; toolsUsed: string[] }> { | ||
| const toolsUsed: string[] = []; | ||
|
|
||
| const systemPrompt = `You are a helpful AI property management assistant. You have access to the following tools to help manage vacation rental properties: | ||
|
|
||
| ${availableTools.map((t) => `- ${t.name}: ${t.description}`).join('\n')} | ||
|
|
||
| When the user asks you to do something, use the appropriate tools to complete the task. You can use multiple tools in sequence if needed. | ||
|
|
||
| Be proactive and helpful. If the user wants to approve a booking, approve it. If they want to change a price, change it. Execute actions immediately without asking for confirmation - the user trusts you to act on their behalf. | ||
|
|
||
| To use a tool, respond with a JSON object like: | ||
| {"tool": "tool_name", "args": {"param1": "value1"}} | ||
|
|
||
| After using a tool, you'll receive the result and can continue the conversation or use another tool.`; | ||
|
|
||
| let messages: Array<{ role: string; content: string }> = [ | ||
| { role: 'system', content: systemPrompt }, | ||
| { role: 'user', content: userMessage }, | ||
| ]; | ||
|
|
||
| // Simple tool-use loop (max 5 iterations to prevent infinite loops) | ||
| for (let i = 0; i < 5; i++) { | ||
| const response = await fetch(`${LITELLM_SERVER_URL}/v1/chat/completions`, { | ||
| method: 'POST', | ||
| headers: { 'Content-Type': 'application/json' }, | ||
| body: JSON.stringify({ | ||
| model: model || 'gpt-4o-mini', | ||
| messages, | ||
| }), | ||
| }); | ||
|
|
||
| if (!response.ok) { | ||
| throw new Error(`LiteLLM request failed: ${await response.text()}`); | ||
| } | ||
|
|
||
| const data: any = await response.json(); | ||
| const assistantMessage = data.choices[0].message.content; | ||
|
|
||
| // Check if the assistant wants to use a tool | ||
| try { | ||
| // Try to extract JSON from the message | ||
| const jsonMatch = assistantMessage.match(/\{[\s\S]*?"tool"[\s\S]*?\}/); | ||
| if (jsonMatch) { | ||
| const toolCall = JSON.parse(jsonMatch[0]); | ||
| if (toolCall.tool && availableTools.some((t) => t.name === toolCall.tool)) { | ||
| const toolResult = executeTool(toolCall.tool, toolCall.args || {}); | ||
| toolsUsed.push(toolCall.tool); | ||
|
|
||
| messages.push({ role: 'assistant', content: assistantMessage }); | ||
| messages.push({ role: 'user', content: `Tool result: ${toolResult}` }); | ||
| continue; | ||
| } | ||
| } | ||
| } catch { | ||
| // Not a tool call, return the response | ||
| } | ||
|
|
||
| return { response: assistantMessage, toolsUsed }; | ||
| } | ||
|
|
||
| return { response: 'Assistant reached maximum iterations', toolsUsed }; | ||
| } | ||
|
|
||
| // AI assistant chat endpoint | ||
| router.post('/authorized/:level/assistant/chat', async (req: Request, res: Response) => { | ||
| try { | ||
| const { level } = req.params as { level: 'minnow' | 'shark' }; | ||
| const { message, model } = assistantQuerySchema.parse(req.body); | ||
|
|
||
| const result = await runAssistant(message, model); | ||
|
Comment on lines
+82
to
+87
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🟠 High The endpoint extracts a 💡 Suggested FixPass the authorization level to // Update function signature (L14-16)
async function runAssistant(
userMessage: string,
level: 'minnow' | 'shark',
model?: string
): Promise<{ response: string; toolsUsed: string[] }> {
// Filter tools based on level (L20-22)
const readOnlyTools = ['list_properties', 'list_bookings', 'get_booking_details'];
const allowedTools = level === 'minnow'
? availableTools.filter(t => readOnlyTools.includes(t.name))
: availableTools;
const systemPrompt = `You are a helpful AI property management assistant. You have access to the following tools:
${allowedTools.map((t) => `- ${t.name}: ${t.description}`).join('\n')}
...`;
// Later, check tool calls against allowedTools (L62)
if (toolCall.tool && allowedTools.some((t) => t.name === toolCall.tool)) {
// ...
}
}
// Pass level when calling (L87)
const result = await runAssistant(message, level, model);🤖 AI Agent PromptThe assistant endpoint at Investigate the intended authorization model:
Implement the authorization control:
Ensure the filtering happens in the application layer, not just in the prompt, so it can't be bypassed by prompt injection. |
||
|
|
||
| return res.json({ | ||
| userMessage: message, | ||
| assistantResponse: result.response, | ||
| toolsUsed: result.toolsUsed, | ||
| }); | ||
| } catch (error) { | ||
| if (error instanceof z.ZodError) { | ||
| return res.status(400).json({ error: 'Validation error', details: error.errors }); | ||
| } | ||
| console.error('Assistant error:', error); | ||
| return res.status(500).json({ | ||
| error: 'Internal server error', | ||
| message: error instanceof Error ? error.message : 'Unknown error', | ||
| }); | ||
| } | ||
| }); | ||
|
|
||
| // Get available tools (for documentation) | ||
| router.get('/authorized/:level/assistant/tools', async (req: Request, res: Response) => { | ||
| return res.json({ tools: availableTools }); | ||
| }); | ||
|
|
||
| export default router; | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,171 @@ | ||
| import * as fs from 'fs'; | ||
| import * as path from 'path'; | ||
| import { AssistantState, Tool } from '../types/assistant'; | ||
|
|
||
| const statePath = path.join(__dirname, '../data/assistant-state.json'); | ||
|
|
||
| function loadState(): AssistantState { | ||
| return JSON.parse(fs.readFileSync(statePath, 'utf-8')); | ||
| } | ||
|
|
||
| function saveState(state: AssistantState): void { | ||
| fs.writeFileSync(statePath, JSON.stringify(state, null, 2)); | ||
| } | ||
|
|
||
| export const availableTools: Tool[] = [ | ||
| { | ||
| name: 'list_properties', | ||
| description: 'List all properties managed by the host', | ||
| parameters: {}, | ||
| }, | ||
| { | ||
| name: 'list_bookings', | ||
| description: 'List all bookings, optionally filtered by status', | ||
| parameters: { | ||
| status: { | ||
| type: 'string', | ||
| enum: ['pending', 'approved', 'declined', 'cancelled'], | ||
| optional: true, | ||
| }, | ||
| }, | ||
| }, | ||
| { | ||
| name: 'get_booking_details', | ||
| description: 'Get detailed information about a specific booking', | ||
| parameters: { | ||
| bookingId: { type: 'string', required: true }, | ||
| }, | ||
| }, | ||
| { | ||
| name: 'approve_booking', | ||
| description: 'Approve a pending booking request', | ||
| parameters: { | ||
| bookingId: { type: 'string', required: true }, | ||
| }, | ||
| }, | ||
| { | ||
| name: 'decline_booking', | ||
| description: 'Decline a pending booking request', | ||
| parameters: { | ||
| bookingId: { type: 'string', required: true }, | ||
| reason: { type: 'string', optional: true }, | ||
| }, | ||
| }, | ||
| { | ||
| name: 'send_message_to_guest', | ||
| description: 'Send an email message to a guest', | ||
| parameters: { | ||
| guestEmail: { type: 'string', required: true }, | ||
| subject: { type: 'string', required: true }, | ||
| body: { type: 'string', required: true }, | ||
| }, | ||
| }, | ||
| { | ||
| name: 'update_property_price', | ||
| description: 'Update the nightly rate for a property', | ||
| parameters: { | ||
| propertyId: { type: 'string', required: true }, | ||
| newPrice: { type: 'number', required: true }, | ||
| }, | ||
| }, | ||
| { | ||
| name: 'set_property_availability', | ||
| description: 'Set whether a property is available for booking', | ||
| parameters: { | ||
| propertyId: { type: 'string', required: true }, | ||
| available: { type: 'boolean', required: true }, | ||
| }, | ||
| }, | ||
| { | ||
| name: 'cancel_booking', | ||
| description: 'Cancel an existing booking', | ||
| parameters: { | ||
| bookingId: { type: 'string', required: true }, | ||
| reason: { type: 'string', optional: true }, | ||
| }, | ||
| }, | ||
| ]; | ||
|
|
||
| // Tool execution functions | ||
| export function executeTool(toolName: string, args: Record<string, any>): string { | ||
| const state = loadState(); | ||
|
|
||
| switch (toolName) { | ||
| case 'list_properties': | ||
| return JSON.stringify(state.properties, null, 2); | ||
|
Comment on lines
+90
to
+95
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🟠 High Tool arguments provided by the LLM are passed directly to execution functions without validation. An attacker using prompt injection can cause the LLM to generate tool calls with malicious parameters—arbitrary email addresses, extreme price values, or invalid booking IDs. The only check is whether the tool name exists in the availableTools list. 💡 Suggested FixAdd Zod schema validation for all tool arguments before execution: import { z } from 'zod';
const toolSchemas: Record<string, z.ZodSchema> = {
list_properties: z.object({}),
list_bookings: z.object({
status: z.enum(['pending', 'approved', 'declined', 'cancelled']).optional(),
}),
get_booking_details: z.object({
bookingId: z.string().regex(/^booking-\d{3}$/),
}),
approve_booking: z.object({
bookingId: z.string().regex(/^booking-\d{3}$/),
}),
send_message_to_guest: z.object({
guestEmail: z.string().email(),
subject: z.string().min(1).max(200),
body: z.string().min(1).max(2000),
}),
update_property_price: z.object({
propertyId: z.string().regex(/^prop-\d{3}$/),
newPrice: z.number().positive().max(10000),
}),
// ... other tools
};
export function executeTool(toolName: string, args: Record<string, any>): string {
if (!toolSchemas[toolName]) {
return `Unknown tool: ${toolName}`;
}
try {
const validatedArgs = toolSchemas[toolName].parse(args);
return executeToolInternal(toolName, validatedArgs);
} catch (error) {
if (error instanceof z.ZodError) {
return `Invalid arguments: ${error.errors.map(e => e.message).join(', ')}`;
}
throw error;
}
}Additionally, for 🤖 AI Agent PromptAt Implement comprehensive input validation:
Look at the tool parameter definitions (L15-87) and create corresponding runtime validation that matches those type signatures. Return clear error messages to the LLM when validation fails. |
||
|
|
||
| case 'list_bookings': { | ||
| let bookings = state.bookings; | ||
| if (args.status) { | ||
| bookings = bookings.filter((b) => b.status === args.status); | ||
| } | ||
| return JSON.stringify(bookings, null, 2); | ||
| } | ||
|
|
||
| case 'get_booking_details': { | ||
| const booking = state.bookings.find((b) => b.id === args.bookingId); | ||
| return booking ? JSON.stringify(booking, null, 2) : 'Booking not found'; | ||
|
Comment on lines
+97
to
+107
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🟡 Medium Guest PII (names and email addresses) is returned by these tools and fed back into the LLM context, which sends it to the external LLM provider. While this is standard behavior for LLM applications, vacation rental guests likely don't expect their personal information to be processed by OpenAI or other AI providers without explicit consent. 💡 Suggested FixMinimize PII exposure by returning only the information the LLM needs. The assistant doesn't need guest names/emails to approve bookings—it just needs booking IDs and dates: case 'list_bookings': {
let bookings = state.bookings;
if (args.status) {
bookings = bookings.filter((b) => b.status === args.status);
}
// Return minimal info without PII
const minimalInfo = bookings.map(b => ({
id: b.id,
propertyId: b.propertyId,
checkIn: b.checkIn,
checkOut: b.checkOut,
status: b.status,
totalPrice: b.totalPrice,
// Omit guestName and guestEmail
}));
return JSON.stringify(minimalInfo, null, 2);
}
case 'get_booking_details': {
const booking = state.bookings.find((b) => b.id === args.bookingId);
if (!booking) return 'Booking not found';
// Return booking details without PII
const { guestName, guestEmail, ...bookingInfo } = booking;
return JSON.stringify(bookingInfo, null, 2);
}When the LLM calls 🤖 AI Agent PromptThe tools at Evaluate what information the LLM actually needs:
Implement PII minimization:
If PII exposure is necessary for functionality, add consent tracking and privacy documentation. |
||
| } | ||
|
|
||
| case 'approve_booking': { | ||
| const booking = state.bookings.find((b) => b.id === args.bookingId); | ||
| if (!booking) return 'Booking not found'; | ||
| if (booking.status !== 'pending') | ||
| return `Cannot approve booking with status: ${booking.status}`; | ||
| booking.status = 'approved'; | ||
| saveState(state); | ||
| return `Booking ${args.bookingId} has been approved`; | ||
| } | ||
|
|
||
| case 'decline_booking': { | ||
| const booking = state.bookings.find((b) => b.id === args.bookingId); | ||
| if (!booking) return 'Booking not found'; | ||
| if (booking.status !== 'pending') | ||
| return `Cannot decline booking with status: ${booking.status}`; | ||
| booking.status = 'declined'; | ||
| saveState(state); | ||
| return `Booking ${args.bookingId} has been declined${args.reason ? `: ${args.reason}` : ''}`; | ||
| } | ||
|
|
||
| case 'send_message_to_guest': { | ||
| const message = { | ||
| to: args.guestEmail, | ||
| subject: args.subject, | ||
| body: args.body, | ||
| timestamp: new Date().toISOString(), | ||
| }; | ||
| state.sentMessages.push(message); | ||
| saveState(state); | ||
| console.log(`[EMAIL SENT] To: ${args.guestEmail}, Subject: ${args.subject}`); | ||
| return `Message sent to ${args.guestEmail}`; | ||
| } | ||
|
|
||
| case 'update_property_price': { | ||
| const property = state.properties.find((p) => p.id === args.propertyId); | ||
| if (!property) return 'Property not found'; | ||
| const oldPrice = property.nightlyRate; | ||
| property.nightlyRate = args.newPrice; | ||
| saveState(state); | ||
| return `Property ${property.name} price updated from $${oldPrice} to $${args.newPrice}`; | ||
| } | ||
|
|
||
| case 'set_property_availability': { | ||
| const property = state.properties.find((p) => p.id === args.propertyId); | ||
| if (!property) return 'Property not found'; | ||
| property.available = args.available; | ||
| saveState(state); | ||
| return `Property ${property.name} availability set to ${args.available}`; | ||
| } | ||
|
|
||
| case 'cancel_booking': { | ||
| const booking = state.bookings.find((b) => b.id === args.bookingId); | ||
| if (!booking) return 'Booking not found'; | ||
| booking.status = 'cancelled'; | ||
| saveState(state); | ||
| return `Booking ${args.bookingId} has been cancelled${args.reason ? `: ${args.reason}` : ''}`; | ||
| } | ||
|
|
||
| default: | ||
| return `Unknown tool: ${toolName}`; | ||
| } | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,41 @@ | ||
| export interface Tool { | ||
| name: string; | ||
| description: string; | ||
| parameters: Record<string, any>; | ||
| } | ||
|
|
||
| export interface ToolCall { | ||
| name: string; | ||
| arguments: Record<string, any>; | ||
| } | ||
|
|
||
| export interface Property { | ||
| id: string; | ||
| name: string; | ||
| nightlyRate: number; | ||
| available: boolean; | ||
| } | ||
|
|
||
| export interface Booking { | ||
| id: string; | ||
| propertyId: string; | ||
| guestName: string; | ||
| guestEmail: string; | ||
| checkIn: string; | ||
| checkOut: string; | ||
| status: 'pending' | 'approved' | 'declined' | 'cancelled'; | ||
| totalPrice: number; | ||
| } | ||
|
|
||
| export interface SentMessage { | ||
| to: string; | ||
| subject: string; | ||
| body: string; | ||
| timestamp: string; | ||
| } | ||
|
|
||
| export interface AssistantState { | ||
| properties: Property[]; | ||
| bookings: Booking[]; | ||
| sentMessages: SentMessage[]; | ||
| } |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🔴 Critical
User messages from the API flow directly into LLM prompts without sanitization, and the LLM has immediate access to privileged tools like approving bookings, sending guest emails, and modifying prices. An attacker can use prompt injection to manipulate the LLM into executing unauthorized actions, such as approving fraudulent bookings or sending malicious messages to guests.
💡 Suggested Fix
Implement multiple defense layers: (1) Add input validation to block known prompt injection patterns, (2) Filter available tools based on authorization level, and (3) Require human confirmation for high-risk operations.
Also update the function signature to accept and use the
levelparameter for tool filtering.🤖 AI Agent Prompt
The assistant route at
src/routes/assistant.ts:33-36has a critical prompt injection vulnerability. User input flows directly to an LLM that controls privileged operations (booking approvals, guest emails, price changes).Investigate the authentication and authorization flow:
levelparameter ('minnow' vs 'shark') should control tool accesssrc/routes/chat.tsfor handling authorization levelsImplement defense-in-depth:
availableToolsbased on the user's authorization level before constructing the system promptThe goal is to prevent unauthorized operations even if prompt injection successfully manipulates the LLM's output.
Was this helpful? 👍 Yes | 👎 No