Skip to content

Tool Validation

Control tool execution with validation callbacks for security and user confirmation.

Overview

Tool validation allows you to:

  • Approve or deny tool execution
  • Require user confirmation
  • Enforce security policies
  • Audit tool usage
  • Abort on dangerous operations

Basic Usage

Provide a validation callback to complete() or generate():

typescript
import { igniteModel, Message } from 'multi-llm-ts'

const validateToolExecution = async (context, tool, args) => {
  // Your validation logic
  return { decision: 'allow' }
}

const response = await model.complete(messages, {
  toolExecutionValidation: validateToolExecution
})

Validation Callback

The callback receives:

typescript
async function validateToolExecution(
  context: PluginExecutionContext,
  tool: string,
  args: any
): Promise<ValidationResponse>

Parameters:

  • context: Execution context (modelId, abortSignal)
  • tool: Tool name being invoked
  • args: Arguments passed to the tool

Returns:

typescript
interface ValidationResponse {
  decision: 'allow' | 'deny' | 'abort'
  extra?: any  // Additional data
}

Validation Decisions

Allow

Execute the tool normally:

typescript
async function validate(context, tool, args) {
  return { decision: 'allow' }
}

Deny

Skip tool execution, continue generation:

typescript
async function validate(context, tool, args) {
  if (tool === 'delete_file') {
    return {
      decision: 'deny',
      extra: { reason: 'File deletion not allowed' }
    }
  }

  return { decision: 'allow' }
}

Behavior:

  • Streaming: Emits LlmChunkTool with state: 'canceled', continues stream
  • Non-streaming: Throws error, stops recursion

Abort

Stop the entire generation:

typescript
async function validate(context, tool, args) {
  if (args.query?.includes('forbidden')) {
    return {
      decision: 'abort',
      extra: { reason: 'Forbidden query detected' }
    }
  }

  return { decision: 'allow' }
}

Behavior:

  • Streaming: Emits LlmChunkToolAbort, stops stream immediately
  • Non-streaming: Throws LlmChunkToolAbort, stops recursion

Decision Matrix

DecisionStreamingNon-Streaming
allowExecute tool, continueExecute tool, continue
denySkip tool, emit canceled chunk, continueSkip tool, throw error, stop
abortSkip tool, emit abort chunk, stopSkip tool, throw abort, stop

Use Cases

Security Policy

typescript
async function validateToolExecution(context, tool, args) {
  // Block dangerous operations
  const dangerousTools = ['delete_file', 'execute_command', 'modify_system']

  if (dangerousTools.includes(tool)) {
    return {
      decision: 'deny',
      extra: { reason: `Tool ${tool} is not allowed` }
    }
  }

  // Check path safety
  if (tool === 'read_file' && args.path?.includes('..')) {
    return {
      decision: 'deny',
      extra: { reason: 'Invalid path' }
    }
  }

  return { decision: 'allow' }
}

User Confirmation

typescript
async function validateToolExecution(context, tool, args) {
  // Require confirmation for sensitive operations
  const sensitiveTools = ['send_email', 'make_purchase', 'delete_data']

  if (sensitiveTools.includes(tool)) {
    const confirmed = await askUserConfirmation(
      `Allow ${tool} with ${JSON.stringify(args)}?`
    )

    return {
      decision: confirmed ? 'allow' : 'deny',
      extra: { confirmed }
    }
  }

  return { decision: 'allow' }
}

Rate Limiting

typescript
const toolCounts = new Map<string, number>()

async function validateToolExecution(context, tool, args) {
  const count = toolCounts.get(tool) || 0

  if (count >= 5) {
    return {
      decision: 'deny',
      extra: { reason: 'Rate limit exceeded' }
    }
  }

  toolCounts.set(tool, count + 1)
  return { decision: 'allow' }
}

Content Filtering

typescript
async function validateToolExecution(context, tool, args) {
  // Check for forbidden content
  const argsStr = JSON.stringify(args).toLowerCase()

  const forbiddenTerms = ['hack', 'exploit', 'bypass']

  for (const term of forbiddenTerms) {
    if (argsStr.includes(term)) {
      return {
        decision: 'abort',
        extra: { reason: `Forbidden term: ${term}` }
      }
    }
  }

  return { decision: 'allow' }
}

