Dev.to Security 🔐 Cybersecurity 👁 0 📖 3 min read

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

📰 Read the original article on Dev.to Security

Originally published by Dev.to Security. Aggregated on AIWithGhost for educational purposes — full credit and traffic to the original publisher.