Suppose you give an AI agent a database connection string with write permissions and a tool to execute raw SQL. It will do exactly what you asked. That's the problem.
I've seen teams make this mistake. They treat agent safety as an afterthought, something to add after something breaks. But by then the damage is done. The guardrails I rely on came from studying those failures and building systems that prevent them from happening in the first place.
Read-Only Connections Are Your First Line of Defense
The single most effective safety measure I've implemented costs nothing and takes five minutes. Give your agent a read-only database connection by default. Only promote to write access when a human explicitly approves the operation.
// Database connection factory with role-based permissions
function getDbConnection(operation: 'read' | 'write'): Pool {
if (operation === 'read') {
return new Pool({
connectionString: process.env.DB_READONLY_URL,
// This user has SELECT only on all tables
});
}
return new Pool({
connectionString: process.env.DB_WRITE_URL,
// This user has INSERT, UPDATE, DELETE on specific tables
});
}
// Agent tool definition
const queryDatabase = {
name: 'query_database',
execute: async (sql: string, context: AgentContext) => {
const connection = getDbConnection(
context.hasWriteApproval ? 'write' : 'read'
);
return connection.query(sql);
}
};
This pattern alone prevents an entire category of catastrophic failures. The agent can explore data, analyze patterns, and generate insights without ever touching production records. When it genuinely needs to write, the flow goes through a human approval step.
Consider an agent that parses natural language buyer requirements and matches them against property opportunities in a PostgreSQL database. The agent should never have write access. It should only query and return results. That constraint forces you to design a separate ingestion pipeline for new data, which turns out to be the right architecture anyway.
Human-in-the-Loop Isn't Optional, It's Architecture
The teams that skip human approval are the ones that deal with the consequences. I've learned to treat human approval as a design constraint from day one, not a feature to add later.
My pattern looks like this: every write operation generates a preview. The preview shows exactly what will change, in plain language and in structured data. A human reviews it. If they approve, the change executes. If they reject it, the agent gets feedback and tries a different approach.
// Approval queue for write operations
interface WriteOperation {
id: string;
agentId: string;
operation: 'insert' | 'update' | 'delete';
targetTable: string;
preview: {
affectedRows: number;
beforeState?: Record<string, unknown>;
afterState: Record<string, unknown>;
};
status: 'pending' | 'approved' | 'rejected';
createdAt: Date;
}
async function requestWriteApproval(
operation: WriteOperation
): Promise<boolean> {
// Store in approval queue
await db.insert(operationQueue).values(operation);
// Notify human via Slack/email
await notifyApprovalChannel({
agentId: operation.agentId,
preview: operation.preview,
approvalUrl: `${APP_URL}/approvals/${operation.id}`,
});
// Wait for human response (with timeout)
return pollForApproval(operation.id, TIMEOUT_30_MINUTES);
}
Think about an agent tasked with updating job listing statuses. It finds a bug in a partner feed that marks all listings as "closed." Without human approval, it would archive thousands of active listings in one pass. The human reviewer catches the anomaly in the preview, rejects the operation, and the feed gets fixed instead. That's the value of a review step.
Output Validation Is a Contract, Not a Suggestion
LLMs are probabilistic. They will occasionally output malformed JSON, invent fields, or return data that doesn't match your schema. If your agent passes that output directly to your database or API, you're asking for trouble.
I validate every agent output against a strict schema before it touches any production system. If the output doesn't match, the agent retries or the operation fails safely.
import { z } from 'zod';
// Schema for agent output that will be written to the database
const JobUpdateSchema = z.object({
listingId: z.string().uuid(),
status: z.enum(['active', 'paused', 'closed', 'draft']),
salaryRange: z.object({
min: z.number().positive(),
max: z.number().positive(),
}).refine(data => data.max >= data.min, {
message: 'Max salary must be >= min salary',
}),
// Anti-hallucination guard: must match existing listing IDs
hasValidListingId: z.literal(true),
});
function validateAgentOutput<T>(
output: unknown,
schema: z.ZodSchema<T>
): T {
const result = schema.safeParse(output);
if (!result.success) {
// Log the failure for debugging
logger.error('Agent output validation failed', {
errors: result.error.flatten(),
rawOutput: output,
});
// Don't proceed. Fail safely.
throw new AgentValidationError(
'Agent output did not pass validation',
result.error
);
}
return result.data;
}
The hasValidListingId field is a pattern I use frequently. It forces the LLM to explicitly confirm it found a valid reference before proceeding. If the model hallucinates a listing ID that doesn't exist, this check catches it.
Sandboxed Execution Environments Contain the Damage
Even with read-only connections and output validation, your agent might still need to run code. Maybe it's generating SQL, executing API calls, or running Python scripts for data analysis. Never run that code in your main application process.
I run all agent-generated code in isolated sandboxes. For simple operations, I use Docker containers with no network access and a read-only filesystem. For more complex scenarios, I spin up ephemeral environments that are destroyed after each execution.
// Execute agent-generated code in a sandboxed Docker container
async function executeInSandbox(code: string): Promise<SandboxResult> {
const container = await docker.createContainer({
Image: 'node:20-slim',
Cmd: ['node', '-e', code],
NetworkDisabled: true, // No network access
ReadonlyRootfs: true, // Read-only filesystem
HostConfig: {
Memory: 256 * 1024 * 1024, // 256MB memory limit
CpuPeriod: 100000,
CpuQuota: 50000, // 0.5 CPU limit
},
});
const output = await container.wait();
const logs = await container.logs();
await container.remove();
return {
exitCode: output.StatusCode,
stdout: logs.stdout,
stderr: logs.stderr,
};
}
Suppose an agent is extracting text from PDFs and structuring it. One run produces an infinite loop. In the sandbox, it times out and returns an error. The main application keeps running. The sandbox absorbed the failure, and the only cost was a few seconds of compute.
Log Everything, Especially Failures
You can't fix what you can't see. Every agent interaction should produce structured logs that capture the input, the output, the decisions made, and any errors encountered. This isn't just for debugging. It's for understanding how your agent behaves in production so you can improve its safety over time.
// Structured agent logging
interface AgentLog {
agentId: string;
sessionId: string;
timestamp: Date;
input: {
query: string;
context: Record<string, unknown>;
tools: string[];
};
output: {
response: string;
toolsCalled: Array<{
toolName: string;
args: Record<string, unknown>;
result: unknown;
}>;
};
safety: {
validationPassed: boolean;
sandboxUsed: boolean;
humanApprovalRequired: boolean;
approvalStatus?: 'approved' | 'rejected';
};
errors?: Array<{
type: string;
message: string;
stack?: string;
}>;
}
I send these logs to Sentry for error tracking and to a dedicated analytics database for pattern analysis. When something goes wrong, I can replay the exact sequence of events that led to the failure. When something goes right, I can extract the patterns that made it work.
Reliability Is a Design Choice
The teams that ship reliable AI agents don't get lucky. They make deliberate architectural decisions that constrain what the agent can do, validate what it produces, and contain the damage when something goes wrong.
If your team is wrestling with agent reliability and shipping slower because of it, that's the kind of thing I help with. Happy to compare notes.
Written by Abdul Rehman, full-stack AI engineer building production SaaS, MVPs, and AI automation. More at PrimeStrides.
Top comments (1)
Psst, you can generate CRUD API endpoints wrapped around your database, with RBAC and individual role-based access on individual endpoints using Magic Cloud. Dropping the production database wouldn't even be possible in theory if you did ... ;)
As a side note, you cannot, not even in theory, secure your database if you execute LLM generated SQL in production ...