Audit Logging

typescript
async function validateToolExecution(context, tool, args) {
  // Log all tool usage
  await auditLog({
    timestamp: new Date(),
    modelId: context.modelId,
    tool,
    args
  })

  return { decision: 'allow' }
}

Handling Responses

With Streaming

typescript
const stream = model.generate(messages, {
  toolExecutionValidation: validate
})

for await (const chunk of stream) {
  if (chunk.type === 'tool' && chunk.state === 'canceled') {
    // Tool was denied
    console.log('Tool denied:', chunk.name)
    console.log('Status:', chunk.status)
  } else if (chunk.type === 'tool_abort') {
    // Generation aborted
    console.log('Aborted:', chunk.reason)
    break  // Stream stops
  } else if (chunk.type === 'content') {
    console.log('Text:', chunk.text)
  }
}

With Completion

typescript
try {
  const response = await model.complete(messages, {
    toolExecutionValidation: validate
  })

  console.log(response.content)
} catch (error) {
  if (error.type === 'tool_abort') {
    console.log('Aborted:', error.reason)
  } else {
    console.error('Error:', error)
  }
}

Chunk Types

LlmChunkTool (Canceled)

When denied:

typescript
{
  type: 'tool',
  name: 'tool_name',
  state: 'canceled',
  status: 'Tool was denied: [reason]'
}

LlmChunkToolAbort

When aborted:

typescript
{
  type: 'tool_abort',
  reason: { /* extra data from validation */ }
}

Advanced Examples

Role-Based Access

typescript
const userRole = 'viewer'  // from auth system

async function validateToolExecution(context, tool, args) {
  const permissions = {
    admin: ['*'],
    editor: ['read_file', 'write_file', 'search'],
    viewer: ['read_file', 'search']
  }

  const allowed = permissions[userRole] || []

  if (!allowed.includes('*') && !allowed.includes(tool)) {
    return {
      decision: 'deny',
      extra: { reason: `${userRole} cannot use ${tool}` }
    }
  }

  return { decision: 'allow' }
}

Context-Aware Validation

typescript
async function validateToolExecution(context, tool, args) {
  // Check model-specific rules
  if (context.modelId.includes('gpt-4') && tool === 'execute_code') {
    // Allow code execution for powerful models only
    return { decision: 'allow' }
  }

  // Deny for other models
  if (tool === 'execute_code') {
    return {
      decision: 'deny',
      extra: { reason: 'Code execution requires GPT-4' }
    }
  }

  return { decision: 'allow' }
}

Async Validation

typescript
async function validateToolExecution(context, tool, args) {
  // Check external API
  const isAllowed = await checkPermissionAPI({
    tool,
    args,
    userId: getCurrentUser()
  })

  if (!isAllowed) {
    return {
      decision: 'deny',
      extra: { reason: 'Permission denied by API' }
    }
  }

  return { decision: 'allow' }
}

Best Practices

  1. Fast validation: Keep validation quick to avoid delays
  2. Clear reasons: Provide descriptive reasons for denials
  3. Whitelist approach: Default to deny, explicitly allow safe tools
  4. Log decisions: Audit all validation decisions
  5. User feedback: Show validation status to users
  6. Test edge cases: Test deny/abort scenarios
  7. Handle async: Support async validation operations

Security Considerations

Input Validation

typescript
async function validateToolExecution(context, tool, args) {
  // Validate argument types
  if (tool === 'read_file') {
    if (typeof args.path !== 'string') {
      return { decision: 'deny', extra: { reason: 'Invalid path type' } }
    }

    // Check path safety
    if (args.path.includes('..') || args.path.startsWith('/etc')) {
      return { decision: 'deny', extra: { reason: 'Unsafe path' } }
    }
  }

  return { decision: 'allow' }
}

Resource Limits

typescript
const activeTools = new Set<string>()

async function validateToolExecution(context, tool, args) {
  // Limit concurrent tool executions
  if (activeTools.size >= 3) {
    return {
      decision: 'deny',
      extra: { reason: 'Too many concurrent tools' }
    }
  }

  activeTools.add(tool)

  // Clean up after execution
  setTimeout(() => activeTools.delete(tool), 30000)

  return { decision: 'allow' }
}

Next Steps

Released under the MIT License.