Tiana随笔

Agent 有工牌了

· Agent 实践

标签:#Agent #安全 #大模型

Agent 有工牌了

10 月 8 日 Google Cloud 给 agent 发了工牌:有自己的邮箱、日历和目录身份,每个动作记在它自己名下。我看完回头数了一遍自己博客上的自动化脚本,发现它们全在共用一把管理员钥匙。这篇写我照着那套分工给三个脚本各发一个身份、按资源切权限、把动作全记下来,以及哪些我抄不动也不想抄。

Agent 有工牌了

10 月 8 日 Google Cloud 有场发布会,我第二天才刷到。Thomas Kurian 那篇博客其实不长,我看得挺慢,有一段来回看了两遍。

大意是,他们的 agent 现在有自己的 Workspace 账号、自己的邮箱、自己的日历和云盘,在公司通讯录里是一条正式条目。同事可以像 @ 一个人那样 @ 它,邮箱格式是 <名字>@agents.company.com。

我第一下有点膈应。给 agent 发工牌这种说法,听着像在往"人"的方向包装一段程序。这两年拟人化的说法看多了,多半是营销。

但那段我看了两遍,是因为它底下讲的事跟包装没关系,全是管它。这块我自己一直没做明白。

三件事

身份、审计、花钱。

身份这块,每个 agent 拿到的是一个密码学签名背书的身份,不是一串大家共用的密钥。它有名字,权限按最小化给,还要安全管理员审批。

我看到"不是共用密钥"这几个字的时候停了一下。这个反面教材的具体长相是,我那个定时备份的路由,用的也是同一把 BLOG_ADMIN_TOKEN。如果哪天我把密钥换掉,忘了同步改服务器上的定时任务,多半要等到第二天才发现前一晚的备份没跑成。麻烦不在于我忘没忘改,而在于密钥一换,所有用它的地方同时失效,日志里还看不出是哪一处没跟上。

审计那块最戳我。它做的每个动作都写进日志,而且记在它自己名下,不记在它主人名下。人干的事和 agent 干的事是两条线,能分开查。

看着简单,我一直没做到。定时脚本跑一遍,日志里记的是我的名字还是脚本的名字?多数时候是一团。真出事了,我分不清那次删除是我手滑,还是脚本跑飞。

第三块是花钱。每个项目能设一个硬上限,跑到了,项目下的 agent 直接停,等人在控制台点一下恢复。顺带还有个按任务自动挑模型的路由。

跟着这条有个数字我觉得挺有意思。token 单价从 2024 年到现在掉了 98%,可企业那边的用量涨得比价格掉得更快,总账单反而更难管了。我以前一直默认便宜就等于少花,看来不是。

Google 还举了个客户例子,新加坡的 DBS 银行,拿 70 到 80 个专门 agent 串成链,做企业信贷备忘录。

看到这个数字,我低头数了下自己的。三个脚本。

我那三个脚本

先把话讲清楚,我写这些不是因为我准备去用 Gemini Enterprise。我的博客跑在一台腾讯云的轻量服务器上,数据是 backend/data/ 底下几个 JSON 文件,连数据库都没有,52 篇文章,两百多条评论。前面那些东西离我远得很。

但有一件事离我很近。

这个博客上的自动化我写过好几拨了。定时备份,生成 sitemap,还有之前把后台包成的那个 MCP 服务。每写一个,我都要回答同一个问题,这段程序是拿谁的身份去动数据。

我现在的答案是给它一份 BLOG_ADMIN_TOKEN。等于把管理员钥匙复制一把塞过去。它能做的和我在后台能做的完全一样,包括删文章。想收回,我就得换钥匙,换完我自己正开着的会话也得跟着重新登一次。

这套能跑到现在,纯粹因为我一个人写,脚本就三个,还都跑在自己机器上。哪天我想让一个 agent 帮我看评论、挑垃圾、顺手写两行摘要,它当场就塌。因为它只有"是管理员"和"不是管理员"两个档,没有"看评论"这一档。

照着抄一版

Google 的实现我抄不动,但它怎么分工能抄。三件,身份、权限、审计。

