Skip to content

Security ​

This guide covers security best practices for deploying Helix Agents in production.

Error Response Sanitization ​

As of v0.10, error responses from AgentServer are sanitized to prevent information leakage. This protects against exposing:

  • Internal implementation details
  • Zod validation schema structure
  • Stack traces and debug information
  • Credentials or API keys in error messages
  • Database query details

Server-Side Behavior ​

Error responses now contain only:

  • HTTP status code (4xx for client errors, 500 for server errors)
  • Generic error message
typescript
// What clients receive for validation errors
{ "error": "Invalid request body" }

// What clients receive for server errors
{ "error": "Internal server error" }

Full error details are logged server-side for debugging:

typescript
// Server logs include full details
this.logger.error('Validation failed', {
  runId,
  error: error.message,
  issues: zodError.issues, // Only in server logs
  stack: error.stack, // Only in server logs
});

Client-Side Error Handling ​

Use HTTP status codes for error categorization:

typescript
try {
  const response = await fetch('/chat/session-123', {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({ message: 'Hello' }),
  });

  if (!response.ok) {
    switch (response.status) {
      case 400:
        // Client error: Invalid request, validation failed
        showUserError('Invalid input. Please check your request.');
        break;
      case 409:
        // Conflict: Already executing
        showUserError('An agent is already running.');
        break;
      case 500:
        // Server error: Log and show generic message
        console.error('Server error');
        showUserError('Something went wrong. Please try again.');
        break;
    }
  }
} catch (error) {
  // Network error
  showUserError('Connection failed. Please try again.');
}

Input Validation ​

Request Body Validation ​

The AgentServer validates all request bodies using Zod schemas. Invalid requests receive a 400 status code with a generic error message.

typescript
// Server automatically validates:
// - /start endpoint: StartAgentRequestSchema
// - /resume endpoint: ResumeAgentRequestSchema
// - /interrupt endpoint: InterruptRequestSchema
// - /abort endpoint: AbortRequestSchema

// Validation errors return:
// Status: 400
// Body: { "error": "Invalid request body" }

Query Parameter Validation ​

Query parameters for pagination are validated:

typescript
// /messages?offset=X&limit=Y
// - offset: must be non-negative integer, defaults to 0
// - limit: must be positive integer, max 1000, defaults to 50

// Invalid parameters return:
// Status: 400
// Body: { "error": "Invalid offset parameter" }

Agent Configuration Security ​

Sensitive Data in State ​

Avoid storing sensitive data in agent state:

typescript
// Don't do this
const agent = defineAgent({
  name: 'my-agent',
  stateSchema: z.object({
    apiKey: z.string(), // Sensitive data in state
  }),
});

// Do this instead
const agent = defineAgent({
  name: 'my-agent',
  stateSchema: z.object({
    // Non-sensitive state only
    results: z.array(z.string()),
  }),
});

// Pass sensitive data via environment
const searchTool = defineTool({
  name: 'search',
  execute: async (params, context) => {
    // Access API key from environment, not state
    const apiKey = process.env.SEARCH_API_KEY;
    // ...
  },
});

System Prompt Injection ​

Be cautious with user input in system prompts:

typescript
// Potentially dangerous
const agent = defineAgent({
  systemPrompt: `You are helping ${userInput}`, // User input in prompt
});

// Safer approach
const agent = defineAgent({
  systemPrompt: 'You are a helpful assistant.',
  // Pass user context via input, not system prompt
});

Tool Security ​

Validate Tool Inputs ​

Always validate tool inputs, even with Zod schemas:

typescript
const fileTool = defineTool({
  name: 'read_file',
  inputSchema: z.object({
    path: z.string().refine(
      (path) => !path.includes('..'), // Prevent path traversal
      'Path cannot contain ..'
    ),
  }),
  execute: async ({ path }) => {
    // Additional runtime validation
    const resolvedPath = resolve(baseDir, path);
    if (!resolvedPath.startsWith(baseDir)) {
      throw new Error('Access denied');
    }
    return readFile(resolvedPath);
  },
});

Limit Tool Capabilities ​

Use requireApproval for dangerous operations. v7 supports two forms:

Static-true form — every invocation pauses for approval:

