通过在 Kubernetes User-Agent、AWS SourceIdentity 和 Git Commit Trailer 中注入 Run ID,将 AI 操作与授权人、决策证据完整绑定,解决事后追溯断链问题。
RBAC 和链路追踪回答不了的问题
DevOps AI 智能体的审计日志,是一条记录——对于智能体所做的任何更改,它能从目标系统自身的日志中回答四个问题:哪个智能体、哪次运行、哪个人授权、以及模型依据了什么证据。实现方式很简单:把一个 run ID 传播到智能体已经触达的三个地方——Kubernetes User-Agent 请求头、AWS STS SourceIdentity、以及 git commit trailers——并为每次运行写入一条只增不减的账本记录,将它们绑定在一起。
说清为什么这很重要。你已经做了该做的事:智能体以最小权限 ServiceAccount 运行、每次工具调用都通过 OpenTelemetry 链路追踪、写操作经过审批门禁。然而审计员——或你自己的故障复盘——会问:"14号 02:13,payments 命名空间里一个 Deployment 被缩容到零。谁批准的?" Kubernetes 审计日志写着 system:serviceaccount:agents:ops-agent-write。你的 OTel 链路追踪说智能体提议了缩容、有人点了批准。两边互不关联。审计日志里没有 run ID;链路追踪里也没有证据证明是这个 API 调用导致的。你只能靠时间戳来关联——这类答案恰恰是 SOC 2 CC7 控制审查不合格的原因。
解决方案不是加更多日志,而是把同一个标识符写入写路径上的所有系统,这样关联就变成字符串匹配,而不是靠猜。
每次智能体调用在开始——模型看到 prompt 之前——就会获得一个 ULID。之后所有下游都携带它:
# run_context.py — one ID to bind them all
import hashlib
import os
from ulid import ULID
from opentelemetry import trace
class RunContext:
def __init__(self, agent: str, trigger: str, approver: str | None = None):
self.run_id = str(ULID()) # e.g. 01J9C4V0X3K9Y2R7QH8P6TN5MB
self.agent = agent # "ops-agent"
self.version = os.environ["AGENT_VERSION"]
self.trigger = trigger # "alert:PaymentsHighErrorRate"
self.approver = approver # filled in by the approval gate
self.prompt_sha = None
def record_prompt(self, system_prompt: str) -> None:
self.prompt_sha = hashlib.sha256(system_prompt.encode()).hexdigest()[:16]
def user_agent(self) -> str:
# Kubernetes stores this verbatim in audit events.
return f"{self.agent}/{self.version} run={self.run_id} approver={self.approver or 'none'}"
def tag_span(self) -> None:
span = trace.get_current_span()
span.set_attribute("agent.run_id", self.run_id)
span.set_attribute("agent.approver", self.approver or "")
为什么要单独一个 ID,而不是复用 OTel 的链路追踪 ID?因为追踪 ID 是 32 位十六进制字符,没人在凌晨三点把它输入 Athena 查询;也因为链路追踪通常只保留 7 到 30 天,而审计日志按年计算。run ID 会作为属性附加到 span 上,所以通过链路追踪搜索仍然能找到它;下面的账本才是比链路追踪存在更久的东西。
Kubernetes 审计日志在每个事件中记录 User-Agent 请求头,而且设置它不需要额外的 RBAC 权限。这让它成为你拥有的最便宜的归属通道。用 Python 客户端:
from kubernetes import client, config
def k8s_client(ctx: RunContext) -> client.ApiClient:
config.load_incluster_config()
api = client.ApiClient()
api.user_agent = ctx.user_agent() # sets the default User-Agent header
return api
apps = client.AppsV1Api(k8s_client(ctx))
apps.patch_namespaced_deployment_scale(
name="checkout", namespace="payments",
body={"spec": {"replicas": 0}},
)
生成的审计事件携带了所有你需要的信息:
{
"kind": "Event",
"level": "RequestResponse",
"verb": "patch",
"user": {"username": "system:serviceaccount:agents:ops-agent-write"},
"userAgent": "ops-agent/1.4.2 run=01J9C4V0X3K9Y2R7QH8P6TN5MB approver=alice@example.com",
"objectRef": {"resource": "deployments", "subresource": "scale",
"namespace": "payments", "name": "checkout"},
"responseStatus": {"code": 200},
"requestReceivedTimestamp": "2026-09-14T02:13:41.220Z"
}
现在审计员的问题只需一行:
# Every API-server write made by run 01J9C4V0X3K9Y2R7QH8P6TN5MB
jq -c 'select(.userAgent | test("run=01J9C4V0X3K9Y2R7QH8P6TN5MB"))
| select(.verb | IN("create","update","patch","delete"))
| {t: .requestReceivedTimestamp, verb, ns: .objectRef.namespace,
res: .objectRef.resource, name: .objectRef.name, code: .responseStatus.code}' \
/var/log/kubernetes/audit.log
确保你的审计策略在 RequestResponse 级别捕获写动词的智能体 SA。RBAC 那篇文章里的策略 stanza 已经做到了;唯一新增的是 userAgent 在每个级别都存在,包括 Metadata,所以即使是简陋的策略也能保留归属信息。
有两个值得坦诚说明的注意事项。首先,User-Agent 是由客户端设置的,所以它是归属手段,不是认证手段。认证靠的是 ServiceAccount 令牌;请求头只是告诉你哪次运行使用了它。被入侵的智能体进程可以在请求头里撒谎,但它不能变成另一个 SA,这就是两层互补的原因。其次,kubectl 没有自定义 user agent 的标志位。如果你的智能体通过 shell 调用 kubectl,这个通道就失效了;这又是一个让智能体通过库或设置好请求头的 MCP server 调用 API 的理由,也是网关代智能体 stamp 请求头的原因。
如果你还需要 API 服务器把人类作为一级身份来记录,则由 harness(绝不是智能体)来扮演:发送 Impersonate-User: ops-agent-write 加上 Impersonate-Extra-approver: alice@example.com,审计事件就会多出一个 impersonatedUser 块及额外字段。这需要 harness 的凭证上有 impersonate 动词权限,这是真正的特权——要用 resourceNames 限制它只能变成那一个 SA,并且不要把它放进智能体自己的 Role 里。
CloudTrail 有两个字段是专为这个设计的,而且都支持跨角色链。智能体承担角色时,把 RoleSessionName 设为 run ID,把 SourceIdentity 设为审批人:
import boto3
def aws_session(ctx: RunContext) -> boto3.Session:
sts = boto3.client("sts")
creds = sts.assume_role(
RoleArn="arn:aws:iam::123456789012:role/ops-agent-write",
RoleSessionName=ctx.run_id, # max 64 chars; ULID is 26
SourceIdentity=ctx.approver or "unapproved", # immutable for the session
DurationSeconds=900,
)["Credentials"]
return boto3.Session(
aws_access_key_id=creds["AccessKeyId"],
aws_secret_access_key=creds["SecretAccessKey"],
aws_session_token=creds["SessionToken"],
)
SourceIdentity 是关键。一旦在会话上设置就不能更改,它会被复制到每一个从它链出的会话中,CloudTrail 会把它写入该会话所有事件的 userIdentity.sessionContext.sourceIdentity。角色的信任策略必须允许它,你也可以用同一个策略拒绝没有审批人的会话:
{
"Version": "2012-10-17",
"Statement": [{
"Effect": "Allow",
"Principal": {"AWS": "arn:aws:iam::123456789012:role/agent-harness"},
"Action": ["sts:AssumeRole", "sts:SetSourceIdentity"],
"Condition": {
"StringLike": {"sts:SourceIdentity": "*@example.com"}
}
}]
}
这个条件把归属变成了强制:写角色在没有附带人类身份的情况下根本无法被承担。配合审批门禁使用,这样审批人的邮箱只在真正审批后才被填充,而"unapproved"会话在 STS 层就被 AccessDenied 挡掉,根本碰不到任何资源。
用 Athena 一行 SQL 查回来(针对你的 CloudTrail 表):
SELECT eventtime, eventname, awsregion,
useridentity.arn AS session_arn,
useridentity.sessioncontext.sourceidentity AS approver,
json_extract_scalar(requestparameters, '$.instanceId') AS instance
FROM cloudtrail_logs
WHERE useridentity.arn LIKE '%/ops-agent-write/01J9C4V0X3K9Y2R7QH8P6TN5MB'
AND readonly = 'false'
ORDER BY eventtime;
会话 ARN 里嵌入了 run ID,因为这就是你放进 RoleSessionName 的东西,所以对 ARN 后缀做 LIKE 就能找到该次运行的所有变更调用。同样的模式也适用于 AWS 垃圾回收智能体:每次 EBS 删除现在都能追溯到某次运行和审批人,完全不需要应用侧日志。
如果你的智能体按应有的方式改变基础设施——通过打开 PR,而不是直接运行 kubectl——那 git 就是第四个审计系统,而且它本身就有结构化元数据的约定:commit trailers。
git -c trailer.ifexists=addIfDifferent commit -q -F - <<EOF
chore(payments): scale checkout to 0 during incident INC-2291
Agent-Run: 01J9C4V0X3K9Y2R7QH8P6TN5MB
Agent: ops-agent/1.4.2
Approved-By: alice@example.com
Prompt-SHA: 3f9a1c2e8b7d4a05
Evidence: https://grafana.example.com/explore?run=01J9C4V0X3K9Y2R7QH8P6TN5MB
EOF
Trailers 可以被 git interpret-trailers --parse 解析,也可以用 git log --grep='Agent-Run: 01J9C4' 搜索。使其成为审计控制而非单纯约定,靠的是对智能体 PR 的一个强制检查——当 trailer 缺失时直接失败:
# .github/workflows/agent-commit-policy.yml
name: agent-commit-policy
on: { pull_request: { branches: [main] } }
jobs:
trailers:
if: github.event.pull_request.user.login == 'ops-agent[bot]'
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with: { fetch-depth: 0 }
- name: Require attribution trailers on every agent commit
run: |
set -e
for sha in $(git rev-list origin/main..HEAD); do
body=$(git log -1 --format=%B "$sha")
for key in Agent-Run Approved-By Prompt-SHA; do
echo "$body" | grep -Eq "^$key: \S+" \
|| { echo "::error::$sha missing $key trailer"; exit 1; }
done
echo "$body" | grep -Eq '^Approved-By: [^ ]+@example\.com$' \
|| { echo "::error::$sha Approved-By is not a human identity"; exit 1; }
done
因为检查只在 bot 的 PR 上运行,而 main 受保护,没有人类审批人的智能体提交无法合并。Argo CD 随后部署的提交,其元数据已经标明了运行 ID 和审批人;同步对应的 Kubernetes 审计事件归属给 Argo,但 git trailer 闭合了整条链路。
链路追踪会过期,审计日志会轮转,批准某次变更的人会离职。账本是每次运行一条 JSON 记录,在运行结束时写入,且存储介质不可修改:
{
"run_id": "01J9C4V0X3K9Y2R7QH8P6TN5MB",
"agent": "ops-agent", "version": "1.4.2", "model": "claude-sonnet-5",
"trigger": "alert:PaymentsHighErrorRate",
"started": "2026-09-14T02:11:03Z", "ended": "2026-09-14T02:14:20Z",
"prompt_sha": "3f9a1c2e8b7d4a05",
"approver": "alice@example.com",
"approval_ref": "slack:C0AGENTS/p1757815999123456",
"tool_calls": [
{"tool": "prom_range_query", "args_sha": "9c0e…", "ok": true},
{"tool": "k8s_scale_deployment", "args": {"ns": "payments", "name": "checkout", "replicas": 0}, "ok": true}
],
"writes": {"kubernetes": 1, "aws": 0, "git": 1},
"transcript_ref": "s3://agent-transcripts/2026/09/14/01J9C4V0X3K9Y2R7QH8P6TN5MB.jsonl",
"trace_id": "4bf92f3577b34da6a3ce929d0e0e4736"
}
把它存在开启了 Object Lock 合规模式的 S3 桶里,保留期与你的审计策略匹配;没有人,包括 root,能缩短它。只读工具调用按参数哈希而非原始参数记录——既是为了保持记录精简,也是因为查询文本可能包含那篇Secrets 文章里说的绝不能持久化的东西。写调用保留完整参数,因为审计员问的就是这些。
如果你的智能体在 MCP 网关后面,网关就是写这条记录的最佳位置:它已经看到了每个智能体的每次工具调用,而且可以拒绝任何请求头中缺少 run ID 的调用。这把"每次运行都在账本里"从约定变成了不变式。
四个通道都填充好之后,02:13 缩容到零的问题就这样定位:
Kubernetes 审计日志——用 jq 对 userAgent 过滤,返回 payments 命名空间里 deployments/scale 上的一次 patch,运行 ID 01J9C4V0X3K9Y2R7QH8P6TN5MB,审批人 alice@example.com。
账本——该运行的记录显示了触发告警、审批 Slack 永久链接、prompt 哈希、以及保存了模型推理过程的 transcript 指针。
Git 和 CloudTrail——用同一个 ID 搜索,它们要么显示匹配的 PR 且没有 AWS 写操作,要么显示账本没有记录的东西——而这才是你真正想要发现的异常。
第三种情况才是真正的收益。一旦每个系统都携带了 run ID,一个夜间任务就能对比账本声明的写操作和审计日志实际记录的该次运行的写操作。账本中没有匹配记录的 Kubernetes 写操作,或者 CloudTrail 事件中的会话名称在账本里查不到——就是智能体在 harness 之外行动的信号。这才是整个方案要构建的告警。
归属不等于授权。User-Agent 和 trailers 是自报告的。真正阻止未授权行为的是 RBAC、STS 信任条件、和分支保护;这些标识符只是让被阻止的和被允许的操作都可解释。
账本记录的是 harness 看到的内容。如果某个工具运行了一个子进程,子进程用自己的凭证调用了另一个 API,这条路径就不可见,除非你也把 run ID 传递过去。审计的是工具实现,不只是智能体。
模型推理不是证据。transcript 显示的是模型说了什么;审计日志显示的是实际发生了什么。两个都保留,但相信第二个。
保留有成本。完整 transcript 加上 RequestResponse 审计级别会累积费用。只读操作哈希化,保留写操作,并把 Object Lock 周期设为你的合规要求而不是"永久"。
这一切都不稀奇。它应用的纪律和你对一个拥有共享紧急权限的人类操作员是一样的,只是这次是一个自己生成命令的进程。智能体只有在每次调用都有人类的名字随行时才能行动,而记住这一切的是目标系统,不是智能体。