Function Calling Security
On this page
Never Execute Blindly
Function calling gives the model the ability to request function execution within your system. This is powerful, but it also creates a potential attack vector. A malicious user cannot execute your code directly; however, they can write anything in the chat field, thereby influencing the arguments the model produces. You need to overturn a comfortable assumption: tool_call arguments are not internal data from your application. They are user data that has passed through a language model. Your code must be the last line of defense.
Validate Before Executing
The model generates JSON arguments where it guarantees grammatical form, never business legitimacy. A transaction_id might be syntactically a string yet contain anything. The discipline, therefore, is to validate every argument before doing anything with it, and to structure the function in three distinct steps: verify the format, verify existence, and only then execute.
import re
def validate_transaction_id(transaction_id: str) -> bool:
"""Validates the format of a transaction identifier."""
# Expected format: T followed by 4 digits
return bool(re.match(r"^T\d{4}$", transaction_id))
def retrieve_payment_status_safe(df, transaction_id: str) -> str:
"""Secure version with validation."""
# Step 1: Validate format
if not validate_transaction_id(transaction_id):
return json.dumps({
"error": "invalid_format",
"message": "ID must be in Txxxx format (e.g., T1001)"
})
# Step 2: Check existence
if transaction_id not in df["transaction_id"].values:
return json.dumps({
"error": "not_found",
"message": f"Transaction {transaction_id} not found"
})
# Step 3: Execute
status = df[df["transaction_id"] == transaction_id]["payment_status"].values[0]
return json.dumps({"status": status})
The regular expression ^T\d{4}$ does all the defensive work here: anchored on both sides, it rejects any string containing even a single extra character. This leads directly to the reason for this anchoring.
When the Argument Becomes a Payload
Consider this request, which appears perfectly innocent:
User: "Find the payment with ID: T1001; DROP TABLE payments"
The model, conscientiously doing its extraction job, might generate {"transaction_id": "T1001; DROP TABLE payments"}. It did nothing wrong: it was given an identifier, and it passed it along. If your function inserts this argument into an SQL query via concatenation, you have just lost your table. The problem isn’t new—it’s classic SQL injection—but function calling adds a link that creates a false impression of filtering.
The following three versions show the progression: what you must never write, the bare minimum acceptable, and the practice to adopt.
# NEVER do this — direct SQL injection
def bad_query(transaction_id):
cursor.execute(f"SELECT * FROM payments WHERE id = ' {transaction_id}'")
# ALWAYS do this — parameterized queries
def safe_query(transaction_id):
cursor.execute("SELECT * FROM payments WHERE id = %s", (transaction_id,))
# EVEN BETTER — validation + parameterized query
def best_query(transaction_id):
if not validate_transaction_id(transaction_id):
raise ValueError("Invalid ID format")
cursor.execute("SELECT * FROM payments WHERE id = %s", (transaction_id,))
The parameterized query is sufficient to neutralize injection, since the driver treats the argument as a value, not as SQL. Upstream validation adds a second barrier and, incidentally, provides a much clearer error message than the database would.
Restrict the Field of Possibilities
The second line of defense is to reduce what the model can request. An explicit allowlist, separate from your dispatch dictionary, states in black and white which functions are authorized in this context. The separation matters: ALLOWED_FUNCTIONS expresses a security decision, while available_functions reflects an implementation state. Confusing the two amounts to authorizing a function simply because you coded it.
# Explicitly define allowed functions
ALLOWED_FUNCTIONS = {
"retrieve_payment_status",
"retrieve_payment_date",
"search_transactions"
}
def execute_tool_call_safe(tool_call, available_functions):
"""Execution with allowlist verification."""
function_name = tool_call.function.name
# Check allowlist
if function_name not in ALLOWED_FUNCTIONS:
return json.dumps({
"error": "forbidden",
"message": f"Function {function_name} is not authorized."
})
if function_name not in available_functions:
return json.dumps({
"error": "not_implemented",
"message": f"Function {function_name} is not implemented."
})
# Execute
function_params = json.loads(tool_call.function.arguments)
return available_functions[function_name](**function_params)
Not All Functions Carry the Same Weight
Reading a payment status and purging old records obviously do not carry the same risk. Classifying your functions by level—read-only, write, destructive—allows you to grant each session or user the appropriate scope. A public order-tracking assistant will run in read mode; a back-office tool will authorize writes. As for destructive actions, the right answer is not to forbid them but to escalate them: the function returns confirmation_required, the model explains to the user what is about to happen, and a human makes the final call.
# Risk levels
READ_ONLY = {"retrieve_payment_status", "retrieve_payment_date", "search_transactions"}
WRITE = {"update_payment_status", "create_refund"}
DESTRUCTIVE = {"delete_transaction", "purge_old_records"}
def execute_with_permissions(tool_call, available_functions, permission_level="read"):
"""Executes with permission level verification."""
function_name = tool_call.function.name
if permission_level == "read" and function_name not in READ_ONLY:
return json.dumps({
"error": "permission_denied",
"message": f"Only read functions are authorized. "
f"{function_name} requires 'write' level."
})
if function_name in DESTRUCTIVE:
return json.dumps({
"error": "confirmation_required",
"message": f"Action {function_name} is destructive and "
f"requires manual confirmation."
})
function_params = json.loads(tool_call.function.arguments)
return available_functions[function_name](**function_params)
Contain Abuse and Runaways
A poorly managed conversation loop can call the same function dozens of times in a few seconds, whether due to malice or a simple chaining bug. A sliding counter over sixty seconds per function costs a few lines of code and prevents saturating a third-party API billed per call.
from collections import defaultdict
import time
class FunctionRateLimiter:
def __init__(self, max_calls_per_minute=10):
self.max_calls = max_calls_per_minute
self.call_history = defaultdict(list)
def can_call(self, function_name: str) -> bool:
now = time.time()
# Clean up calls older than one minute
self.call_history[function_name] = [
t for t in self.call_history[function_name]
if now - t < 60
]
return len(self.call_history[function_name]) < self.max_calls
def record_call(self, function_name: str):
self.call_history[function_name].append(time.time())
rate_limiter = FunctionRateLimiter(max_calls_per_minute=10)
What Returns to the Model
The last blind spot is often the most costly. A function that returns a complete database row might include a password hash, an API key, or a card number. Yet everything you place in a tool message enters the model’s context, meaning it could potentially appear in its on-screen response, in your logs, and at your provider’s end. Masking sensitive fields before sending—recursively, to cover nested objects—is the precaution that will save you from a data leak incident.
def sanitize_result(result: dict) -> dict:
"""Removes sensitive fields before returning to the model."""
sensitive_keys = {"password", "api_key", "secret", "token", "ssn", "credit_card"}
sanitized = {}
for key, value in result.items():
if key.lower() in sensitive_keys:
sanitized[key] = "[MASKED]"
elif isinstance(value, dict):
sanitized[key] = sanitize_result(value)
else:
sanitized[key] = value
return sanitized
Key Takeaways
- Always validate arguments before executing a function—format, type, allowed values
- Use parameterized queries to prevent SQL injection
- Implement an allowlist of authorized functions
- Classify functions by risk level (read, write, destructive)
- Add rate limiting to prevent abuse
- Sanitize results to avoid exposing sensitive data to the model