typescript
const deleteTool = defineTool({
  name: 'delete_file',
  inputSchema: z.object({ path: z.string() }),
  requireApproval: true, // Pauses for user approval before every call
  execute: async ({ path }) => {
    // Only executes after the user approves via submitToolResult
    await deleteFile(path);
  },
});

Function form — approval gated on runtime args (fail-closed if it throws):

typescript
const sendEmailTool = defineTool({
  name: 'send_email',
  inputSchema: z.object({ to: z.array(z.string()), body: z.string() }),
  requireApproval: ({ to }) => to.length > 50, // Only approve large blasts
  execute: async ({ to, body }) => {
    await sendBulkEmail(to, body);
  },
});

When a requireApproval-gated tool fires, the runtime emits a tool_approval_request stream chunk and suspends the run. The frontend submits the user's decision via submitToolResult({ kind: 'approval-response', approvalId, approved }), which resumes the agent. The answer is bound to the pending call: an approval accepts its approval-response with its own approvalId, or an error-only answer (a client-tool-result with error and no result, drained as not executed, never as the tool's success). A client-tool-result carrying a result for an approval-gated server tool is refused (400 validation_error), never recorded as the tool's output. A denial synthesizes the canonical message "Tool call was not approved by the user" as the tool result so the LLM has a stable anchor for the rejection.

requireApproval cannot be combined with execute: 'client' or finishWith: true — the framework rejects those at defineTool() time.

Streaming Security ​

SSE/WebSocket Authentication ​

Implement authentication for streaming endpoints:

typescript
// Worker entry point
export default {
  async fetch(request: Request, env: Env): Promise<Response> {
    // Verify authentication before forwarding to DO
    const token = request.headers.get('Authorization');
    if (!verifyToken(token, env.JWT_SECRET)) {
      return new Response('Unauthorized', { status: 401 });
    }

    // Forward authenticated request to DO
    const stub = env.AGENTS.get(doId);
    return stub.fetch(request);
  },
};

Rate Limiting ​

Implement rate limiting at the worker level:

typescript
export default {
  async fetch(request: Request, env: Env): Promise<Response> {
    const clientIP = request.headers.get('CF-Connecting-IP');

    // Check rate limit
    const allowed = await checkRateLimit(env.RATE_LIMITER, clientIP);
    if (!allowed) {
      return new Response('Too many requests', { status: 429 });
    }

    // Process request
    // ...
  },
};

Logging Best Practices ​

What to Log ​

typescript
// Good: Log operation metadata
logger.info('Agent started', {
  runId,
  agentType: agent.name,
  userId,
  sessionId,
});

// Good: Log errors with context
logger.error('Tool execution failed', {
  runId,
  toolName,
  error: error.message,
  stack: error.stack,
});

What NOT to Log ​

typescript
// Bad: Logging sensitive data
logger.info('Request', {
  body: request.body, // May contain credentials
});

// Bad: Logging full state
logger.info('State', {
  state: agentState, // May contain PII
});

// Bad: Logging API responses
logger.info('LLM response', {
  response: llmResponse, // May contain sensitive content
});

Environment Variables ​

Required Secrets ​

Store all sensitive configuration in environment variables:

bash
# Cloudflare Workers
wrangler secret put OPENAI_API_KEY
wrangler secret put ANTHROPIC_API_KEY
wrangler secret put JWT_SECRET

# Node.js
export OPENAI_API_KEY=sk-...
export REDIS_URL=redis://...

Access Pattern ​

typescript
// Cloudflare Workers - API keys accessed via env, never hardcoded
interface Env {
  OPENAI_API_KEY: string;
  // Never expose in responses
}

export const MyAgentServer = createAgentServer<Env>({
  llmAdapter: ({ env }) =>
    new VercelAIAdapter({
      model: openai('gpt-6-luna', { apiKey: env.OPENAI_API_KEY }),
    }),
  agents: registry,
});

Production Checklist ​

Before deploying to production:

  • [ ] Error responses are sanitized (default in v0.10+)
  • [ ] Authentication implemented for all endpoints
  • [ ] Rate limiting configured
  • [ ] Sensitive data not stored in agent state
  • [ ] Tool inputs validated and sanitized
  • [ ] Dangerous tools require confirmation
  • [ ] API keys stored in environment variables
  • [ ] Logging excludes sensitive data
  • [ ] HTTPS enforced for all connections
  • [ ] CORS configured appropriately

Next Steps ​

Released under the MIT License.