身份这里,关键是不能让它变成一串密钥,得把它做成一条记录。密钥只回答"对不对",回答不了"你是谁、能干嘛"。做成记录,后面两件才有地方挂。

# backend/app/core/agent_identity.py
"""给自动化客户端发工牌:身份、权限、是否可撤销。"""

from dataclasses import dataclass


@dataclass(frozen=True)
class AgentCredential:
    agent_id: str          # 形如 agent-comment-digest
    label: str             # 人类可读,会出现在审计日志里
    scopes: frozenset      # 只给需要的那几项
    created_at: str        # ISO 时间,方便日后追
    revoked: bool = False

    def allows(self, scope: str) -> bool:
        if self.revoked:
            return False
        return scope in self.scopes

三个客户端,三种权限,互相不越界。

# backend/app/services/agent_registry.py
"""每个自动化客户端一条记录,权限按「资源:动作」切。"""

from ..core.agent_identity import AgentCredential

REGISTRY = {
    "agent-backup": AgentCredential(
        agent_id="agent-backup",
        label="每日备份任务",
        scopes=frozenset({"posts:read", "backup:write"}),
        created_at="2026-10-13T01:00:00+00:00",
    ),
    "agent-sitemap": AgentCredential(
        agent_id="agent-sitemap",
        label="sitemap 生成器",
        scopes=frozenset({"posts:read", "seo:write"}),
        created_at="2026-10-13T01:00:00+00:00",
    ),
    "agent-comment-digest": AgentCredential(
        agent_id="agent-comment-digest",
        label="评论摘要(待接入)",
        scopes=frozenset({"comments:read"}),
        created_at="2026-10-13T01:00:00+00:00",
    ),
}


def get(agent_id: str):
    return REGISTRY.get(agent_id)

审计这块我一开始想省,觉得三个脚本不值得这么讲究。后来发现省了它,整套东西就只剩个摆设。

# backend/app/services/agent_audit.py
"""动作级审计:成功的和被拒的都写。"""

import json
from datetime import datetime, timezone
from pathlib import Path

AUDIT_PATH = Path("backend/data/agent_audit.json")


def record(agent_id: str, action: str, target: str, allowed: bool, detail: str = "") -> None:
    rows = json.loads(AUDIT_PATH.read_text(encoding="utf-8")) if AUDIT_PATH.exists() else []
    rows.append({
        "at": datetime.now(timezone.utc).isoformat(timespec="seconds"),
        "agent": agent_id,
        "action": action,
        "target": target,
        "allowed": allowed,
        "detail": detail,
    })
    AUDIT_PATH.write_text(json.dumps(rows, ensure_ascii=False, indent=1), encoding="utf-8")

这里我自己还加了一条规矩,审计文件不给 agent 写权限。它是被审计的那一方,不能碰自己的记录。

最后缝成一个守门函数,所有自动化入口都从它这过。

# backend/app/services/agent_gate.py
"""所有自动化入口从这里过:先验身份再执行。"""

from ..core.agent_identity import AgentCredential
from .agent_audit import record


def envelope(data=None, message="", ok=True):
    return {"ok": ok, "data": data, "message": message}


def guarded(cred: AgentCredential, scope: str, target: str, fn):
    if cred.revoked:
        record(cred.agent_id, scope, target, False, "credential revoked")
        return envelope(None, "credential revoked", ok=False)
    if not cred.allows(scope):
        record(cred.agent_id, scope, target, False, "scope missing")
        return envelope(None, "forbidden", ok=False)
    data = fn()
    record(cred.agent_id, scope, target, True)
    return envelope(data, "")

接口还是老规矩,POST 加 envelope。

# backend/app/api/routes/agent.py
from fastapi import APIRouter

from ...services.agent_registry import get
from ...services.agent_gate import guarded, envelope

router = APIRouter(prefix="/api/agent")


@router.post("/comments/digest")
def comments_digest(payload: dict):
    cred = get(payload.get("agent_id", ""))
    if cred is None:
        return envelope(None, "unknown agent", ok=False)
    return guarded(cred, "comments:read", "comments", _recent_comments)

我把三个身份各调了一遍,又把其中一个撤销后再调一次。跑出来的东西是这样。

