Agent Governance Toolkit 安全审计:修复 Python 参考实现中两处 Fail-open 漏洞(地理围栏与空标签集合)
【免费下载链接】agent-governance-toolkitAI Agent Governance Toolkit — Policy enforcement, zero-trust identity, execution sandboxing, and reliability engineering for autonomous AI agents. Covers 10/10 OWASP Agentic Top 10.项目地址: https://gitcode.com/GitHub_Trending/ag/agent-governance-toolkit
导读
本文基于 agent-governance-toolkit 仓库docs/security/audits/2026-07-31-fail-open-closure-python-reference.md记录的安全审计结论,深入讲解 Python 参考实现(agent-os)中两处被恶意复审发现的 fail-open 缺陷及其修复方案:data_classification.py中地理围栏(geography)缺省放行,以及context_aggregation.py中空标签集合导致聚合规则匹配一切。读完本文,你将理解 ABAC 数据访问决策中"属性缺失即拒绝"的 fail-closed 原则如何落地、单调递增的升级兜底(monotone backstop)为何必须防住"通配规则",以及对应的回归测试如何逐条锁定修复行为。
审计背景:跨语言 SDK 移植的对照复审
该审计(PR #3524)源于对同一套治理语义在多种语言 SDK 中移植实现的对照复审:TypeScript(#3199)、Go(#3261)、.NET(#3443)、Rust(#3450)均已先行修复,而 Python 参考实现(agent-governance-python/agent-os/src/agent_os/policies/)在复审中被发现是其中"最宽松"的实现,存在两处 fail-open 缺口。所谓 fail-open,指的是当输入不满足约束时系统反而放行——这与仓库中 ADR 0013 确立的"所有策略评估错误一律 fail-closed"原则背道而驰。ADR 0013 指出,在企业和多租户部署中,单条 fail-open 路径就可能击穿整个信任模型,因为攻击者只要能触发评估异常或缺省场景,就能绕过全部治理控制。
本次 PR 的目标正是把 Python 参考实现从 fail-open 拉回 fail-closed:对之前被静默放行的输入,改为显式拒绝。
缺口一:地理围栏缺省放行(data_classification.py)
缺陷位置与成因
缺口位于data_classification.py的DataAccessEvaluator._evaluate_single方法。修复前的判断逻辑是:
policy.required_geography and data_label.geography and data_label.geography != policy.required_geography问题出在中间的data_label.geography真值判断上:当数据标签的geography字段为空字符串""时,整个条件短路为假,检查被直接跳过。结果就是:一条要求required_geography="US"的策略,在面对一个没有标注任何地理信息的数据标签时会静默放行——这些未标注数据被当成了"非 US",但实际处理时却等效于放行。
修复后的 fail-closed 语义
现在 data_classification.py 中的判断为:
if ( policy.required_geography is not None and data_label.geography != policy.required_geography ): return DataAccessDecision( allowed=False, reason=( f"Geography '{data_label.geography}' does not match " f"required '{policy.required_geography}'" ), agent_id=agent_id, data_label=data_label, matched_policy=policy_ref, )关键变化:判断条件从"策略要求地理信息且标签带地理信息且二者不匹配"改为"只要策略要求了地理信息(is not None)而标签地理信息不匹配即拒绝"。"" != "US"为真,所以缺失地理信息的标签必然被拒。
这一改动与底层数据模型严格对应:ABACPolicy.required_geography是Optional[str] = None(data_classification.py),None 表示"不要求";而DataLabel.geography默认是str = ""(data_classification.py),空字符串是"未标注"的自然表示。修复后,只有required_geography=None(策略不要求)时检查才被跳过,彻底贯彻 ABAC 的"属性缺失不授予任何权限"(absence of an attribute grants nothing)语义,并与 TS 移植版刻意的 fail-closed 行为对齐。
完整决策链:分类、类别、地理层层把关
_evaluate_single是整个 ABAC 决策链的核心,地理检查只是其中一环。从源码结构看,决策按固定顺序依次执行,任一环节失败即返回拒绝:
- 分类层级:
data_label.classification > policy.max_classification时拒绝; - 允许分类列表:若
allowed_classifications非空且标签分类不在其中则拒绝; - 显式禁用类别:标签任一 category 命中
denied_categories则拒绝; - 允许类别白名单:若
allowed_categories非空且标签类别不在其中则拒绝; - 地理围栏(本次修复点):策略要求地理信息而标签不匹配则拒绝。
此外,DataAccessEvaluator.evaluate对同一 agent 的多条策略采用"最严格结果获胜":任一策略拒绝即整体拒绝(data_classification.py),未注册任何策略的 agent 直接默认拒绝(default deny)。这保证了 fail-closed 语义贯穿决策入口。
缺口二:空标签集合的聚合规则匹配一切(context_aggregation.py)
缺陷位置与成因
第二处缺口在context_aggregation.py的AggregationRule。聚合评估(Context Accumulation Governance)用组织自定的规则来覆盖"标签组合":当信封(ContextEnvelope)中已积累的标签集合包含规则声明的all_labels时,规则把敏感性抬升到sets_sensitivity并追加adds_restrictions限制。
修复前的判断是子集运算:
rule.all_labels <= env.labels对于 Python 集合,frozenset() <= labels恒为真——空集是任何集合的子集。于是,一条all_labels=frozenset()的规则会匹配每一个信封,产生两个严重后果:
- 强制抬升敏感性:任何信封都被抬到规则的
sets_sensitivity(哪怕标签组合根本不相关); - 压制升级兜底(escalation backstop):
evaluate_aggregation中escalate = not applied and len(env.labels) >= n_category_threshold——一旦规则被判定为"命中",applied非空,escalate恒为 False。而该兜底的本意是:未被任何规则覆盖的标签组合应升级人工审查,而不是静默通过。空标签规则等于把"未覆盖组合"全部变成"已覆盖组合",兜底从此失效。
修复方案:构造期拒绝,规则无法被创建
修复采用"从源头掐断"的方式,在 context_aggregation.py 的AggregationRule.__post_init__中直接抛错:
def __post_init__(self) -> None: if not self.all_labels: raise ValueError( f"AggregationRule {self.name!r} must have " f"a non-empty all_labels set" )由于AggregationRule是@dataclass(frozen=True),__post_init__在对象构造完成前执行,空标签集合的规则根本不可能被实例化。无论是有意为之还是配置笔误(例如从空配置模板生成规则),匹配一切的"通配规则"都无法产生,升级兜底始终保持可达。
这一修复与evaluate_aggregation的单调性设计(context_aggregation.py)协同:聚合敏感性取"信封当前敏感性"与"所有命中规则的 sets_sensitivity"的最大值,只会上升不会下降;升级条件严格限定为"无规则命中且标签数达到阈值"。空标签规则被移除后,这条兜底路径对任何未覆盖的组合都能正常工作。
威胁模型影响评估
审计文档对修复前后的安全影响做了逐维度的对照,归纳如下:
| 维度 | 修复方向 | 说明 |
|---|---|---|
| 经地理信息的数据外泄 | 已封堵 | 无地理标注的数据标签不再满足要求地理的策略;绑定required_geography="US"的 agent 无法再读取应按"非 US"对待的未标注数据。与 TS 移植版刻意的 fail-closed 行为一致 |
| 升级兜底完整性 | 已封堵 | 单调兜底的存在意义就是:未被规则覆盖的标签组合升级审查而非放行。空标签规则被构造期拒绝后,兜底保持可达,匹配一切的行为无法被有意或配置失误地制造出来 |
| 现有合法配置 | 不受影响 | 仓库与测试中的规则全部使用非空标签集合;未设置required_geography的策略仍允许任意标签地理信息。被移除的 fail-open 行为原本就未文档化,也没有任何调用点依赖 |
| 新增攻击面 | 无 | 两个修复都只是在既有决策点上收窄接受范围,未引入新的输入、网络暴露、密钥或信任决策 |
遗留项:限制门控缺口交由 #3523 处理
同一轮复审还发现了第三处 fail-open——限制门控(restriction-gating)缺口:decide_next门控与义务(obligations)读取的是env.restrictions(信封上已落地的限制),而非聚合评估生效后的限制集合。该缺口不在本次 PR 范围内,由 #3523 修复:从源码可见,context_accumulation.py 中decide_next已改为读取agg.restrictions(evaluate_aggregation的结果,从env.restrictions播种后是超集,只会门控更多、绝不会更少),并让楼层触发(floor-triggered)的CONSTRAIN在未携带限制时显式命名一个义务,避免"携带空义务集的 CONSTRAIN 对按义务执行的宿主是空操作"。对应回归测试见 test_context_accumulation.py。待 #3523 落地后,Python 参考实现对复审发现的三处缺口将全部 fail-closed。
回归测试覆盖:每个修复都有可回退验证
审计要求每个回归测试都满足"回退修复即失败、应用修复即通过"的双向验证。测试矩阵如下:
| 测试 | 验证内容 | 源码位置 |
|---|---|---|
test_missing_geography_denied_when_required | 策略要求required_geography="US"时,geography=""的标签被拒绝 | test_data_classification.py |
test_matching_geography_allowed | 显式匹配的地理信息仍被放行 | test_data_classification.py |
test_mismatched_geography_denied | 显式不匹配的地理信息被拒绝 | test_data_classification.py |
test_no_geography_requirement_allows_any | 未设置required_geography的策略仍允许任意标签地理 | test_data_classification.py |
test_empty_all_labels_rejected | 构造AggregationRule(all_labels=frozenset())抛出ValueError | test_context_aggregation.py |
值得注意test_missing_geography_denied_when_required与test_no_geography_requirement_allows_any是成对设计的:前者锁定"要求时缺失即拒",后者锁定"不要求时任意放行"——这正是修复后语义的两个边界,防止未来重构把任意一端重新放宽。test_empty_all_labels_rejected则用pytest.raises(ValueError, match="non-empty")同时断言异常类型与消息,锁死构造期校验行为。审计同时确认:agent-os 单元测试套件(含上述两个测试文件)在修复分支上全部通过。
修复原则总结:为什么"缺省拒绝"比"缺省放行"更安全
从两处修复可以提炼出 Python 参考实现遵循的三条可复用原则:
- 可选属性的比较必须以"策略是否要求"为开关,而非"数据是否提供":
required_geography is not None把"是否启用检查"交给策略侧,数据侧无论缺失("")还是不匹配("EU")都同等对待为"不通过"。任何以数据真值做短路开关的判断(如data_label.geography and ...)都应视为 fail-open 嫌疑。 - 会匹配一切的集合必须从模型层禁止:
frozenset() <= labels恒真是一个反直觉的数学事实,靠调用方自觉规避不可靠;在__post_init__构造期抛错,把非法状态变成不可表示状态(make illegal states unrepresentable)。 - 安全兜底路径必须永远可达:升级兜底只有在"无规则命中"时才触发,因此任何能让规则"总是命中"的输入都会静默关闭兜底。对这类路径,回归测试不仅要覆盖"兜底触发",还要覆盖"兜底被压制"的场景,例如 test_context_aggregation.py 的
test_backstop_escalates_on_n_distinct_categories。
这两处修复共同把 Python 参考实现与 TypeScript/Go/.NET/Rust 移植版拉齐到一致的 fail-closed 语义,也再次印证了 ADR 0013 的决策:在 agent 治理场景中,一次静默放行的代价远高于一次显式拒绝的误报。
【免费下载链接】agent-governance-toolkitAI Agent Governance Toolkit — Policy enforcement, zero-trust identity, execution sandboxing, and reliability engineering for autonomous AI agents. Covers 10/10 OWASP Agentic Top 10.项目地址: https://gitcode.com/GitHub_Trending/ag/agent-governance-toolkit
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考