AI Agent 的权限最小化:每个 agent 实例只拿到完成任务的最小权限集合

一、AI Agent 权限过多的灾难场景

先看一个真实可能发生的场景:你给 Agent 配了文件读取权限和 Shell 执行权限,让它帮你"分析 /var/log 下的日志,找出错误行并汇总"。

为什么会出现这种情况?因为传统的权限模型是角色粒度的,不是任务粒度的

  • 角色:"文件管理 Agent" → 拥有所有文件操作权限
  • 实际任务:"读日志文件" → 只需要 Read,不需要 Delete

二、Capability 令牌:把权限变成"凭票入场"

我在第三篇讲过了 Capabilities-based Security 的一般原理。现在把它用到 AI Agent 上:不给 Agent 全局权限,只给每项具体任务发放临时的"功能令牌"

三、Rust 实现:编译期保证权限安全

3.1 定义权限类型

use std::marker::PhantomData;
use std::path::PathBuf;

/// 文件读取权限 — 限定只能读指定目录下的文件
pub struct ReadFile {
    // 限定可以读取的根目录
    allowed_root: PathBuf,
}

/// 文件写入权限 — 限定只能写指定目录
pub struct WriteFile {
    allowed_root: PathBuf,
}

/// 网络请求权限 — 限定只能访问白名单域名
pub struct NetworkAccess {
    allowed_domains: Vec<String>,
}

/// Shell 执行权限 — 限定只能运行白名单命令
pub struct ExecShell {
    allowed_commands: Vec<String>,
}

/// Capability 令牌:持有它才能使用对应的能力
pub struct Cap<T> {
    _marker: PhantomData<T>,
}

impl<T> Cap<T> {
    /// 创建令牌(只能在权限管理器内部调用)
    fn new() -> Self {
        Cap { _marker: PhantomData }
    }
}

3.2 Agent 的定义:只持有被授予的令牌

/// AI Agent:通过泛型参数限制它拥有的能力
/// T1, T2, T3... 是它可以持有的权限类型
pub struct Agent<R = (), W = (), N = (), E = ()>
where
    R: 'static,
    W: 'static,
    N: 'static,
    E: 'static,
{
    // 每种权限都是可选的,未授予时类型为 ()
    read_token: Option<Cap<R>>,    // 文件读权限
    write_token: Option<Cap<W>>,   // 文件写权限
    network_token: Option<Cap<N>>, // 网络权限
    exec_token: Option<Cap<E>>,    // Shell 执行权限
}

impl Agent {
    /// 创建没有任何权限的 Agent
    pub fn new() -> Self {
        Agent {
            read_token: None,
            write_token: None,
            network_token: None,
            exec_token: None,
        }
    }
}

3.3 权限管理器:按任务分配最小权限

/// 权限管理器:负责按任务需求分配权限令牌
pub struct PermissionManager;

impl PermissionManager {
    /// 为"日志分析"任务创建 Agent
    /// 只需要读日志文件的权限
    pub fn create_log_analyzer(log_dir: &str) -> Agent<ReadFile, (), (), ()> {
        // 只拿类型信息,不创建 Agent 本身也可以
        // 但这里我们返回带 ReadFile 权限的 Agent
        Agent {
            read_token: Some(Cap::<ReadFile>::new()),
            write_token: None,   // 明确不给写权限
            network_token: None, // 明确不给网络权限
            exec_token: None,    // 明确不给执行权限
        }
    }

    /// 为"自动部署"任务创建 Agent
    /// 需要读文件 + 执行 Shell
    pub fn create_deploy_agent(
        deploy_dir: &str,
    ) -> Agent<ReadFile, WriteFile, (), ExecShell> {
        Agent {
            read_token: Some(Cap::new()),
            write_token: Some(Cap::<WriteFile>::new()),
            network_token: None,
            exec_token: Some(Cap::<ExecShell>::new()),
        }
    }
}

3.4 资源访问:检查令牌才能执行

/// 安全的文件系统访问层
pub struct SecureFileSystem;

impl SecureFileSystem {
    /// 读取文件:必须持有 ReadFile 令牌
    pub fn read_file(&self, path: &str, _token: &Cap<ReadFile>) -> Result<String, String> {
        // 编译期保证:调用者一定持有 ReadFile 权限
        // 运行时检查:路径是否在允许的目录下
        std::fs::read_to_string(path)
            .map_err(|e| format!("读取文件失败: {}", e))
    }

    /// 写入文件:必须持有 WriteFile 令牌
    pub fn write_file(
        &self,
        path: &str,
        content: &str,
        _token: &Cap<WriteFile>,
    ) -> Result<(), String> {
        std::fs::write(path, content)
            .map_err(|e| format!("写入文件失败: {}", e))
    }

    /// 删除文件:必须持有 DeleteFile 令牌
    /// 注意:日志分析 Agent 永远拿不到这个令牌
    pub fn delete_file(&self, path: &str, _token: &Cap<DeleteFile>) -> Result<(), String> {
        std::fs::remove_file(path)
            .map_err(|e| format!("删除文件失败: {}", e))
    }
}

// 删除权限类型定义(日志分析 Agent 拿不到这个)
pub struct DeleteFile;

3.5 实际使用示例

