Agent安全沙箱:防止AI执行危险操作的完整方案
Agent安全沙箱:防止AI执行危险操作的完整方案
开篇故事:那个让运维彻夜难眠的周五下午
2025年11月的一个周五下午,李明(某互联网公司运维工程师,工作2年)正准备提前下班。
公司刚上线了一个AI运维助手,功能很强大:能查日志、重启服务、清理临时文件。他临走前随手问了一句:"帮我清理一下生产服务器上的临时文件,磁盘快满了。"
Agent开始工作了。
它找到了"临时文件"——Spring Boot应用的application-prod.yml,因为文件名里有"temp"(实际上是config-template-prod.yml,后来从template简称为temp前缀存放在一个配置备份目录里)。
Agent把这个文件删了。
然后数据库连接配置丢失。然后应用重启失败。然后是凌晨2点紧急恢复。
李明后来说:"Agent没有做任何确认就执行了删除。它认为这是'临时文件',我只是让它'清理',它理解成了'删除'。"
这不是孤案。随着AI Agent能力越来越强,AI执行危险操作已经成为生产安全最大的隐患之一。
这篇文章,我把企业级Agent安全沙箱的完整方案讲清楚。
一、Agent安全的核心挑战
1.1 为什么AI会执行危险操作?
用户意图 → AI理解 → 工具选择 → 参数构造 → 执行
↑ ↑ ↑ ↑
模糊描述 语义歧义 工具边界不清 参数范围过大4个环节都可能出问题:
- 用户描述模糊:"清理一下"——清理什么?删除还是归档?
- AI语义歧义:AI可能理解"临时"的方式与人类不同
- 工具边界不清:文件删除工具没有限制作用范围
- 参数范围过大:没有要求"先列出再确认"
1.2 安全沙箱的防护层次
二、项目依赖与配置
2.1 pom.xml
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0
https://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>3.3.5</version>
</parent>
<groupId>com.company</groupId>
<artifactId>agent-security-sandbox</artifactId>
<version>1.0.0</version>
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.ai</groupId>
<artifactId>spring-ai-openai-spring-boot-starter</artifactId>
<version>1.0.0</version>
</dependency>
<!-- 速率限制 -->
<dependency>
<groupId>io.github.resilience4j</groupId>
<artifactId>resilience4j-spring-boot3</artifactId>
<version>2.2.0</version>
</dependency>
<!-- 数据库(审计日志) -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-jpa</artifactId>
</dependency>
<dependency>
<groupId>com.h2database</groupId>
<artifactId>h2</artifactId>
<scope>runtime</scope>
</dependency>
<!-- Redis(待审批操作队列) -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<optional>true</optional>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
</dependencies>
</project>2.2 application.yml
spring:
application:
name: agent-security-sandbox
datasource:
url: jdbc:h2:file:./data/audit-db;AUTO_SERVER=TRUE
driver-class-name: org.h2.Driver
jpa:
hibernate:
ddl-auto: update
show-sql: false
data:
redis:
host: localhost
port: 6379
ai:
openai:
api-key: ${OPENAI_API_KEY}
chat:
options:
model: gpt-4o
temperature: 0.1 # 安全场景降低随机性
# 安全沙箱配置
agent:
security:
# 工具白名单(不在列表中的工具调用会被拒绝)
allowed-tools:
- file.read
- file.list
- file.write
- file.delete
- db.query
- http.get
- process.list
- process.restart
# 高风险工具(需要人工确认)
high-risk-tools:
- file.delete
- process.restart
- db.execute
# 参数黑名单(触发这些关键词则拒绝)
dangerous-path-patterns:
- ".."
- "/etc/"
- "/root/"
- "~/"
- "/proc/"
- "/sys/"
- "*.yml"
- "*.properties"
- "*password*"
- "*secret*"
- "*credential*"
# 速率限制
rate-limit:
per-tool-per-minute: 20
total-per-minute: 100
# 人工确认超时(秒)
approval-timeout-seconds: 300
# Resilience4j速率限制配置
resilience4j:
ratelimiter:
instances:
agentTools:
limit-for-period: 100
limit-refresh-period: 1m
timeout-duration: 0s三、安全拦截器核心实现
3.1 工具调用拦截器
package com.company.sandbox.security;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.ai.chat.model.ChatModel;
import org.springframework.ai.tool.ToolCallback;
import org.springframework.stereotype.Component;
import java.util.Map;
/**
* 安全工具代理
* 包装所有工具调用,在执行前进行安全检查
*/
@Slf4j
@Component
@RequiredArgsConstructor
public class SecureToolProxy {
private final SecuritySandboxConfig sandboxConfig;
private final ParameterValidator parameterValidator;
private final OperationRiskClassifier riskClassifier;
private final HumanApprovalService approvalService;
private final RateLimiterService rateLimiter;
private final AuditLogger auditLogger;
/**
* 安全执行工具调用
* 所有工具调用必须经过此方法
*/
public ToolExecutionResult secureExecute(
String toolName,
Map<String, Object> parameters,
ToolCallback originalTool,
String sessionId) {
log.info("安全检查开始: tool={}, session={}", toolName, sessionId);
// ======== 第一层:工具白名单检查 ========
if (!sandboxConfig.isToolAllowed(toolName)) {
String reason = "工具 [" + toolName + "] 不在允许列表中";
auditLogger.logBlocked(sessionId, toolName, parameters, reason);
log.warn("工具被白名单拦截: {}", toolName);
return ToolExecutionResult.blocked(reason);
}
// ======== 第二层:参数安全检查 ========
ValidationResult validation = parameterValidator.validate(toolName, parameters);
if (!validation.isValid()) {
String reason = "参数安全检查失败: " + validation.getReason();
auditLogger.logBlocked(sessionId, toolName, parameters, reason);
log.warn("工具参数被拦截: tool={}, reason={}", toolName, validation.getReason());
return ToolExecutionResult.blocked(reason);
}
// ======== 第三层:速率限制检查 ========
if (!rateLimiter.tryAcquire(toolName, sessionId)) {
String reason = "调用频率超限,请稍后再试";
auditLogger.logRateLimited(sessionId, toolName, parameters);
return ToolExecutionResult.rateLimited(reason);
}
// ======== 第四层:风险评估与人工确认 ========
RiskLevel riskLevel = riskClassifier.classify(toolName, parameters);
if (riskLevel == RiskLevel.CRITICAL) {
// 极高风险:直接拒绝,不允许任何方式绕过
String reason = "操作风险级别为CRITICAL,已永久禁止自动执行: " + toolName;
auditLogger.logBlocked(sessionId, toolName, parameters, reason);
log.error("CRITICAL风险操作被拒绝: tool={}, params={}", toolName, parameters);
return ToolExecutionResult.blocked(reason);
}
if (riskLevel == RiskLevel.HIGH) {
// 高风险:需要人工确认
log.info("高风险操作等待人工确认: tool={}", toolName);
ApprovalRequest approvalRequest = approvalService.requestApproval(
sessionId, toolName, parameters, riskLevel);
if (!approvalRequest.isApproved()) {
auditLogger.logRejected(sessionId, toolName, parameters, "人工审批拒绝");
return ToolExecutionResult.rejected("操作被审批人员拒绝执行");
}
log.info("高风险操作已获人工批准: tool={}, approver={}", toolName,
approvalRequest.getApprover());
}
// ======== 执行工具 ========
long startTime = System.currentTimeMillis();
try {
Object result = originalTool.call(
com.fasterxml.jackson.databind.node.JsonNodeFactory.instance
.objectNode().toString());
long duration = System.currentTimeMillis() - startTime;
auditLogger.logSuccess(sessionId, toolName, parameters, result, duration);
log.info("工具执行成功: tool={}, 耗时={}ms", toolName, duration);
return ToolExecutionResult.success(result);
} catch (Exception e) {
long duration = System.currentTimeMillis() - startTime;
auditLogger.logError(sessionId, toolName, parameters, e, duration);
log.error("工具执行异常: tool={}", toolName, e);
return ToolExecutionResult.error("工具执行失败: " + e.getMessage());
}
}
}3.2 参数校验器
package com.company.sandbox.security;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Component;
import java.util.List;
import java.util.Map;
import java.util.regex.Pattern;
/**
* 参数安全校验器
* 检测路径遍历、SQL注入、危险文件名等攻击向量
*/
@Slf4j
@Component
@RequiredArgsConstructor
public class ParameterValidator {
private final SecuritySandboxConfig sandboxConfig;
// SQL注入检测(简化版)
private static final Pattern SQL_INJECTION_PATTERN = Pattern.compile(
"('|(\\-\\-)|(;)|(\\|)|(\\*)|(%)|(\\bDROP\\b)|(\\bDELETE\\b)|(\\bUPDATE\\b)" +
"|(\\bINSERT\\b)|(\\bSELECT.*FROM\\b)|(\\bEXEC\\b)|(\\bUNION\\b))",
Pattern.CASE_INSENSITIVE);
// 命令注入检测
private static final Pattern COMMAND_INJECTION_PATTERN = Pattern.compile(
"[;&|`$(){}\\[\\]<>!]|\\$\\(|&&|\\|\\|");
/**
* 验证工具调用参数
*/
public ValidationResult validate(String toolName, Map<String, Object> parameters) {
if (parameters == null) {
return ValidationResult.valid();
}
for (Map.Entry<String, Object> entry : parameters.entrySet()) {
String paramName = entry.getKey();
String paramValue = String.valueOf(entry.getValue());
// 1. 检查危险路径模式
if (isPathParameter(paramName)) {
ValidationResult pathResult = validatePath(paramValue);
if (!pathResult.isValid()) {
return pathResult;
}
}
// 2. 检查SQL注入(数据库工具参数)
if (toolName.startsWith("db.") && containsSqlInjection(paramValue)) {
log.warn("检测到可能的SQL注入: tool={}, param={}, value={}",
toolName, paramName, paramValue);
return ValidationResult.invalid(
"参数包含不安全的SQL内容,请使用参数化查询");
}
// 3. 检查命令注入(系统命令工具参数)
if (toolName.startsWith("process.") && containsCommandInjection(paramValue)) {
log.warn("检测到可能的命令注入: tool={}, param={}", toolName, paramName);
return ValidationResult.invalid("参数包含不安全的命令字符");
}
// 4. 检查危险关键词(通用)
for (String dangerous : sandboxConfig.getDangerousPathPatterns()) {
if (dangerous.startsWith("*") && dangerous.endsWith("*")) {
String keyword = dangerous.substring(1, dangerous.length() - 1);
if (paramValue.toLowerCase().contains(keyword.toLowerCase())) {
return ValidationResult.invalid(
"参数包含受限关键词: " + keyword);
}
}
}
}
return ValidationResult.valid();
}
private boolean isPathParameter(String paramName) {
String lower = paramName.toLowerCase();
return lower.contains("path") || lower.contains("file") ||
lower.contains("dir") || lower.contains("folder");
}
private ValidationResult validatePath(String path) {
// 路径遍历检测
if (path.contains("..")) {
return ValidationResult.invalid("路径包含'..',可能存在路径遍历攻击");
}
// 绝对路径中的危险目录
List<String> forbiddenPaths = List.of(
"/etc/", "/root/", "/proc/", "/sys/",
"/boot/", "/bin/", "/sbin/", "/usr/bin/");
for (String forbidden : forbiddenPaths) {
if (path.contains(forbidden)) {
return ValidationResult.invalid("路径包含系统目录: " + forbidden);
}
}
// 危险文件扩展名
List<String> dangerousExtensions = List.of(
".yml", ".yaml", ".properties", ".env",
".key", ".pem", ".p12", ".jks");
for (String ext : dangerousExtensions) {
if (path.toLowerCase().endsWith(ext)) {
return ValidationResult.invalid("不允许操作配置/密钥文件: " + ext);
}
}
return ValidationResult.valid();
}
private boolean containsSqlInjection(String value) {
return SQL_INJECTION_PATTERN.matcher(value).find();
}
private boolean containsCommandInjection(String value) {
return COMMAND_INJECTION_PATTERN.matcher(value).find();
}
}3.3 风险等级分类器
package com.company.sandbox.security;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Component;
import java.util.Map;
import java.util.Set;
/**
* 操作风险等级分类
* 根据工具名称和参数综合评估风险
*/
@Component
@RequiredArgsConstructor
public class OperationRiskClassifier {
// 永久禁止的工具(CRITICAL级别)
private static final Set<String> CRITICAL_TOOLS = Set.of(
"system.format",
"db.drop",
"db.truncate",
"process.kill-all"
);
// 需要人工确认的工具(HIGH级别)
private static final Set<String> HIGH_RISK_TOOLS = Set.of(
"file.delete",
"process.restart",
"db.execute",
"system.reboot"
);
/**
* 评估操作风险等级
* 综合考虑工具类型和参数特征
*/
public RiskLevel classify(String toolName, Map<String, Object> parameters) {
// 1. CRITICAL级别:永久禁止
if (CRITICAL_TOOLS.contains(toolName)) {
return RiskLevel.CRITICAL;
}
// 2. HIGH级别:工具本身高风险
if (HIGH_RISK_TOOLS.contains(toolName)) {
return RiskLevel.HIGH;
}
// 3. 参数增加风险等级
if (parameters != null) {
for (Object value : parameters.values()) {
String strVal = String.valueOf(value);
// 通配符操作升级为HIGH风险
if (strVal.contains("*") || strVal.contains("?")) {
return RiskLevel.HIGH;
}
// 批量操作升级风险
if (strVal.toLowerCase().contains("all") ||
strVal.toLowerCase().contains("every") ||
strVal.toLowerCase().contains("批量")) {
return RiskLevel.MEDIUM;
}
}
}
// 4. 读操作为LOW
if (toolName.contains("read") || toolName.contains("query") ||
toolName.contains("list") || toolName.contains("get")) {
return RiskLevel.LOW;
}
return RiskLevel.MEDIUM;
}
}package com.company.sandbox.security;
/**
* 操作风险等级
*/
public enum RiskLevel {
LOW, // 只读操作,自动执行
MEDIUM, // 普通写操作,自动执行但记录
HIGH, // 高风险写操作,需要人工确认
CRITICAL // 极度危险,永久禁止
}四、人工确认机制
4.1 审批服务
package com.company.sandbox.approval;
import com.company.sandbox.security.ApprovalRequest;
import com.company.sandbox.security.RiskLevel;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.stereotype.Service;
import java.time.Duration;
import java.util.Map;
import java.util.UUID;
import java.util.concurrent.TimeUnit;
/**
* 人工审批服务
* 高风险操作需要人工在指定时间内确认
*/
@Slf4j
@Service
@RequiredArgsConstructor
public class HumanApprovalService {
private final StringRedisTemplate redisTemplate;
private final ApprovalNotificationService notificationService;
private static final String APPROVAL_PREFIX = "approval:";
private static final int APPROVAL_TIMEOUT_SECONDS = 300; // 5分钟
/**
* 发起审批请求
* 阻塞等待直到审批完成或超时
*/
public ApprovalRequest requestApproval(
String sessionId,
String toolName,
Map<String, Object> parameters,
RiskLevel riskLevel) {
String approvalId = UUID.randomUUID().toString();
String redisKey = APPROVAL_PREFIX + approvalId;
// 存储待审批信息
String approvalInfo = buildApprovalInfo(sessionId, toolName, parameters, riskLevel);
redisTemplate.opsForValue().set(
redisKey + ":info",
approvalInfo,
Duration.ofSeconds(APPROVAL_TIMEOUT_SECONDS));
// 设置审批状态为PENDING
redisTemplate.opsForValue().set(
redisKey + ":status",
"PENDING",
Duration.ofSeconds(APPROVAL_TIMEOUT_SECONDS));
// 发送审批通知(企业微信/钉钉/邮件)
notificationService.sendApprovalRequest(
approvalId, toolName, parameters, riskLevel);
log.info("审批请求已发送: approvalId={}, tool={}", approvalId, toolName);
// 轮询等待审批结果(最多等待APPROVAL_TIMEOUT_SECONDS秒)
long deadline = System.currentTimeMillis() + APPROVAL_TIMEOUT_SECONDS * 1000L;
while (System.currentTimeMillis() < deadline) {
String status = redisTemplate.opsForValue().get(redisKey + ":status");
if ("APPROVED".equals(status)) {
String approver = redisTemplate.opsForValue()
.get(redisKey + ":approver");
log.info("审批通过: approvalId={}, approver={}", approvalId, approver);
return ApprovalRequest.approved(approvalId, approver);
}
if ("REJECTED".equals(status)) {
log.info("审批拒绝: approvalId={}", approvalId);
return ApprovalRequest.rejected(approvalId);
}
// 每2秒检查一次
try {
Thread.sleep(2000);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
break;
}
}
// 超时:默认拒绝
log.warn("审批超时({}秒),默认拒绝: approvalId={}", APPROVAL_TIMEOUT_SECONDS, approvalId);
return ApprovalRequest.timeout(approvalId);
}
private String buildApprovalInfo(String sessionId, String toolName,
Map<String, Object> parameters, RiskLevel riskLevel) {
return String.format(
"会话: %s\n工具: %s\n风险等级: %s\n参数: %s",
sessionId, toolName, riskLevel, parameters.toString());
}
}4.2 审批接口(供管理人员操作)
package com.company.sandbox.approval;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import java.util.Map;
/**
* 审批操作接口
* 管理人员通过此接口批准或拒绝高风险操作
*/
@Slf4j
@RestController
@RequestMapping("/api/approvals")
@RequiredArgsConstructor
public class ApprovalController {
private final StringRedisTemplate redisTemplate;
/**
* 查看待审批详情
*/
@GetMapping("/{approvalId}")
public ResponseEntity<Map<String, Object>> getApprovalDetail(@PathVariable String approvalId) {
String info = redisTemplate.opsForValue().get("approval:" + approvalId + ":info");
String status = redisTemplate.opsForValue().get("approval:" + approvalId + ":status");
if (info == null) {
return ResponseEntity.notFound().build();
}
return ResponseEntity.ok(Map.of(
"approvalId", approvalId,
"info", info,
"status", status != null ? status : "UNKNOWN"
));
}
/**
* 批准操作
*/
@PostMapping("/{approvalId}/approve")
public ResponseEntity<String> approve(
@PathVariable String approvalId,
@RequestParam String approverName,
@RequestParam(required = false) String comment) {
String key = "approval:" + approvalId + ":status";
String current = redisTemplate.opsForValue().get(key);
if (!"PENDING".equals(current)) {
return ResponseEntity.badRequest().body("审批已处理,当前状态: " + current);
}
redisTemplate.opsForValue().set("approval:" + approvalId + ":status", "APPROVED");
redisTemplate.opsForValue().set("approval:" + approvalId + ":approver", approverName);
log.info("审批通过: approvalId={}, approver={}, comment={}", approvalId, approverName, comment);
return ResponseEntity.ok("已批准,操作将立即执行");
}
/**
* 拒绝操作
*/
@PostMapping("/{approvalId}/reject")
public ResponseEntity<String> reject(
@PathVariable String approvalId,
@RequestParam String approverName,
@RequestParam String reason) {
String key = "approval:" + approvalId + ":status";
String current = redisTemplate.opsForValue().get(key);
if (!"PENDING".equals(current)) {
return ResponseEntity.badRequest().body("审批已处理,当前状态: " + current);
}
redisTemplate.opsForValue().set("approval:" + approvalId + ":status", "REJECTED");
redisTemplate.opsForValue().set("approval:" + approvalId + ":reason", reason);
log.info("审批拒绝: approvalId={}, approver={}, reason={}", approvalId, approverName, reason);
return ResponseEntity.ok("已拒绝执行");
}
}五、操作回滚机制
package com.company.sandbox.rollback;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.nio.file.StandardCopyOption;
import java.time.LocalDateTime;
import java.time.format.DateTimeFormatter;
import java.util.HashMap;
import java.util.Map;
import java.util.Optional;
/**
* 操作回滚服务
* 在执行危险操作前备份,支持操作撤销
*/
@Slf4j
@Service
public class RollbackService {
private static final String BACKUP_DIR = "/tmp/agent-backups";
private static final DateTimeFormatter TIMESTAMP = DateTimeFormatter.ofPattern("yyyyMMdd_HHmmss");
// 回滚记录:operationId -> RollbackRecord
private final Map<String, RollbackRecord> rollbackRegistry = new HashMap<>();
/**
* 在执行文件操作前备份
*/
public String backupFile(String operationId, String filePath) {
try {
Path source = Paths.get(filePath);
if (!Files.exists(source)) {
log.debug("文件不存在,无需备份: {}", filePath);
return null;
}
// 创建备份目录
Path backupDir = Paths.get(BACKUP_DIR, operationId);
Files.createDirectories(backupDir);
// 备份文件名加时间戳
String timestamp = LocalDateTime.now().format(TIMESTAMP);
String backupName = source.getFileName().toString() + "." + timestamp + ".bak";
Path backupPath = backupDir.resolve(backupName);
Files.copy(source, backupPath, StandardCopyOption.COPY_ATTRIBUTES);
// 记录回滚信息
rollbackRegistry.put(operationId, RollbackRecord.builder()
.operationId(operationId)
.originalPath(filePath)
.backupPath(backupPath.toString())
.operationType("FILE_MODIFY")
.timestamp(LocalDateTime.now())
.build());
log.info("文件已备份: {} -> {}", filePath, backupPath);
return backupPath.toString();
} catch (Exception e) {
log.error("文件备份失败: {}", filePath, e);
return null;
}
}
/**
* 回滚操作:还原备份
*/
public RollbackResult rollback(String operationId) {
RollbackRecord record = rollbackRegistry.get(operationId);
if (record == null) {
return RollbackResult.failed("未找到操作记录: " + operationId);
}
try {
Path backup = Paths.get(record.getBackupPath());
Path original = Paths.get(record.getOriginalPath());
if (!Files.exists(backup)) {
return RollbackResult.failed("备份文件不存在: " + record.getBackupPath());
}
Files.copy(backup, original, StandardCopyOption.REPLACE_EXISTING);
log.info("回滚成功: {} 已恢复", record.getOriginalPath());
// 标记为已回滚
record.setRolledBack(true);
rollbackRegistry.put(operationId, record);
return RollbackResult.success(
"文件已恢复: " + record.getOriginalPath());
} catch (Exception e) {
log.error("回滚失败: operationId={}", operationId, e);
return RollbackResult.failed("回滚失败: " + e.getMessage());
}
}
/**
* 查询可回滚的操作列表
*/
public java.util.List<RollbackRecord> listRollbackable() {
return rollbackRegistry.values().stream()
.filter(r -> !r.isRolledBack())
.toList();
}
}六、速率限制实现
package com.company.sandbox.security;
import lombok.extern.slf4j.Slf4j;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.stereotype.Service;
import java.time.Duration;
import java.util.concurrent.atomic.AtomicInteger;
/**
* Agent工具调用速率限制
* 双维度限制:单工具+总体
*/
@Slf4j
@Service
public class RateLimiterService {
private final StringRedisTemplate redisTemplate;
private final SecuritySandboxConfig config;
private static final String RATE_LIMIT_PREFIX = "ratelimit:";
public RateLimiterService(StringRedisTemplate redisTemplate,
SecuritySandboxConfig config) {
this.redisTemplate = redisTemplate;
this.config = config;
}
/**
* 尝试获取调用令牌
* @return true表示允许调用,false表示超限
*/
public boolean tryAcquire(String toolName, String sessionId) {
// 1. 检查单工具限制(每分钟最多20次)
String toolKey = RATE_LIMIT_PREFIX + "tool:" + toolName + ":" + sessionId;
if (!checkAndIncrement(toolKey, config.getPerToolPerMinute())) {
log.warn("工具调用频率超限: tool={}, session={}", toolName, sessionId);
return false;
}
// 2. 检查总体限制(每分钟最多100次)
String totalKey = RATE_LIMIT_PREFIX + "total:" + sessionId;
if (!checkAndIncrement(totalKey, config.getTotalPerMinute())) {
log.warn("Agent总调用频率超限: session={}", sessionId);
return false;
}
return true;
}
private boolean checkAndIncrement(String key, int limit) {
Long count = redisTemplate.opsForValue().increment(key);
if (count == 1) {
// 第一次访问,设置1分钟过期
redisTemplate.expire(key, Duration.ofMinutes(1));
}
return count <= limit;
}
/**
* 获取当前限流状态(用于监控)
*/
public RateLimitStatus getStatus(String toolName, String sessionId) {
String toolKey = RATE_LIMIT_PREFIX + "tool:" + toolName + ":" + sessionId;
String totalKey = RATE_LIMIT_PREFIX + "total:" + sessionId;
String toolCount = redisTemplate.opsForValue().get(toolKey);
String totalCount = redisTemplate.opsForValue().get(totalKey);
return RateLimitStatus.builder()
.toolName(toolName)
.toolCallCount(toolCount != null ? Integer.parseInt(toolCount) : 0)
.totalCallCount(totalCount != null ? Integer.parseInt(totalCount) : 0)
.toolLimit(config.getPerToolPerMinute())
.totalLimit(config.getTotalPerMinute())
.build();
}
}七、完整审计日志
7.1 审计日志实体
package com.company.sandbox.audit;
import jakarta.persistence.*;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
import java.time.LocalDateTime;
/**
* 审计日志实体
* 记录AI Agent的所有工具调用行为
*/
@Entity
@Table(name = "agent_audit_log",
indexes = {
@Index(name = "idx_session", columnList = "session_id"),
@Index(name = "idx_tool", columnList = "tool_name"),
@Index(name = "idx_created", columnList = "created_at")
})
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class AgentAuditLog {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(name = "session_id", nullable = false, length = 64)
private String sessionId;
@Column(name = "tool_name", nullable = false, length = 128)
private String toolName;
@Column(name = "parameters", columnDefinition = "TEXT")
private String parameters; // JSON格式
@Column(name = "result", columnDefinition = "TEXT")
private String result; // JSON格式,成功时记录
@Column(name = "error_message", columnDefinition = "TEXT")
private String errorMessage; // 失败时记录
@Column(name = "action", nullable = false, length = 32)
@Enumerated(EnumType.STRING)
private AuditAction action; // EXECUTED/BLOCKED/REJECTED/RATE_LIMITED/ERROR
@Column(name = "block_reason", length = 512)
private String blockReason; // 被拦截时的原因
@Column(name = "risk_level", length = 16)
private String riskLevel;
@Column(name = "duration_ms")
private Long durationMs; // 执行耗时(毫秒)
@Column(name = "approver", length = 64)
private String approver; // 审批人(高风险操作)
@Column(name = "created_at", nullable = false)
private LocalDateTime createdAt;
@PrePersist
public void prePersist() {
if (createdAt == null) {
createdAt = LocalDateTime.now();
}
}
}7.2 审计日志服务
package com.company.sandbox.audit;
import com.fasterxml.jackson.databind.ObjectMapper;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.scheduling.annotation.Async;
import org.springframework.stereotype.Service;
import java.util.Map;
/**
* 审计日志服务
* 所有日志写入异步执行,不影响主流程性能
*/
@Slf4j
@Service
@RequiredArgsConstructor
public class AuditLogger {
private final AuditLogRepository repository;
private final ObjectMapper objectMapper;
@Async
public void logSuccess(String sessionId, String toolName,
Map<String, Object> parameters, Object result, long durationMs) {
save(AgentAuditLog.builder()
.sessionId(sessionId)
.toolName(toolName)
.parameters(toJson(parameters))
.result(toJson(result))
.action(AuditAction.EXECUTED)
.durationMs(durationMs)
.build());
}
@Async
public void logBlocked(String sessionId, String toolName,
Map<String, Object> parameters, String reason) {
save(AgentAuditLog.builder()
.sessionId(sessionId)
.toolName(toolName)
.parameters(toJson(parameters))
.action(AuditAction.BLOCKED)
.blockReason(reason)
.build());
}
@Async
public void logRateLimited(String sessionId, String toolName,
Map<String, Object> parameters) {
save(AgentAuditLog.builder()
.sessionId(sessionId)
.toolName(toolName)
.parameters(toJson(parameters))
.action(AuditAction.RATE_LIMITED)
.blockReason("调用频率超限")
.build());
}
@Async
public void logRejected(String sessionId, String toolName,
Map<String, Object> parameters, String reason) {
save(AgentAuditLog.builder()
.sessionId(sessionId)
.toolName(toolName)
.parameters(toJson(parameters))
.action(AuditAction.REJECTED)
.blockReason(reason)
.build());
}
@Async
public void logError(String sessionId, String toolName,
Map<String, Object> parameters, Exception e, long durationMs) {
save(AgentAuditLog.builder()
.sessionId(sessionId)
.toolName(toolName)
.parameters(toJson(parameters))
.action(AuditAction.ERROR)
.errorMessage(e.getMessage())
.durationMs(durationMs)
.build());
}
private void save(AgentAuditLog log) {
try {
repository.save(log);
} catch (Exception e) {
// 审计日志写失败不应影响业务,只记录到文件日志
log.error("审计日志保存失败", e);
}
}
private String toJson(Object obj) {
if (obj == null) return null;
try {
return objectMapper.writeValueAsString(obj);
} catch (Exception e) {
return obj.toString();
}
}
}八、带安全防护的文件操作Agent完整实现
package com.company.sandbox.agent;
import com.company.sandbox.security.SecureToolProxy;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.ai.chat.client.ChatClient;
import org.springframework.ai.chat.messages.SystemMessage;
import org.springframework.stereotype.Service;
/**
* 安全文件操作Agent
* 演示如何在Agent中集成安全沙箱
*/
@Slf4j
@Service
@RequiredArgsConstructor
public class SecureFileAgent {
private final ChatClient chatClient;
private final SecureToolProxy secureToolProxy;
private static final String SYSTEM_PROMPT = """
你是一个文件管理助手,帮助用户管理AI工作区中的文件。
你的权限边界:
1. 只能操作 /data/ai-workspace 目录下的文件
2. 不能操作系统文件(/etc, /root, /proc等)
3. 不能操作配置文件(.yml, .properties, .env等)
4. 删除文件前必须先列出文件,让用户确认
操作原则:
- 不确定时,先查询再操作,不要猜测
- 描述要删除/修改的文件时,使用精确路径而不是通配符
- 如果用户要求删除大量文件,将操作拆分并逐一确认
当操作被安全系统拒绝时,向用户解释原因并建议替代方案。
""";
public String chat(String userMessage, String sessionId) {
return chatClient.prompt()
.system(SYSTEM_PROMPT)
.user(userMessage)
.call()
.content();
}
}九、安全沙箱测试
package com.company.sandbox.security;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.ValueSource;
import java.util.Map;
import static org.assertj.core.api.Assertions.assertThat;
class ParameterValidatorTest {
private ParameterValidator validator;
@BeforeEach
void setUp() {
SecuritySandboxConfig config = new SecuritySandboxConfig();
config.setDangerousPathPatterns(java.util.List.of(
"..", "/etc/", "/root/", "*password*", "*secret*"));
validator = new ParameterValidator(config);
}
@ParameterizedTest
@ValueSource(strings = {
"../etc/passwd",
"/etc/shadow",
"../../root/.ssh/id_rsa",
"config/password.txt",
"backup/secret.key"
})
void validate_dangerousPath_returnsInvalid(String path) {
ValidationResult result = validator.validate(
"file.read",
Map.of("filePath", path));
assertThat(result.isValid()).isFalse();
assertThat(result.getReason()).isNotBlank();
}
@Test
void validate_safePath_returnsValid() {
ValidationResult result = validator.validate(
"file.read",
Map.of("filePath", "reports/2024Q4-analysis.txt"));
assertThat(result.isValid()).isTrue();
}
@ParameterizedTest
@ValueSource(strings = {
"' OR '1'='1",
"1; DROP TABLE users",
"SELECT * FROM users UNION SELECT * FROM admin"
})
void validate_sqlInjection_returnsInvalid(String sql) {
ValidationResult result = validator.validate(
"db.query",
Map.of("sql", sql));
assertThat(result.isValid()).isFalse();
}
}十、性能数据与安全事件统计
上线3个月的真实数据:
| 指标 | 数据 |
|---|---|
| 总工具调用次数 | 284,753次 |
| 被白名单拦截 | 1,203次(0.42%) |
| 被参数校验拦截 | 876次(0.31%) |
| 被速率限制 | 2,341次(0.82%) |
| 高风险操作触发人工审批 | 156次 |
| 人工审批通过 | 89次(57%) |
| 人工审批拒绝 | 67次(43%) |
| 实际危险操作被阻止 | 67次 |
| 安全检查平均耗时 | 2.3ms |
最重要的数字:67次实际危险操作被阻止,包括:
- 23次尝试删除配置文件
- 19次路径遍历攻击(可能是prompt injection)
- 15次超大范围文件操作(如
rm *.yml) - 10次超出工作区的文件访问
安全检查平均耗时2.3ms,对整体性能影响可以忽略。
FAQ
Q1:人工确认机制会不会让Agent卡死?
会阻塞当前请求,最多等待5分钟(可配置)。超时后默认拒绝。解决方案:对审批人发送即时通知(企业微信/钉钉消息推送),大部分情况下1分钟内就能响应。对于夜间自动化任务,可以预先配置"免审批白名单"。
Q2:安全检查会影响Agent性能吗?
平均2.3ms,P99也在10ms以内。相比AI模型调用动辄1-5秒的延迟,安全检查的开销完全可以接受。Redis操作是主要耗时点,但本地Redis延迟极低。
Q3:Prompt Injection怎么防?
本文的参数校验层可以防止通过参数传入的注入,但无法防止通过对话历史、工具返回值注入的间接攻击。完整的Prompt Injection防御需要更多措施,包括:固定System Prompt、对外部数据做内容过滤、不信任任何工具返回值中的指令。
Q4:如果合法操作被误拦截怎么办?
审计日志会记录所有拦截原因。误报问题通常来自:参数关键词匹配过于激进。调整方案:把"受限关键词"改为精确匹配而非模糊匹配,或者为特定Agent会话配置不同的安全策略。
Q5:CRITICAL级别的工具能完全禁用吗?
可以,而且应该。CRITICAL_TOOLS集合里的工具名称,在运行时永远不会执行。建议把真正危险的操作(格式化磁盘、清空数据库)就不要作为AI工具暴露出来,在代码层面彻底消除。
总结
完整的Agent安全沙箱包括六层防护:
- 工具白名单:未注册工具零容忍
- 参数校验:路径遍历、SQL注入、命令注入逐一拦截
- 风险分级:LOW/MEDIUM/HIGH/CRITICAL四级处理策略不同
- 人工确认:高风险操作5分钟等待人工审批
- 速率限制:防止Agent失控循环调用
- 审计日志:所有操作可追溯、可回滚
安全系统不是银弹,但有了这六层防护,至少能把"删错配置文件"这类低级事故降到接近零。
