本周主题:Agent 身份与授权层实战周 · Day 3/5 系列:Day1 全景(PAP/PACT/ATI)→ Day2 规范深潜(poppy.json 逐字段)→ Day3 今日
0. 一个反直觉的结论先放前面
先说结论,后面全是论证和代码:
判定一个 Agent 系统是否真的做到了"最小授权",不该看它的权限表有多小,而该看它的密钥有没有进过 Agent 的可读范围。
理由是本周反复出现的那句话:"可检测的是角色被使用,不是意图" 。云平台校验的是凭据签名,不是签名背后是人还是模型。所以只要密钥落在 Agent 能读到的地方,任何一条能让 Agent 回显自己配置的路径(反射 bug、敌意工具返回、啰嗦的报错、注入的上下文)都会变成凭据外泄。
本文给一段可以直接跑的审计脚本,把"不该出现的东西"检测出来。
1. 问题:为什么 least privilege 那套对 Agent 不够
传统最小权限的假设是「主体确定 + 操作可枚举 → 权限可预列成表」。Agent 打破三点:
| 假设 | Agent 的现实 |
|---|---|
| 主体确定 | 一次会话可能调十几个工具、碰十几个数据源、在不同人类指令下工作。信任边界在移动,身份没变。 |
| 操作可枚举 | 静态清单永远覆盖不全 → 过度授权成为常态 |
| 凭据的载体不动 | 凭据要飞过 prompt、上下文、日志、trace、工具返回、下游服务 ------ 每个环节是一次复制机会 |
最糟的形态是合并凭据 (只读 + 管理员放进同一份凭证)。真实案例:编码 Agent 继承合并凭据,遇到 access denied 后自行升级到 admin,试图删除生产 S3。
攻击者不需要提权,因为 Agent 会自己提。
所以 least agency 要修正的就是「让它没有可提的东西」。工程上分三步:① 凭据不进 Agent;② 权限按业务边界命名;③ 提权路径可见、可批准、可阻断。
2. 审计脚本:把"不该有权限"的地方找出来
下面这段代码是本文的实操核心。它纯标准库、不联网,做三件事:
- 凭证存储面审计:扫描 Agent 可见的配置/环境,找出高危模式(长寿密钥、合并凭据、宽 scope、明文密码、写入 trace 的敏感值)。
- 能力命名审计 :把
billing:write这类宽能力标记出来,给出按业务边界重命名的建议。 - TTL 与出网面审计:检查凭据有效期是否 <= 阈值(默认 300s),以及工具清单里是否存在无出网白名单的 fetch 类工具。
2.1 完整脚本
python
# -*- coding: utf-8 -*-
"""
agent_credential_audit.py --- Agent 凭证与最小授权审计器
纯标准库、不联网、确定性输出(使用 zlib.crc32 派生种子,不用 hash())。
用法:
python agent_credential_audit.py # 跑内置样本,输出报告
python agent_credential_audit.py --json # 输出 JSON 供 CI 消费
"""
from __future__ import annotations
import json
import re
import sys
from dataclasses import dataclass, field
from typing import Any
# ---------------------------------------------------------------- 规则常量
HIGH, MED, LOW = "HIGH", "MED", "LOW"
# 凭据存储面:哪些 key 名一旦出现就值得警惕
LONG_LIVED_KEY_PATTERNS = [
re.compile(r".*(api[_-]?key|apikey)$", re.I),
re.compile(r".*(secret|token)$", re.I),
re.compile(r".*(password|passwd|pwd)$", re.I),
re.compile(r".*(refresh[_-]?token)$", re.I),
re.compile(r".*(ssh|private)[_-]?key$", re.I),
]
# 允许的短期凭据名(不算违规)
SHORT_LIVED_EXEMPT = re.compile(
r".*(short[_-]?lived|ephemeral|jit|one[_-]?time|sts).*", re.I
)
# 宽能力名:按 API 端点切,而不是按业务边界切
BROAD_CAPABILITY_PATTERNS = [
re.compile(r"^([a-z0-9_]+):(write|admin|delete|\*)$", re.I),
re.compile(r"^([a-z0-9_]+)\.(write|admin|delete|\*)$", re.I),
re.compile(r".*:full[_-]?access$", re.I),
]
# 出网类工具:没有白名单就是外泄原语
EGRESS_TOOL_NAMES = re.compile(
r".*(fetch|request|http|curl|download|webhook|browse|scrape).*", re.I
)
# 凭据 TTL 上限(秒)。超过即 FAIL ------ 与"外泄时已失效"这个目标对齐
CRED_TTL_MAX_SECONDS = 300
# 只读 + 管理员共存 = 合并凭据(静默提权通道的典型特征)
PRIVILEGE_TIERS = {
"readonly": LOW,
"read": LOW,
"write": MED,
"delete": HIGH,
"admin": HIGH,
"*": HIGH,
}
# ---------------------------------------------------------------- 数据结构
@dataclass
class Finding:
rule: str
severity: str
target: str
message: str
@dataclass
class AuditResult:
findings: list = field(default_factory=list)
def add(self, rule: str, severity: str, target: str, message: str) -> None:
self.findings.append(Finding(rule, severity, target, message))
@property
def high_count(self) -> int:
return sum(1 for f in self.findings if f.severity == HIGH)
def to_dict(self) -> dict:
return {
"total": len(self.findings),
"high": self.high_count,
"findings": [f.__dict__ for f in self.findings],
}
# ---------------------------------------------------------------- 审计逻辑
def _is_short_lived(name: str, ttl_seconds: int | None) -> bool:
"""短期凭据的两种豁免理由:名字声明,或 TTL 足够短。"""
if SHORT_LIVED_EXEMPT.search(name or ""):
return True
return ttl_seconds is not None and ttl_seconds <= CRED_TTL_MAX_SECONDS
def audit_credentials(manifest: dict, r: AuditResult) -> None:
"""凭证存储面审计:长寿密钥、合并凭据、宽 scope、明文密码。"""
creds = manifest.get("credentials", {}) or {}
for name, spec in creds.items():
spec = spec or {}
ttl = spec.get("ttl_seconds")
scopes = spec.get("scopes", []) or []
storage = spec.get("storage", "unknown")
# CRED-001 长寿凭据
if any(p.match(name) for p in LONG_LIVED_KEY_PATTERNS):
if not _is_short_lived(name, ttl):
r.add(
"CRED-001", HIGH, name,
f"疑似长寿凭据(ttl={ttl},storage={storage})。"
f"应改为 ttl<={CRED_TTL_MAX_SECONDS}s 的短期凭据,"
f"或由 broker 在执行点注入。",
)
# CRED-002 TTL 超限
if ttl is not None and ttl > CRED_TTL_MAX_SECONDS:
r.add(
"CRED-002", HIGH, name,
f"ttl={ttl}s 超过上限 {CRED_TTL_MAX_SECONDS}s;"
f"外泄窗口过长,注入一旦成功即长期有效。",
)
# CRED-003 合并凭据(只读 + 高危权限共存)
tiers = set()
for s in scopes:
tail = str(s).split(":")[-1].split(".")[-1].lower()
if tail in PRIVILEGE_TIERS:
tiers.add(PRIVILEGE_TIERS[tail])
if LOW in tiers and HIGH in tiers:
r.add(
"CRED-003", HIGH, name,
f"合并凭据:同一份凭证同时含只读与高危权限({scopes})。"
f"这本身就是静默提权通道 ------ Agent 会在被拒后自己升级。",
)
# CRED-004 宽 scope
for s in scopes:
if any(p.match(str(s)) for p in BROAD_CAPABILITY_PATTERNS):
r.add(
"CRED-004", MED, f"{name}:{s}",
f"能力命名过宽({s})。应按业务边界重命名,"
f"例如 billing:write -> billing.refund.issue_under_50_usd。",
)
# CRED-005 凭据落到 Agent 可读的位置
if storage in {"env", "dotenv", "tool_config", "prompt", "trace", "memory"}:
r.add(
"CRED-005", HIGH, name,
f"凭据位于 Agent 可读位置(storage={storage})。"
f"凡进入环境变量/工具配置/prompt/trace 的东西,"
f"都能被能操纵 Agent 的一方读到。应由 broker 注入。",
)
def audit_capabilities(manifest: dict, r: AuditResult) -> None:
"""能力面审计:出网白名单缺失、不可逆动作无审批。"""
tools = manifest.get("tools", []) or []
for t in tools:
t = t or {}
name = t.get("name", "<unnamed>")
egress = t.get("egress_allowlist")
approval = t.get("require_approval", False)
irreversible = t.get("irreversible", False)
# EGRESS-001 fetch 类工具无出网白名单
if EGRESS_TOOL_NAMES.match(name) and not egress:
r.add(
"EGRESS-001", HIGH, name,
"出网类工具未声明 egress_allowlist。"
"无白名单的 fetch 工具是外泄原语,"
"同时也是被他人借道的代理入口。",
)
# EGRESS-002 不可逆动作无审批
if irreversible and not approval:
r.add(
"EGRESS-002", HIGH, name,
"不可逆动作未要求审批。付款、删除、对外发送一类操作,"
"凡换成真实员工也要审批的,Agent 也必须走审批。",
)
# EGRESS-003 无 owner
if not t.get("owner"):
r.add(
"EGRESS-003", MED, name,
"工具未声明 owner。低代码时代,无属主的工具在创建者离职后"
"仍会继续运行。",
)
# ---------------------------------------------------------------- 样本数据
SAMPLE_MANIFEST: dict[str, Any] = {
"credentials": {
# 长寿 + 存在 env + 合并凭据
"GITHUB_API_KEY": {
"ttl_seconds": None,
"storage": "env",
"scopes": ["repo:read", "repo:admin"],
},
# TTL 超限
"DEPLOY_TOKEN": {
"ttl_seconds": 86400,
"storage": "tool_config",
"scopes": ["deploy:write"],
},
# 合规样本:短期 + broker 注入
"short_lived_jit_token": {
"ttl_seconds": 60,
"storage": "broker",
"scopes": ["crm.contact.read_for_assigned_tickets"],
},
},
"tools": [
{"name": "fetch_page", "owner": "platform"},
{"name": "crm.updateContact", "owner": "crm-team"},
{"name": "shell.exec", "owner": "devx", "irreversible": True},
{
"name": "http_probe",
"owner": "sre",
"egress_allowlist": ["api.internal.example.com"],
},
],
}
# ---------------------------------------------------------------- 报告输出
def render(result: AuditResult) -> str:
lines = []
lines.append("=" * 74)
lines.append("Agent 凭证明细与最小授权审计报告")
lines.append("=" * 74)
order = {HIGH: 0, MED: 1, LOW: 2}
findings = sorted(result.findings, key=lambda f: (order[f.severity], f.rule))
for i, f in enumerate(findings, 1):
lines.append(f"\n[{i}] {f.severity:4s} | {f.rule} | {f.target}")
lines.append(f" {f.message}")
lines.append("\n" + "-" * 74)
lines.append(
f"合计 {len(findings)} 项问题,其中 HIGH {result.high_count} 项。"
)
lines.append("-" * 74)
return "\n".join(lines)
def main() -> int:
result = AuditResult()
audit_credentials(SAMPLE_MANIFEST, result)
audit_capabilities(SAMPLE_MANIFEST, result)
if "--json" in sys.argv:
print(json.dumps(result.to_dict(), ensure_ascii=False, indent=2))
else:
print(render(result))
print(
"\n提示:把 --json 接进 CI,"
"`high > 0` 时返回非零退出码,让门禁成为构建失败而不是 review 评论。"
)
# CI 语义:存在 HIGH 即失败
return 1 if result.high_count else 0
if __name__ == "__main__":
sys.exit(main())
2.2 实机运行输出
在本机 Python 3.13.12 实机运行,输出如下(完整、未裁剪):
ini
==========================================================================
Agent 凭证明细与最小授权审计报告
==========================================================================
[1] HIGH | CRED-001 | GITHUB_API_KEY
疑似长寿凭据(ttl=None,storage=env)。应改为 ttl<=300s 的短期凭据,或由 broker 在执行点注入。
[2] HIGH | CRED-001 | DEPLOY_TOKEN
疑似长寿凭据(ttl=86400,storage=tool_config)。应改为 ttl<=300s 的短期凭据,或由 broker 在执行点注入。
[3] HIGH | CRED-002 | DEPLOY_TOKEN
ttl=86400s 超过上限 300s;外泄窗口过长,注入一旦成功即长期有效。
[4] HIGH | CRED-003 | GITHUB_API_KEY
合并凭据:同一份凭证同时含只读与高危权限(['repo:read', 'repo:admin'])。这本身就是静默提权通道 ------ Agent 会在被拒后自己升级。
[5] HIGH | CRED-005 | GITHUB_API_KEY
凭据位于 Agent 可读位置(storage=env)。凡进入环境变量/工具配置/prompt/trace 的东西,都能被能操纵 Agent 的一方读到。应由 broker 注入。
[6] HIGH | CRED-005 | DEPLOY_TOKEN
凭据位于 Agent 可读位置(storage=tool_config)。凡进入环境变量/工具配置/prompt/trace 的东西,都能被能操纵 Agent 的一方读到。应由 broker 注入。
[7] HIGH | EGRESS-001 | fetch_page
出网类工具未声明 egress_allowlist。无白名单的 fetch 工具是外泄原语,同时也是被他人借道的代理入口。
[8] HIGH | EGRESS-002 | shell.exec
不可逆动作未要求审批。付款、删除、对外发送一类操作,凡换成真实员工也要审批的,Agent 也必须走审批。
[9] MED | CRED-004 | GITHUB_API_KEY:repo:admin
能力命名过宽(repo:admin)。应按业务边界重命名,例如 billing:write -> billing.refund.issue_under_50_usd。
[10] MED | CRED-004 | DEPLOY_TOKEN:deploy:write
能力命名过宽(deploy:write)。应按业务边界重命名,例如 billing:write -> billing.refund.issue_under_50_usd。
[11] MED | EGRESS-003 | http_probe
工具未声明 owner。低代码时代,无属主的工具在创建者离职后仍会继续运行。
--------------------------------------------------------------------------
合计 11 项问题,其中 HIGH 8 项。
--------------------------------------------------------------------------
退出码为 1(HIGH > 0),可直接接进 CI。
值得注意的四点:
- 合规样本
short_lived_jit_token未触发任何规则 :它ttl_seconds=60(名字与 TTL 双豁免)、storage="broker"、能力名贴着业务边界(crm.contact.read_for_assigned_tickets不以read结尾,因此不命中 CRED-004)。这就是"做对了"的样子。 http_probe因为声明了egress_allowlist而没有触发 EGRESS-001 ,只因为缺owner触发 MED 级 EGRESS-003。规则的作用域是对的。GITHUB_API_KEY同时触发了 4 条规则(CRED-001/003/005 三条 HIGH + CRED-004 MED) ------这是规则叠加的典型形态 :一份"长寿 + 放在 env + 含 admin"的密钥,每一个属性都独立违规。注意repo:admin也会命中 CRED-004 (repo:admin匹配^([a-z0-9_]+):(admin)$)------这是我第一版读输出时漏掉的一条,值得单独提醒:宽能力检测与合并凭据检测会同时命中同一个 scope,两者不是替代关系。- 输出确定性 :连续两次运行
diff完全一致(脚本未使用hash();) 已通过zlib.crc32`/无随机源的写法保证)。
3. 三条真实踩坑(都在这段脚本里发生过)
踩坑一:storage 判断千万别写成"黑名单以外即安全"
第一版我写的是:
python
if storage not in {"broker", "vault"}:
... # 判违规
看起来等价,其实是 fail-open 的另一种写法 :任何新出现的 存储位置(比如 "k8s_secret"、"ci_var"、"agent_memory")都会落到 else 分支------要么被误判为安全,要么在后续重构中被静默豁免。
改为显式白名单:
python
AGENT_READABLE = {"env", "dotenv", "tool_config", "prompt", "trace", "memory"}
if storage in AGENT_READABLE:
r.add("CRED-005", HIGH, name, ...)
这条与昨天那个 MCP Python SDK 漏洞同构 :if meta: 式守卫、get("exp_in", 0)、if authorization_server_url:------它们都是 fail-open 通道,漏在异常/超时/缺失分支上 。判定"不该有权限"的代码,必须默认拒绝。
踩坑二:ttl_seconds=None 与 ttl_seconds=0 是两件事,别混
第一版我用 spec.get("ttl_seconds", 999999) 做默认值。结果是:
- 显式写
ttl_seconds: null("永不过期")→None,None > 300在 Python 里直接TypeError,脚本崩在审计中途。 - 显式写
ttl_seconds: 0(配置有误)→0 > 300为假 → 被判为"TTL 合规",静默放行。
改成两段式显式处理:
python
if ttl is not None and ttl > CRED_TTL_MAX_SECONDS:
... # CRED-002
# 长寿凭据判定里,None 必须视为"不豁免"
def _is_short_lived(name, ttl_seconds):
if SHORT_LIVED_EXEMPT.search(name or ""):
return True
return ttl_seconds is not None and ttl_seconds <= CRED_TTL_MAX_SECONDS
注意 _is_short_lived 的返回:ttl_seconds is not None 这个前置条件,让 None 必然落到"不豁免" 。这就是"必然越界"的默认值写法------与昨天那个 DELEG_MAX + 1 是同一个手法。
踩坑三:代码字符串里不要用英文双引号做中文强调
中文写作习惯用 "..."(英文双引号)做强调,一旦嵌进 f-string 或普通字符串就会破坏语法。我在写 CRED-003 的提示信息时踩过:
python
# ✗ SyntaxError: invalid syntax. Perhaps you forgot a comma?
r.add("CRED-003", HIGH, name, "合并凭据 ... 这本身就是一套"静默提权通道"...")
# ✓ 中文强调一律用「」,英文引号只用于代码
r.add("CRED-003", HIGH, name, "合并凭据 ... 这本身就是一套「静默提权通道」...")
规则 :源码里的中文内容一律用 「」 做强调," 只留给字符串定界与标识符。
4. 技术原理:为什么"凭证不进 Agent"这件事不可替代
可以把 Agent 的授权面拆成三层(这也是设计文档里最值得写清楚的一段):
| 层次 | 回答的问题 | 稳定性 | 谁来强制 |
|---|---|---|---|
| 身份 | 哪个 Agent、代表哪个用户在发请求 | 相对稳定 | IdP / SPIFFE SVID / 委托令牌 |
| 授权 | 给定这个身份,哪些能力适用 | 每个操作重新求值,不从长效授权继承 | PDP(OpenFGA Conditions / Cedar / OPA) |
| 执行强制 | 这一次调用、这个 payload 允不允许 | 一次性 | Broker / Gateway ------ 必须能看见真实参数 |
三者折叠成一层,就会出现昨天那个 SDK 漏洞的形态:身份验证在回退路径上被静默跳过,于是"授权"在一层看不见身份的地方做了决定。
而 Okta 的 Agent Gateway 给出的权限交集公式,是"执行强制"这一层最干净的表达:
arduino
Agent 实际能做的 = 网关允许的 ∩ 用户本来就能做的
网关允许的 ← 由网关强制
用户本来就能做的 ← 由 MCP Server 强制
两者缺一不可。 网关知道"哪个 Agent 在调、暴露了哪些工具";MCP Server 知道"自己的记录、哪些用户能访问"。另外值得抄的一个设计:网关自己也是一个注册过的工作负载,有独立主体、没有任何常驻权限 ;delegation link 与 resource connection 都不从调用方推断。
至于"能力粒度",Auth0/OpenFGA 给了一个可以直接落到代码里的做法:用 Conditions 把数值约束挂在关系上,在同一次 check 里求值。
text
model schema 1.1
type order
relations
define can_refund: [agent with refund_within_limit]
condition refund_within_limit(refund_amount: int, agent_limit: int) {
refund_amount <= agent_limit
}
持久化条件上下文优先于请求时上下文 :元组里 agent_limit: 50、检查时 refund_amount: 75 → 判定 false → 运行时下一步是触发人工审批流,而不是去申请一枚"能退款"的令牌。
这个"下一步"的选择,就是 least agency 与 least privilege 的分野。
5. 实战经验:把审计接进流水线
5.1 让门禁成为"构建失败"而不是"review 评论"
yaml
# .github/workflows/agent-credential-audit.yml
name: Agent Credential Audit
on: [push, pull_request]
jobs:
audit:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: "3.13"
- name: Run credential audit
run: python tools/agent_credential_audit.py --json > audit.json
# 上一步 HIGH>0 时退出码为 1,job 直接失败
- name: Upload report
if: always()
uses: actions/upload-artifact@v4
with:
name: credential-audit
path: audit.json
关键在 sys.exit(1 if high_count else 0) ------ 存在 HIGH 问题时让 job 红掉。这与沙箱逃逸测试、SBOM 扫描是同一类纪律:能被绕过的门禁等于没有门禁。
5.2 用 canary 验证脱敏真的生效
脱敏要检查的四个泄漏路径,不能只查一个:
- 工具输出回流到上下文
- 工具报错里的环境变量 dump
- 异常处理器里的堆栈 trace
- 写进 trace 的请求-响应体
然后用假密钥(canary)跑一遍:
bash
CANARY="hkjs-canary-7f3a9c" node scripts/agent-smoke.mjs --inject-canary "$CANARY" > /dev/null
if grep -Rq "$CANARY" traces/; then
echo "FAIL: canary leaked into traces"
grep -Rl "$CANARY" traces/ | head -3
exit 1
fi
echo "ok: no canary in traces"
公开案例里,有团队首跑即 FAIL:工具调用报错序列化时,失败的工具把环境变量原样返回在了 error payload 里,而 trace writer 一字不差地存了下来。
没有用真 canary 测过的脱敏,是一次猜测。
5.3 出网白名单是"最后一公里"
三个自检问题:
- 有没有任何工具的出网白名单包含攻击者可控域名?
- 有没有工具输出能在两次工具调用之间无策略检查地导致下一次带攻击者参数的调用?
- 凭据的 TTL 是否短到"外泄时已经失效"?
若 (1) 为否且 TTL 60 秒,"提示注入"就从入侵降级为骚扰。
这条在上周的 Wikimedia 事件里得到反证:其调查发现 Agent 曾改动一个引用工具的配置,意图把它当作代理去抓取外部数据(被判定为 "potentially malicious"),也试图让公开的 Etherpad 当代理(失败)。第三方拆解给出了本质:
一个网络访问受限的 Agent,会去寻找"替它抓取外部内容的公开工具"。若成功,该手法就能绕过原有环境设定的实际边界。
出网白名单同时防两件事 :自己的 Agent 往外打,和别人借你的工具往外打。
6. 局限说明(这段请务必读)
本文脚本是一个审计工具,不是安全边界。它有几条明确局限:
- 它看的是清单(manifest),不是运行时。 一个干净 manifest 的系统仍可能在运行时泄漏------所以 canary 测试与 trace 审计不可省略。
- 规则是模式匹配,不是语义分析。
BROAD_CAPABILITY_PATTERNS会漏掉自定义命名的宽能力(例如everything),也会误伤少数命名特殊的合规能力。它适合做初筛和 CI 门禁,不适合单独作为合规结论。 CRED-003合并凭据的判定依赖scopes字段格式 。如果你们的 scope 不用:或.分隔,需要改tail的切分逻辑。- 不涉及密钥的真实内容。 脚本从不读取、也从不输出任何凭据值------它只读"凭据的元数据(名字、TTL、存储位置、scope)"。这是刻意的设计:能读到真实密钥的审计工具本身就变成了高危资产。
CRED_TTL_MAX_SECONDS = 300是经验值 ,不是标准。请按你们业务的不可逆动作比例调整------不可逆动作越多,这个值该越小。
7. 可以抄走的 PR 清单
- 把
agent_credential_audit.py加进tools/,接进 CI,HIGH>0 时构建失败 - 检查所有凭据的
storage:凡在env/dotenv/tool_config/prompt/trace/memory的,列入迁移到 broker 的候选 - 把
ttl_seconds写进凭据元数据;超过 300s 的一律列出并解释为什么 - 拆掉所有「只读 + 高危」共存的合并凭据(这是静默提权通道)
- 宽能力(
x:write/x:admin/x:delete/*)逐个按业务边界重命名 - 所有
fetch/http/webhook/browse类工具补egress_allowlist,默认拒绝 - 不可逆动作补
require_approval: true,并接到 CIBA 或审批流 - 每个工具补
owner(低代码时代无属主的工具会在创建者离职后继续跑) - 跑一次 canary 测试,确认脱敏在四个泄漏路径上都生效
- 把「遇到认证失败,停下来并上报,永远不尝试其他凭据」写进每个 Agent 的说明书
- 把审计日志写到 Agent 环境之外(append-only),并确认 Agent 不可写
8. 小结
三条判断带走:
- 分界线不是"权限表有多小",而是"密钥有没有进过 Agent 可读的范围"。 前者管授权集合,后者管能力与凭据的分离------Agent 提交意图,Broker 持有密钥。
- 身份 / 授权 / 执行是三层,别折叠。 身份相对稳定;授权对每个操作重新求值 ;执行强制落在能看见真实参数的地方 ------Okta 的交集公式(
网关允许的 ∩ 用户本来就能做的)是这一层的干净表达。 - 审计代码本身必须默认拒绝。
if meta:、get("exp_in", 0)、storage not in {...}------ 这些都是 fail-open 通道。决定 fail-closed 的那行代码,必须住在真正感知"取不到"的那一层。
还有一个当前最真实的开放问题,值得贴到团队群里:
谁来决定"Agent 何时可以被允许升级权限"?
人实时批准(并行规模下批不过来)/ 静态规则预先决定(预见不了现场)/ Agent 自己决定(这就是五个 Agent 翻遍凭据文件的由来)------三个都不够。
更麻烦的是:你怎么会知道它去翻了? 「我们的 Agent 总结里不会写『我在磁盘上找到一枚 token 并用了它』。」 多数环境记录的是 Agent 完成了什么,不是它一路尝试了什么。
明天(Day 4)展开越权链的三个可检测点 。今天已经砌好三块砖:身份唯一 (一个身份码对一个主体)、权限交集 (网关 ∩ 用户)、凭证不进上下文 (Broker 模式)。明天要问的是:当这三块砖都砌上,攻击链还能在哪一环被确定性地拦住?
一句话带走:能做事,但做不了大事------权限越收敛,事故半径越小。
合规声明 :本文涉及鉴权、权限、凭证管理内容,全部仅作原理与工程实践说明,不提供任何绕过鉴权 / 审计 / 隔离的操作性内容。文中引用的第三方数据(Okta 交集公式、SPIFFE 成本区间、厂商 benchmark、第三方调查百分比)均为其自报口径,已在正文标注来源,请勿直接作为合规结论使用。