security:给每个 Agent 一枚可追溯的"指纹"
Day 56 的钩子里我们瞥见了 ctx.agent.security_config.fingerprint。Day 08 的工具执行里也传了 agent_fingerprint。当多个 Agent、跨进程协作、日志和审计混在一起时,一个根本问题浮现:这条工具调用/这段输出,到底是哪个 Agent 干的?我能不能给它一个稳定、可复现、防篡改的身份?今天读 security/ 这个小而精的模块:Fingerprint(指纹模型 + UUID5 生成)、SecurityConfig(挂载到 Agent)、以及指纹如何在执行链里被带上做审计追溯。模块虽小,却是"可信 Agent 工程化"的地基。
痛点:多 Agent 协作下的"身份"缺失
Fingerprint = 给每个组件(Agent/Crew)一枚 UUID 身份 + 创建时间 + 元数据。默认随机(uuid4)保证唯一;也可用种子经 uuid5 确定性生成——同种子永远同 UUID,实现"跨进程稳定身份"。SecurityConfig 把它挂到 Agent 上(默认自动生成,你无感),执行链里把 str(fingerprint) 带进工具调用做审计追溯。这是 CrewAI 安全体系目前落地的核心(其余认证/授权还是 TODO)。Fingerprint:一个 Pydantic 身份模型
指纹的数据结构(security/fingerprint.py:41):
# security/fingerprint.py:41
class Fingerprint(BaseModel):
"""dual identifiers: human-readable ID + Fingerprint UUID for tracking/auditing."""
_uuid_str: str = PrivateAttr(default_factory=lambda: str(uuid4())) # 私有:默认随机 UUID
_created_at: datetime = PrivateAttr(default_factory=datetime.now) # 私有:创建时间
metadata: Annotated[dict[str, Any], BeforeValidator(_validate_metadata)] = Field(
default_factory=dict) # 公开:附加元数据
@property
def uuid_str(self) -> str: # 只读暴露
return self._uuid_str
@property
def created_at(self) -> datetime:
return self._created_at
@property
def uuid(self) -> UUID:
return UUID(self.uuid_str)
PrivateAttr + default_factoryuuid_str/created_at 用 私有属性存,只通过 @property 只读暴露——外部拿得到、改不了。身份一旦生成就不可变。default_factory=lambda: str(uuid4())不给种子时,每个指纹默认一个随机 UUID4。保证唯一性(碰撞概率天文级低)。metadata 可写唯一可写字段:挂业务标签(如 {"team":"research"})。带 BeforeValidator 做校验(见 L07 边界)。uuid 属性需要真 UUID 对象时按需 UUID(uuid_str) 转换。存字符串、用时转——序列化友好。UUID5:用种子生成"可复现"的身份
确定性生成是指纹最有意思的能力(security/fingerprint.py:75):
# security/fingerprint.py:75
@classmethod
def _generate_uuid(cls, seed: str) -> str:
if not seed.strip():
raise ValueError("Seed cannot be empty or whitespace")
return str(uuid5(CREW_AI_NAMESPACE, seed)) # ★命名空间 + 种子 → 确定性 UUID
# :90
@classmethod
def generate(cls, seed: str | None = None, metadata=None) -> Self:
fingerprint = cls(metadata=metadata or {})
if seed:
fingerprint.__dict__["_uuid_str"] = cls._generate_uuid(seed) # 有种子就覆盖成确定值
return fingerprint
命名空间是一个写死的常量(security/constants.py:13):
# security/constants.py:13
CREW_AI_NAMESPACE: Annotated[UUID, "deterministic UUID v5 (SHA-1). Custom namespace for CrewAI."] = \
UUID("f47ac10b-58cc-4372-a567-0e02b2c3d479")
uuid5(namespace, seed)UUID5 = 对 namespace + seed 做 SHA-1 哈希得到的 UUID。同样的输入永远得到同样的输出——这就是"可复现"。CREW_AI_NAMESPACE专属命名空间常量。作用:即使别人也用 seed="researcher",只要命名空间不同,UUID 就不同——避免和其他系统撞号。seed.strip() 空校验空种子直接抛错。因为空种子没意义,且会生成一个所有人共享的"垃圾 UUID",索性禁掉。__dict__["_uuid_str"] = ...PrivateAttr 没有 setter,只能绕过 pydantic 直接写 __dict__ 来覆盖默认随机值。这是"不可变模型的可控例外"。Fingerprint() → 每次都是不同的随机 UUID(uuid4)。Fingerprint.generate(seed="research-agent-v1") → 无论在哪台机器、跑几次、重启多少回,永远得到同一个 UUID(uuid5)。用途:给一个长期存在的 Agent 固定种子,它的历史日志跨重启都能串成同一条身份线;而临时 Agent 用随机指纹即可。
相等、哈希、序列化:让指纹能比较能存盘
指纹重写了 __eq__/__hash__ 并提供字典互转(security/fingerprint.py:113):
# security/fingerprint.py:113
def __eq__(self, other: Any) -> bool:
if type(other) is Fingerprint:
return self.uuid_str == other.uuid_str # 只按 UUID 比,不看 metadata/时间
return False
def __hash__(self) -> int:
return hash(self.uuid_str) # 能进 set / 当 dict key
# :123
def to_dict(self):
return {"uuid_str": self.uuid_str,
"created_at": self.created_at.isoformat(),
"metadata": self.metadata}
# :135
@classmethod
def from_dict(cls, data):
if not data:
return cls()
fingerprint = cls(metadata=data.get("metadata", {}))
if "uuid_str" in data:
fingerprint.__dict__["_uuid_str"] = data["uuid_str"] # 还原保存的 UUID
if "created_at" in data and isinstance(data["created_at"], str):
fingerprint.__dict__["_created_at"] = datetime.fromisoformat(data["created_at"])
return fingerprint
__eq__ 只比 uuid_str两个指纹"是否同一身份"只看 UUID——metadata 变了、时间不同,只要 UUID 一样就是同一个。身份的本质是 UUID。__hash__ = hash(uuid_str)和 __eq__ 保持一致(相等的对象哈希必须相等)。让指纹能放进 set 去重、当字典 key 做索引。to_dict / from_dict序列化对:存盘/传输时转成纯 dict(含 ISO 时间字符串),读回时精确还原同一个 UUID——不是新建,是恢复。from_dict(空) → cls()兜底:空数据就返回一个全新随机指纹,不崩。SecurityConfig:指纹怎么挂到 Agent 上
指纹通过 SecurityConfig 容器挂载(security/security_config.py:20):
# security/security_config.py:20
class SecurityConfig(BaseModel):
"""authentication *TODO*, identity (fingerprints), scoping *TODO*, delegation *TODO*"""
model_config = ConfigDict(arbitrary_types_allowed=True)
fingerprint: Fingerprint = Field(
default_factory=Fingerprint, # ★默认自动生成一枚随机指纹
description="Unique identifier for the component")
@field_validator("fingerprint", mode="before")
@classmethod
def validate_fingerprint(cls, v):
if v is None: return Fingerprint()
if isinstance(v, str): # 传字符串 → 当种子确定性生成
if not v.strip(): raise ValueError("Fingerprint seed cannot be empty")
return Fingerprint.generate(seed=v)
if isinstance(v, dict): return Fingerprint.from_dict(v) # 传 dict → 还原
if isinstance(v, Fingerprint): return v
raise ValueError(f"Invalid fingerprint type: {type(v)}")
Agent 基类上就是一个默认字段(agents/agent_builder/base_agent.py:344):
# agents/agent_builder/base_agent.py:344
security_config: SecurityConfig = Field(
default_factory=SecurityConfig,
description="Security configuration for the agent, including fingerprinting.")
default_factory=Fingerprint你 Agent(role=...) 时啥都不填,也自动得到 security_config.fingerprint。安全是"默认开启、零配置"。validate_fingerprint 多态一个字段吃三种输入:字符串(当种子)、dict(还原)、Fingerprint(原样)。用起来极灵活:SecurityConfig(fingerprint="my-seed") 就能要一个确定性身份。arbitrary_types_allowed允许 Fingerprint 这种自定义类型进 pydantic 模型。注释还说明"不能 frozen",因为有测试要改指纹。TODO 字段docstring 诚实标注 authentication/scoping/delegation 仍是 TODO——目前落地的只有 identity(指纹)。读源码要能看出"设计意图 vs 已实现"。指纹在执行链里的追溯
回到 Day 08 的执行器,工具调用前会把指纹打包进上下文(agents/crew_agent_executor.py:403):
# agents/crew_agent_executor.py:403
fingerprint_context = {}
if (self.agent
and hasattr(self.agent, "security_config")
and hasattr(self.agent.security_config, "fingerprint")):
fingerprint_context = {
"agent_fingerprint": str(self.agent.security_config.fingerprint) # __str__ → uuid_str
}
...
tool_result = execute_tool_and_check_finality(
...,
fingerprint_context=fingerprint_context, # :417 随工具调用一起传下去(审计用)
)
hasattr 双重防御不假设 agent 一定有 security_config——兼容旧代码/自定义 Agent。没有就用空 dict,不崩。str(fingerprint)指纹的 __str__(:109)返回 uuid_str。所以打进上下文的是那串 UUID 文本,方便写日志/传输。fingerprint_context 下传随每次工具执行携带。这样审计系统/事件(Day 26 事件总线)能记录"这次工具调用来自哪个指纹"——出事可追溯到具体 Agent。原生模式同理:1241 原生函数调用路径也有一份同样的指纹打包。两条执行路都不漏审计。👶 小白:指纹能防止 Agent 干坏事吗?
👨🏫 老师:不能直接防。指纹是"可追溯性"(谁干的),不是"授权"(能不能干)。它像监控摄像头,事后能查录像;真正的"不让干"要靠 Day 56 的钩子(before_tool_call 返回 False 拦截)或权限系统(还是 TODO)。可追溯 + 可拦截,合起来才是完整安全。今天这块是地基里的"身份"这一层。
取舍 + 边界 + 今日小结
fingerprint.uuid_str = "别的" 随便能改,那审计追溯就成了笑话——谁都能改成别人的号栽赃。所以源码把 uuid/created_at 设成私有 + 只读,正常路径改不了(生成时才通过 __dict__ 这个"后门"写一次)。不可变性是可信身份的前提。朴素实现随手一个可写字段,看似灵活,实则毁掉了身份的意义。_validate_metadata(security/fingerprint.py:17)里:metadata 必须是 dict、key 必须是字符串、只能嵌套一层("Metadata can only be nested one level deep")、且 len(str(v)) > 10_000 就报错。为什么限制?因为指纹会被频繁序列化、随每次工具调用传输——若允许塞进大 blob 或深层嵌套,会拖慢每一次调用、撑大日志。身份标识就该轻量,别拿它当数据库。想存大数据请用记忆系统(阶段6)。🧠 今天你应该能回答
- 为什么不能用 role 当 Agent 身份?指纹解决了什么?
- UUID4 和 UUID5 的区别?种子生成"可复现"靠什么原理?
- 为什么身份字段是私有只读、只能通过 __dict__ 写一次?
__eq__为什么只比 uuid_str,不看 metadata?- SecurityConfig 的 fingerprint 字段能吃哪几种输入?
- 指纹是"授权"还是"可追溯"?它和钩子怎么配合构成安全?
✋ 10 分钟动手
P=lib/crewai/src/crewai/security
sed -n '41,157p' $P/fingerprint.py # Fingerprint 全貌 + generate/eq/dict
sed -n '20,88p' $P/security_config.py # SecurityConfig + validate_fingerprint
sed -n '13,16p' $P/constants.py # CREW_AI_NAMESPACE
grep -n "fingerprint" ../agents/crew_agent_executor.py | head # 执行链里的追溯
python -c "
from crewai.security.fingerprint import Fingerprint
a=Fingerprint.generate(seed='agent-v1'); b=Fingerprint.generate(seed='agent-v1')
print(a==b, str(a)) # True,且两次相同 → 可复现身份
print(Fingerprint() == Fingerprint()) # False → 随机各不同
"
a2a/(Agent-to-Agent 协议):一个 Agent 怎么把任务委托给远端另一个 Agent、多轮对话怎么维持、A2AConfig/wrap_agent_with_a2a_instance 怎么给普通 Agent 装上"跨机协作"的能力。这是 CrewAI 走向"分布式多智能体"的关键一步。