== 三个身份各自调用的结果 ==
ALLOW  agent-backup           posts:read     -> ok
DENY   agent-backup           comments:read  -> forbidden
ALLOW  agent-sitemap          posts:read     -> ok
ALLOW  agent-comment-digest   comments:read  -> ok
DENY   agent-comment-digest   posts:write    -> forbidden

== 撤销 agent-comment-digest 之后再调 ==
ok=False  message=credential revoked

== 审计日志(最后 6 条)==
2026-10-13T00:42:49+00:00  agent-backup           posts:read     allowed=False scope missing
2026-10-13T00:42:49+00:00  agent-sitemap          posts:read     allowed=True
2026-10-13T00:42:49+00:00  agent-comment-digest   comments:read  allowed=True
2026-10-13T00:42:49+00:00  agent-comment-digest   posts:write    allowed=False scope missing
2026-10-13T00:42:49+00:00  agent-comment-digest   comments:read  allowed=False credential revoked

前三行正常放行,中间两行被挡,最后一行是撤销之后再调,照样进不去。审计里被拒的理由写得挺清楚,scope missing 是权限不够,credential revoked 是身份没了。这两件事在排查的时候完全是两个方向,混一起能看半天。

权限的名字我改了三回

第一版用的是 read 和 write。太粗。一个 agent 只想读评论,就得连读别的东西的权限一起拿。

第二版我按资源分,posts、comments、stats。好一点,但读和写还是搅在一起。备份脚本要读文章,摘要脚本也要读文章,可备份脚本还得能写备份,摘要脚本不能。这么分,早晚出事。

第三版才定成现在这样,资源:动作,posts:read、backup:write、comments:read。多敲几个字符,换来的是每个身份能干嘛一眼扫完。

能抄和抄不动的

独立身份最好抄,成本也最低,说白了就是一条记录。最小权限要花心思,颗粒度得自己摸,我为它返工了两次。审计归属我认为最该先做,它是唯一能在事后告诉你"到底谁干的"的东西。

抄不动的摆一排。密码学签名的身份要有密钥基础设施,我这三个脚本不值当为它搭一套。实时的花费上限也不行,我按量计费,没有"项目"这个维度,只能事后翻日志。网络层按策略精细放行这套,我在 nginx 里最多做到只放行 GET、POST、HEAD 这种程度。

有人可能要问,既然身份能做成记录,为什么不干脆上 OAuth。我想过。我这边的自动化压根不跟外部系统打交道,没有第三方要授权,也没有哪个资源所有者要点头,OAuth 那一整套流程搬过来是空转。它解决的是跨系统授权的问题,而我这里没有跨系统这个前提。

它那套东西做得那么厚,我大概能理解。它要应付的是"agent 数量乘系统数量",两个数都很大,不厚管不住。我这个乘法里两个因子都是个位数,硬抄那些机械结构只会给自己找罪受。该学的是它怎么分工,不是它那个体量。

还有件我没想明白的

Google 那篇里有一句我第一遍扫过去了,回头想起来才翻去看。说的是 coworker agent 要走公司通讯录那一套,包括离职流程。

一句话,意思不轻。人离职要交工牌、停邮箱、把手头的活交出去。agent 不用了怎么办。它的账号、它攒下的记忆、它办到一半没办完的任务,谁收尾。

我这边也有同一个尾巴。自动化的 agent_id 是写死在注册表里的常量,撤销就是翻一个布尔值,看着够用。但要是哪天脚本多了,或者我把它接到一个自己会攒状态的外部服务上,"撤销"就不只是从表里划掉那么简单。它在别处留下的东西,我这套管不着。

再往下想一层,我甚至不确定该不该把 agent 真接进来。现在这三个脚本,最坏也就是删错一篇文章,我还能从 JSON 里捞回来。换成 agent,它会看、会判断、会顺着上下文往下走,出错的方式和出错的速度都不是一个量级。

就写到这

我这套东西拦不住一个真想干坏事的 agent。就三个脚本,最硬的一道防线还是 nginx 加一条白名单。

我要的其实很简单。半年后翻日志,能认出那次误删是脚本跑飞了,还是我自己手快。这件事现在算是做到了。

相关文章