object({
risk_level: z.enum(['low', 'medium', 'high']),
allowed_groups: z.array(z.string()),
requires_approval: z.boolean(),
approval_groups: z.array(z.string()).optional(),
write_access: z.boolean(),
context_restrictions: z.object({
max_tokens: z.number().optional(),
sensitive_fields: z.array(z.string()).optional(),
}).optional(),
})),
});
export type PolicyConfig = z.infer<typeof PolicySchema>;
// Runtime Models
export interface OperatorIdentity {
userId: string;
groups: string[];
deviceCompliant: boolean;
sessionId: string;
}
export interface ToolRequest {
toolName: string;
parameters: Record<string, unknown>;
operator: OperatorIdentity;
}
export interface Verdict {
allowed: boolean;
requiresApproval: boolean;
reason: string;
traceId: string;
}
**2. Implement the Policy Evaluator**
The evaluator is the heart of the control plane. It performs deterministic checks against identity, device posture, and policy rules.
```typescript
import { randomUUID } from 'crypto';
export class PolicyEvaluator {
private policy: PolicyConfig;
constructor(policyConfig: PolicyConfig) {
// Validate policy structure at load time
this.policy = PolicySchema.parse(policyConfig);
}
async evaluate(request: ToolRequest): Promise<Verdict> {
const traceId = randomUUID();
const { toolName, operator } = request;
// 1. Kill Switch Checks
if (this.policy.kill_switch.disabled_users.includes(operator.userId)) {
return this.verdict(false, traceId, 'User disabled by kill switch');
}
if (this.policy.kill_switch.disabled_tools.includes(toolName)) {
return this.verdict(false, traceId, 'Tool disabled by kill switch');
}
if (this.policy.kill_switch.global_read_only) {
const toolDef = this.policy.tools[toolName];
if (toolDef?.write_access) {
return this.verdict(false, traceId, 'Write access blocked by global read-only mode');
}
}
// 2. Tool Existence Check
const toolDef = this.policy.tools[toolName];
if (!toolDef) {
return this.verdict(false, traceId, 'Tool not defined in policy');
}
// 3. Device Posture Check
if (!operator.deviceCompliant) {
return this.verdict(false, traceId, 'Device non-compliant');
}
// 4. Group Membership Check
const hasAccess = operator.groups.some(group =>
toolDef.allowed_groups.includes(group)
);
if (!hasAccess) {
return this.verdict(false, traceId, 'Insufficient group privileges');
}
// 5. Approval Requirements
const requiresApproval = toolDef.requires_approval ||
(toolDef.write_access && operator.groups.length === 0); // Example logic
return {
allowed: true,
requiresApproval,
reason: 'Authorized',
traceId,
};
}
private verdict(allowed: boolean, traceId: string, reason: string): Verdict {
return { allowed, requiresApproval: false, reason, traceId };
}
}
3. Tool Executor with Context Sanitization
Tools should never return raw data to the agent without validation. The executor wraps tool calls and applies sanitization rules.
import { createHash } from 'crypto';
export class ToolExecutor {
private evaluator: PolicyEvaluator;
private auditLogger: AuditLogger;
constructor(evaluator: PolicyEvaluator, auditLogger: AuditLogger) {
this.evaluator = evaluator;
this.auditLogger = auditLogger;
}
async execute(request: ToolRequest): Promise<string> {
// Evaluate policy before execution
const verdict = await this.evaluator.evaluate(request);
// Log the decision
await this.auditLogger.logDecision(request, verdict);
if (!verdict.allowed) {
throw new Error(`Policy violation: ${verdict.reason} [Trace: ${verdict.traceId}]`);
}
if (verdict.requiresApproval) {
// In production, this would trigger an approval workflow
throw new Error(`Approval required for tool ${request.toolName} [Trace: ${verdict.traceId}]`);
}
// Execute tool (mock implementation)
const rawOutput = await this.invokeTool(request.toolName, request.parameters);
// Sanitize output before returning to agent
return this.sanitizeOutput(request.toolName, rawOutput);
}
private async invokeTool(toolName: string, params: Record<string, unknown>): Promise<string> {
// Actual tool implementation goes here
// e.g., AWS SDK call, Jira API call, etc.
return `Result from ${toolName}`;
}
private sanitizeOutput(toolName: string, output: string): string {
const toolDef = this.evaluator['policy'].tools[toolName];
if (!toolDef?.context_restrictions) return output;
let sanitized = output;
// Truncate if necessary
const maxTokens = toolDef.context_restrictions.max_tokens;
if (maxTokens && sanitized.length > maxTokens) {
sanitized = sanitized.substring(0, maxTokens) + '... [TRUNCATED]';
}
// Mask sensitive fields
const sensitiveFields = toolDef.context_restrictions.sensitive_fields || [];
for (const field of sensitiveFields) {
const regex = new RegExp(`"${field}"\\s*:\\s*"[^"]*"`, 'g');
sanitized = sanitized.replace(regex, `"${field}": "[MASKED]"`);
}
// Detect and block secrets
if (this.containsSecretPattern(sanitized)) {
throw new Error('Tool output contains potential secrets; blocked by sanitizer.');
}
return sanitized;
}
private containsSecretPattern(text: string): boolean {
// Regex patterns for common secrets
const patterns = [
/AKIA[0-9A-Z]{16}/,
/-----BEGIN (RSA |EC )?PRIVATE KEY-----/,
/(?:api[_-]?key|password|secret)\s*[:=]\s*['"]?[A-Za-z0-9+/=_\-]{20,}/i,
];
return patterns.some(p => p.test(text));
}
}
4. Audit Logger
Audit logs must be immutable and include full context for compliance.
export interface AuditEntry {
timestamp: string;
traceId: string;
userId: string;
toolName: string;
verdict: Verdict;
parametersHash: string;
}
export class AuditLogger {
async logDecision(request: ToolRequest, verdict: Verdict): Promise<void> {
const entry: AuditEntry = {
timestamp: new Date().toISOString(),
traceId: verdict.traceId,
userId: request.operator.userId,
toolName: request.toolName,
verdict,
parametersHash: createHash('sha256')
.update(JSON.stringify(request.parameters))
.digest('hex'),
};
// Write to secure storage (e.g., WORM bucket, SIEM)
console.log('[AUDIT]', JSON.stringify(entry));
}
}
5. Gateway Assembly
import Fastify from 'fastify';
const app = Fastify();
// Load policy (in production, fetch from secure config service)
const policyConfig: PolicyConfig = {
version: '1.0.0',
kill_switch: {
global_read_only: false,
disabled_tools: [],
disabled_users: [],
},
tools: {
'jira:read_ticket': {
risk_level: 'low',
allowed_groups: ['devops-read', 'security-read'],
requires_approval: false,
write_access: false,
},
'aws:describe_instances': {
risk_level: 'medium',
allowed_groups: ['cloud-readonly'],
requires_approval: false,
write_access: false,
context_restrictions: {
max_tokens: 4000,
sensitive_fields: ['PublicIpAddress'],
},
},
},
};
const evaluator = new PolicyEvaluator(policyConfig);
const auditLogger = new AuditLogger();
const executor = new ToolExecutor(evaluator, auditLogger);
app.post('/agent/execute', async (request, reply) => {
// Identity resolution should happen via middleware (OIDC/JWT validation)
const identity: OperatorIdentity = {
userId: 'user-123',
groups: ['devops-read'],
deviceCompliant: true,
sessionId: 'sess-abc',
};
const toolRequest: ToolRequest = {
toolName: request.body.toolName as string,
parameters: request.body.parameters as Record<string, unknown>,
operator: identity,
};
try {
const result = await executor.execute(toolRequest);
return { success: true, result };
} catch (error) {
return reply.code(403).send({
success: false,
error: error instanceof Error ? error.message : 'Unknown error'
});
}
});
app.listen({ port: 3000 }, (err) => {
if (err) throw err;
console.log('Control Plane listening on port 3000');
});
Pitfall Guide
Implementing a control plane introduces new failure modes. The following pitfalls are derived from production deployments in regulated environments.
| Pitfall | Explanation | Fix |
|---|
| Header Trust Vulnerability | Accepting user identity from HTTP headers without cryptographic validation allows spoofing. | Validate JWT/OIDC tokens at the gateway edge. Never trust X-User-Email headers. |
| Tool Output Context Overflow | Tool responses may contain sensitive data that leaks into the LLM context window. | Implement strict output sanitization with truncation, field masking, and secret detection before returning data to the agent. |
| Policy Evaluation Race Conditions | Concurrent requests may bypass checks if state is not handled atomically. | Ensure policy evaluation is stateless or uses atomic locks. Log every decision with a unique trace ID. |
| Regex Blind Spots | Relying solely on regex for secret detection leads to false negatives. | Combine regex with entropy analysis and allow-listing of known safe patterns. |
| Silent Denials | Failing to log denied requests creates audit gaps and hides attack attempts. | Log all verdicts, including denials, with full context. Alert on high denial rates. |
| Kill Switch Latency | Slow policy reloads delay emergency shutdowns. | Use in-memory caching with pub/sub invalidation. Test kill-switch latency regularly. |
| Tool Chaining Risks | Agents may combine multiple low-risk tools to achieve a high-risk outcome. | Define policy rules that consider tool combinations or restrict data flow between tools. |
Production Bundle
Action Checklist
Decision Matrix
| Scenario | Recommended Approach | Why | Cost Impact |
|---|
| Internal Dev Tool | Lightweight Policy Engine | Speed of development outweighs strict compliance needs. | Low |
| Prod Financial Agent | Full Control Plane with Audit | Regulatory compliance requires deterministic control and auditability. | Medium |
| Multi-Tenant SaaS | Tenant-Isolated Policies | Data segregation and per-tenant risk profiles are critical. | High |
| High-Frequency Trading | In-Memory Policy Cache | Latency constraints require sub-millisecond policy evaluation. | Medium |
Configuration Template
version: "2.0.0"
kill_switch:
global_read_only: false
disabled_tools: []
disabled_users: []
tools:
jira:read_ticket:
risk_level: low
allowed_groups:
- devops-read
- security-read
requires_approval: false
write_access: false
aws:modify_security_group:
risk_level: high
allowed_groups:
- cloud-admin
requires_approval: true
approval_groups:
- prod-approvers
write_access: true
context_restrictions:
max_tokens: 2000
sensitive_fields:
- SecurityGroupId
- VpcId
Quick Start Guide
- Initialize Project:
mkdir control-plane && cd control-plane
npm init -y
npm install fastify zod
npm install -D typescript @types/node
- Create Policy File:
Save the configuration template as
policy.yaml and load it into your application.
- Run Gateway:
Start the Fastify server and verify the health endpoint.
- Test Policy Enforcement:
Send a request to
/agent/execute with a valid identity and tool name. Verify the verdict and audit log.
- Validate Sanitization:
Inject a mock secret into a tool response and confirm the sanitizer blocks or masks it.
This implementation provides a robust foundation for deploying AI agents in regulated environments. By enforcing policy at the code level, organizations can harness the power of LLMs while maintaining strict control over authorization, data access, and auditability.