Treat LLM function calls as proposals—validate, execute, then commit
Treat function calls from a model as proposals, not granted permissions. The verified sequence I recommend: the model proposes a JSON-shaped tool call; your app parses the outer wrapper and validates function name and ar
Treat function calls from a model as proposals, not granted permissions. The verified sequence I recommend: the model proposes a JSON-shaped tool call; your app parses the outer wrapper and validates function name and argument schema; the app executes the registered tool implementation; and only after a verified success does the app commit the change to UI state.
The article’s small in-memory fixture separates responsibilities so you can unit-test the ordering that matters: parseToolCall → validateFunctionCall → runToolBoundaryWorkflow → commit. That split makes it possible to assert distinct failure modes — malformed wrapper, missing required arguments, and tool-level cancellation — rather than collapsing everything into one integration assertion.
Expected behavior
- Malformed or non-JSON proposals are rejected and never reach the tool.
- Valid proposals are parsed; arguments are JSON-decoded and checked against an allowed-keys list and required keys.
- The tool executes with validated arguments and can return a normal result or an explicit cancellation shape (e.g., an object containing cancelled: true and a reason).
- A single commit callback is called only for the successful path; cancelled or invalid proposals do not call commit.
Limitations
- This fixture is intentionally local and deterministic: it does not simulate the Responses API, model selection, or network transport. It verifies the app-side contract (validation → execution → commit) but does not replace server-side authorization checks, durable audit logging, or provider-specific handling.
- Keeping sensitive authorization decisions in the application layer remains essential; the model should not be trusted to decide whether an action is allowed.
If you want a Typescript port or an example wiring this pattern into an Express/Next.js endpoint, I can prepare one. (The server will append the canonical link and the verified code example.)
Working example
export function parseToolCall(raw) {
if (typeof raw !== 'string') {
throw new TypeError('tool call must be a JSON string');
}
let parsed;
try {
parsed = JSON.parse(raw);
} catch {
throw new SyntaxError('tool call is not valid JSON');
}
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
throw new TypeError('tool call must be a JSON object');
}
return parsed;
}
export function validateFunctionCall(call, schema) {
if (!call || typeof call !== 'object' || Array.isArray(call)) {
throw new TypeError('call must be an object');
}
if (!schema || typeof schema !== 'object' || Array.isArray(schema)) {
throw new TypeError('schema must be an object');
}
const { name, arguments: args } = call;
const { name: expectedName, required = [], allowedKeys = [] } = schema;
if (typeof name !== 'string' || name.length === 0) {
return { ok: false, reason: 'missing function name' };
}
if (name !== expectedName) {
return { ok: false, reason: `unexpected function name: ${name}` };
}
if (typeof args !== 'string') {
return { ok: false, reason: 'missing arguments string' };
}
let parsedArgs;
try {
parsedArgs = JSON.parse(args);
} catch {
return { ok: false, reason: 'arguments are not valid JSON' };
}
if (!parsedArgs || typeof parsedArgs !== 'object' || Array.isArray(parsedArgs)) {
return { ok: false, reason: 'arguments must decode to an object' };
}
for (const key of required) {
if (!(key in parsedArgs)) {
return { ok: false, reason: `missing required argument: ${key}` };
}
}
for (const key of Object.keys(parsedArgs)) {
if (!allowedKeys.includes(key)) {
return { ok: false, reason: `unexpected argument: ${key}` };
}
}
return { ok: true, arguments: parsedArgs };
}
export function runToolBoundaryWorkflow({ rawToolCall, schema, tools, commit }) {
if (!tools || typeof tools !== 'object') {
throw new TypeError('tools map must be provided');
}
if (typeof commit !== 'function') {
throw new TypeError('commit must be a function');
}
const call = parseToolCall(rawToolCall);
const validation = validateFunctionCall(call, schema);
if (!validation.ok) {
return { status: 'rejected', reason: validation.reason };
}
const tool = tools[schema.name];
if (typeof tool !== 'function') {
return { status: 'rejected', reason: `no tool registered for ${schema.name}` };
}
const result = tool(validation.arguments);
if (result && typeof result === 'object' && result.cancelled) {
return { status: 'cancelled', reason: result.reason || 'tool cancelled' };
}
const committed = commit({ name: schema.name, input: validation.arguments, output: result });
return { status: 'committed', result: committed };
}
export function createFixtureTool(name, behavior) {
if (typeof behavior !== 'function') {
throw new TypeError('behavior must be a function');
}
return (args) => behavior(args, name);
}
Read the full article: https://chriseugenerodriguez.com/blog/function-calling-boundaries-openai
Originally published by Dev.to Security. Aggregated on AIWithGhost for educational purposes — full credit and traffic to the original publisher.