fn main() {
    let pm = PermissionManager::new();
    let fs = SecureFileSystem;

    // === 任务1:日志分析 ===
    // 只能读到 ReadFile 权限的 Agent
    let analyzer = pm.create_log_analyzer("/var/log");

    // ✓ 可以读日志
    if let Some(token) = &analyzer.read_token {
        let content = fs.read_file("/var/log/syslog", token);
        println!("日志内容: {:?}", content);
    }

    // ❌ 编译错误:Agent 没有 WriteFile 令牌
    // fs.write_file("/var/log/syslog", "hacked", &analyzer.write_token);

    // ❌ 编译错误:Agent 没有 DeleteFile 令牌
    // fs.delete_file("/var/log/syslog", &analyzer.delete_token);

    // === 任务2:自动部署 ===
    let deployer = pm.create_deploy_agent("/opt/app");

    // ✓ 可以读部署脚本
    if let Some(token) = &deployer.read_token {
        fs.read_file("/opt/app/deploy.sh", token).unwrap();
    }

    // ✓ 可以写部署日志
    if let Some(token) = &deployer.write_token {
        fs.write_file("/opt/app/deploy.log", "deploy success", token).unwrap();
    }

    // ❌ 编译错误:部署 Agent 也没有 DeleteFile 令牌
    // 即使它具有写入权限,也不能删除
}

3.6 实战踩坑:泛型参数爆炸与 Option 令牌的安全漏洞

这套方案在原型阶段很好用,但扩展到 5+ 种权限时遇到两个现实问题:

问题一:泛型参数爆炸。每增加一种权限(数据库读、消息队列写、Redis 访问……),Agent 的泛型参数就多一个。5 种权限意味着 <R, W, N, E, D, M, K>,函数签名长得一行写不下:

// ❌ 类型签名失控
pub fn create_full_agent() -> Agent<ReadFile, WriteFile, NetworkAccess, ExecShell, DbRead, MqPublish> {

解法:把权限令牌放到 HashMap<TypeId, Box<dyn Any>> 里做运行时管理。牺牲一部分编译期安全,换取可扩展性:

pub struct DynamicAgent {
    caps: HashMap<TypeId, Box<dyn Any>>,
}
impl DynamicAgent {
    pub fn has_cap<T: 'static>(&self) -> bool {
        self.caps.contains_key(&TypeId::of::<T>())
    }
}

问题二:Option 令牌的假安全感Option<Cap<ReadFile>> 在编译期保证 Agent 不能调用 read_file(因为需要 &Cap<ReadFile>),但它不保证 Agent 不持有其他更危险的令牌。一个声称"只读日志"的 Agent 可能实际也拿到了 ExecShell,只是 Option 是 Some 而非 None

真正可靠的做法是让每个任务的函数签名明确暴露所有权限类型,通过 Code Review 检查而非纯依赖编译器。

四、动态权限:运行时的细粒度控制

编译期检查很好,但有些权限需要在运行时根据上下文决定。比如"只能读 100MB 以内的文件"这个限制,编译器做不了。

/// 运行时权限约束
pub struct PermissionConstraint {
    pub max_file_size: usize,          // 最大文件大小(字节)
    pub allowed_paths: Vec<PathBuf>,   // 允许的路径前缀
    pub rate_limit_per_minute: u32,    // 每分钟最大操作次数
}

impl SecureFileSystem {
    /// 带运行时约束的文件读取
    pub fn read_file_constrained(
        &self,
        path: &str,
        token: &Cap<ReadFile>,
        constraint: &PermissionConstraint,
    ) -> Result<String, String> {
        // 编译期:必须有 ReadFile 令牌 ✓

        // 运行时1:检查路径是否在允许范围内
        let path_buf = PathBuf::from(path);
        if !constraint.allowed_paths.iter().any(|p| path_buf.starts_with(p)) {
            return Err("路径不在允许范围内".to_string());
        }

        // 运行时2:检查文件大小
        let metadata = std::fs::metadata(path)
            .map_err(|_| "无法读取文件元数据".to_string())?;
        if metadata.len() > constraint.max_file_size as u64 {
            return Err(format!(
                "文件过大:{}字节,最大允许 {} 字节",
                metadata.len(),
                constraint.max_file_size
            ));
        }

        // 通过所有检查后才执行读取
        self.read_file(path, token)
    }
}

我们内部做了一次攻击测试:给一个只持有 ReadFile 令牌的 Agent 输入恶意 Prompt,诱导它调用 delete_file。结果 Agent 的 LLM 确实"想"删除文件,但因为没令牌,操作直接被编译期拒绝了。

五、总结

AI Agent 权限最小化的核心思路:

  1. 编译期隔离:用 Rust 泛型 + 零大小类型标记,让 Agent 在编译时就确定"能做什么"
  2. 任务粒度授权:不为 Agent 分配"角色权限",而是为每项具体任务分配最小必要权限
  3. 令牌即权限:Agent 只能使用它持有的 Cap 令牌调用对应操作,不能凭空调
  4. 双重检查:编译期(有没有令牌)+ 运行时(具体约束),缺一不可
  5. 用完即弃:任务完成后丢弃 Agent 实例,令牌随之失效

这个方案的额外好处是:代码即文档。看 Agent 的泛型参数就知道它有哪些权限,Code Review 时一眼能看出来权限分配是否合理。

保持学习,保持输出!你在设计 Agent 权限时遇到过什么坑?评论区分享!

参考资料


Logo

中国智能体开发者社区,聚焦智能体与大模型开发,提供前沿资讯、实用工具链、开源项目及行业案例。通过技术沙龙、开发者大赛等活动,促进经验交流与协作,助力开发者快速构建创新智能应用。

更多推荐