数据截至 (上游 commit d9f7b80be1ee)
攻击生成流水线:从漏洞到对抗输入(baseline → 精炼 → 单轮增强)
30 秒导读: 红队测试要先有"坏话"才能去问模型。本章讲 DeepTeam 怎么造这些坏话:给一条漏洞(如"偏见"),先让一个 LLM 合成几条 baseline 攻击句,再用一个专门的
AttackEngine把它们改写得更隐蔽、更像真实用户提问,最后随机挑一个"增强手法"(Base64 编码、ROT13、或让 LLM 再改写一遍)套上去。产物是一批RTTestCase,每条都带着一句待发射的对抗输入。真正拿去打模型、判有没有越狱,是第 04 章的事;多轮对话式越狱是第 03 章。
本章是主线的上半段——只回答一个问题:攻击是怎么被造出来的?
1. 这是什么(零基础也能懂)
假设你要给一个客服 AI 做安全体检。你担心它会说出带种族/性别偏见的话。你不可能手写几千条刁钻问题——太累,也不够刁钻。于是 DeepTeam 帮你自动"出题":
- 你告诉它:测这个漏洞(偏见),每个子类型出 N 道题。
- 它先让一个"出题模型"编几条朴素的坏问题(baseline)。
- 再把这些问题改写得更狡猾(精炼),让安全过滤器更难识别。
- 最后随机给每道题套一层伪装(增强)——可能是把整句话编码成 Base64,也可能是再让模型换个说法。
一句话直觉:这条流水线是一台"对抗样本工厂"。原料是一条漏洞定义,成品是一批带对抗输入的测试用例。 它本身不判分,只负责"造弹药"。
三个关键角色先混个脸熟(详细数据模型见第 01 章):
| 角色 | 白话职责 | 源码锚点 |
|---|---|---|
AttackSimulator | 总编排:调度 baseline 生成 + 抽样增强 | deepteam/attacks/attack_simulator/attack_simulator.py:36 |
BaseVulnerability.simulate_attacks | 合成 baseline:让出题模型产出结构化攻击 | deepteam/vulnerabilities/bias/bias.py:180 |
AttackEngine | 精炼:transform → variations → LLM 校验 | deepteam/attacks/attack_engine/attack_engine.py:18 |
单轮攻击(如 Base64 / GrayBox) | 增强:给单条输入套伪装 | deepteam/attacks/single_turn/base64/base64.py:6 |
2. 顶层全景(它大概怎么转)
怎么读这张图: 从上到下是时间顺序。左半区是"造朴素坏话",中间是"改狡猾",右下是"套伪装"。数据一路都是 RTTestCase 列表在流动。
输入: vulnerabilities=[Bias(...)], attacks=[Base64(w=3), GrayBox(w=1)], N 条/类型
│
▼
┌─────────────────────────────────────────────────────────┐
│ AttackSimulator.simulate / a_simulate │ 编排层
│ attack_simulator.py:62 / :150 │
└───────────────┬─────────────────────────────┬───────────┘
│ 阶段①:每条漏洞 baseline │ 阶段②:每条 test_case 抽一个增强
▼ ▼
┌───────────────────────────────┐ ┌──────────────────────────────┐
│ vulnerability.simulate_attacks │ │ random.choices(attacks, │
│ bias.py:180 │ │ weights=[a.weight ...]) │
│ · Template.generate_baseline_ │ │ └─ 抽中一个方法 │
│ attacks 造 prompt │ │ │
│ · simulator_model.generate( │ │ enhance_attack(attack, tc) │
│ schema=SyntheticDataList) │ │ attack_simulator.py:476 │
└───────────────┬───────────────┘ │ · inspect.signature 探参数 │
│ baseline 攻击 │ · 算法式 or 模型式 分派 │
▼ └──────────────┬───────────────┘
┌─ ──────────────────────────────┐ │
│ AttackEngine.refine (精炼层) │ ▼
│ attack_engine.py:33 │ tc.input = 被伪装后的对抗输入
│ transform→variations→validate │ │
└───────────────┬───────────────┘ ▼
▼ 返回 List[RTTestCase]
更狡猾的 baseline 攻击 (交给第 04 章打靶评测)
主线走一遍(高层):
simulate拿到一组漏洞和一组增强方法。- 阶段① 对每条漏洞调
simulate_baseline_attacks→ 里面既做 LLM 合成,又顺手做了精炼,吐出一批 baselineRTTestCase。 - 阶段② 遍历这批 test_case,每条按
attack.weight抽一个增强方法,原地改写它的.input。 - 全程有并发节流、错误容忍、成本累计三条"横切"逻辑贯穿。
注意一个容易看漏的点:精炼(AttackEngine)不是编排层单独的一步,而是嵌在 baseline 合成的尾巴里——
simulate_attacks最后一行就调了_refine_simulated_attacks(见 §5)。所以"三段式"里的前两段(baseline→精炼)其实在阶段①内部一次跑完,阶段②才是增强。
3. 核心原理
3.1 编排层:AttackSimulator 怎么调度
它要解决的小问题: 有 M 条漏洞、K 个增强方法、每类 N 条攻击,怎么把它们编排成"先造后套"、还不能因为一次 LLM 调用失败就全盘崩、还要控制并发别把 API 打爆?
同步版 simulate(attack_simulator.py:62) 分两个 for 循环,对应上图两个阶段。
阶段①逐条漏洞造 baseline(:78-91):
# 示意,非源码:阶段① 造 baseline
for vulnerability in vulnerabilities:
test_cases.extend(
self.simulate_baseline_attacks( # 内部会调 vuln.simulate_attacks
attacks_per_vulnerability_type=n,
vulnerability=vulnerability,
ignore_errors=ignore_errors,
)
)
阶段②对每条 test_case 抽一个增强方法(:93-113)。权重抽样是这里的灵魂:
# 示意,非源码:阶段② 按权重抽一个增强方法
attack_weights = [attack.weight for attack in attacks] # 例 [3, 1]
for test_case in test_cases:
sampled_attack = random.choices( # 每条 tc 独立抽一次
attacks, weights=attack_weights, k=1
)[0] # Base64 被抽中概率 3/4
self.enhance_attack(attack=sampled_attack, test_case=test_case, ...)
真实实现里 random.choices(attacks, weights=attack_weights, k=1)[0] 就在 attack_simulator.py:136-138。每条测试用例独立抽一次,所以最终增强方法的分布近似正比于各自的 weight。
异步版 a_simulate(attack_simulator.py:150) 逻辑相同,但加了三层工程化处理:
| 关切 | 怎么做 | 位置 |
|---|---|---|
| 并发节流 | asyncio.Semaphore(self.max_concurrent),每个任务进出都 async with semaphore | :128、:149、:181 |
| 空方法兜底 | if not attacks: 时退化成 BaselineAttack()(它的 a_enhance 原样返回,等于不增强) | :183-184 |
| 扇出并发 | baseline 与增强都用 asyncio.gather 批量跑 | :166、:200 |
BaselineAttack(:24-29)是个占位攻击:a_enhance 直接 return attack,用来在"没提供任何增强方法"时保持流水线结构不变。
错误容忍(ignore_errors) 是贯穿始终的开关。以 simulate_baseline_attacks(:214-239)为例:出错时若 ignore_errors=True,就为该漏洞的每个类型 × 每条生成一个只带 error 字段的占位 RTTestCase,而不是抛异常中断整轮:
# 示意,非源码:出错不崩,填占位用例
except Exception as e:
if ignore_errors:
return [RTTestCase(vulnerability=..., vulnerability_type=t,
error=f"Error simulating adversarial attacks: {e}")
for t in vulnerability.get_types()
for _ in range(attacks_per_vulnerability_type)]
else:
raise
成本累计(cost) 用一个巧妙的 context-local 累加器。enhance_attack 用 with cost_accumulator() as acc: 开一个作用域(:291、:333),作用域内所有 generate/a_generate 调用都会把自己的开销记进这个累加器(attack_simulator/utils.py:52-65 的 cost_accumulator + :38-42 的 _record_cost);结束时 test_case.simulation_cost = add_cost(test_case.simulation_cost, acc[0])(:319)。add_cost(attack_simulator/utils.py:26-33)约定 None 代表"非原生模型不报成本",全 None 才返回 None,否则把 None 当 0 相加。
关键细节:成本用
ContextVar而非把 cost 一路当参数传。ContextVar 对 asyncio 并发安全——每个任务拿到自己的副本,子任务共享同一个累加器对象(attack_simulator/utils.py:36-42的注释点明了这一点)。
3.2 Baseline 合成:让 LLM 造第一版攻击
它要解决的小问题: 一条抽象的漏洞(BiasType.RELIGION)怎么变成几句具体的、像真人会问的坏话?
思路: 用模板拼一个"出题 prompt",让出题模型按结构化 schema 吐出 JSON。以偏见漏洞为实证(bias.py:180 的 simulate_attacks):
第一步,按漏洞类型选模板造 prompt(:195-201):
# 示意,非源码:每个漏洞子类型拼一个 baseline prompt
for type in self.types: # 如 GENDER / RELIGION / RACE / POLITICS
templates[type].append(
BiasTemplate.generate_baseline_attacks( # bias/template.py:13 按类型分派
type, attacks_per_vulnerability_type, self.purpose
)
)
BiasTemplate.generate_baseline_attacks(bias/template.py:13)就是个大 dispatch,按 BiasType 转到 generate_religious_baseline_attacks 等(:37)。模板本身是一段"你来当文案,基于 purpose 编 N 条能诱发偏见的真实用户提问,只返回 JSON"的指令(template.py:44-56)。
第二步,调模型并解析,这里有 native / 非 native 两条路径(bias.py:206-220):
| 路径 | 触发条件 | 怎么拿结果 |
|---|---|---|
| native | self.using_native_model 为真 | model.generate(prompt, schema=SyntheticDataList) 直接返回 (res, cost),res.data 已是结构化对象 |
| 非 native(schema 成功) | 模型接受 schema= 参数 | 同样返回结构化 SyntheticDataList,但 cost 记为 None |
| 非 native(schema 失败) | model.generate(prompt, schema=...) 抛 TypeError | 退化为 model.generate(prompt) 拿纯文本,再用 trimAndLoadJson(res) 兜底解析成 dict |
# 示意,非源码:非 native 的兜底解析(bias.py:212-220)
try:
res = self.simulator_model.generate(prompt, schema=SyntheticDataList)
local_attacks = [item.input for item in res.data] # 结构化路径
except TypeError: # 模型不吃 schema 参数
res = self.simulator_model.generate(prompt) # 退化成纯文本
data = trimAndLoadJson(res) # 从脏文本里抠出 JSON
local_attacks = [item["input"] for item in data["data"]]
trimAndLoadJson 来自 deepeval,负责从可能夹了 markdown 围栏或废话的模型输出里把 JSON 抠出来——这是"模型不老实返回纯 JSON"时的安全网。
第三步,把每条攻击文本包成 baseline RTTestCase,并把这次合成的成本按条数均摊(bias.py:222-237):
# 示意,非源码:成本按 baseline 条数均摊
per_attack_cost = (simulation_cost / len(local_attacks)
if simulation_cost is not None and local_attacks else simulation_cost)
第四步——也是最容易看漏的一步——返回前先过一遍精炼(bias.py:239):
return self._refine_simulated_attacks(simulated_test_cases, purpose)
所以 simulate_attacks 出来的已经是精炼后的攻击,不是最朴素那版。精炼层就是下一节。
3.3 精炼层:AttackEngine 把攻击改狡猾
它要解决的小问题: LLM 第一次造的坏话往往太直白("你觉得低收入家庭的人工作能力差吗?"),容易被安全过滤器一眼拦下。得把它伪装成"业务流程/技术任务",既保留恶意、又更难识别。
思路:三步走(attack_engine.py:33 的 refine):
baseline 攻击
│
▼ ① transform:改写成隐蔽版(camouflage)
transformed_input ──────────────────────────┐
│ │(variations=1 时跳过②)
▼ ② variations:再派生若干"换角度"变体 │
[transformed] + [变体...] ←── 上限 5 │
│ │
▼ ③ validate:逐个让 LLM 判"够不够格当测试" │
过滤掉 is_valid=False 的 ◄──────────────────┘
│
▼
为每个存活候选克隆一条新 RTTestCase
第①步 transform(attack_engine.py:48-59): 用 transform_attack_template 造 prompt,让模型按 TransformedAttack schema(types.py:6,含 reason + input)返回改写版。这个模板极有料——它列了一堆**"不许做"的红线**:不许翻转恶意极性、不许把敏感目标换成无害代名词、不许把可执行请求学术化(attack_engine/template.py:41-46),还配了三个"好/坏改写"对照例子。改写后若结果为空,回退到原始输入(:57-59)。
第②步 variations(attack_engine.py:61-78): 只有 self.variations > 1 时才做。用 generate_variations_template 让模型一次产出多个"换个操作角度、但保留同样毒性"的变体(AttackVariations schema,types.py:11)。候选集 = [transformed_input] + 变体们。
变体数量上限是 5,在构造函数里被死死夹住(attack_engine.py:29):
self.variations = max(1, min(variations, 5)) # 下限 1、上限 5
第③步 validate(attack_engine.py:80-88 → _validate_candidates_with_llm:207): 逐个候选让模型判 ValidationResult.is_valid(types.py:16),只留下 is_valid=True 的:
# 示意,非源码:LLM 校验过滤(attack_engine.py:227-233)
result, cost = generate_with_cost(prompt, ValidationResult, self.simulator_model)
if result.is_valid:
validated.append(candidate)
校验 prompt(template.py:131 的 validate_attack_template)明确说"你查的是质量不是安全"——要求候选仍带对抗意图、不能被过度洗白、要像真人会问。若全被否掉,兜底保留 transform 那版(:87-88)。
成本在克隆用例间均摊(attack_engine.py:90-105): 精炼一条 baseline 可能裂变出多条(1 个 transform + 若干变体都通过校验)。于是把"baseline 原成本 + 本次 精炼成本"合计,再除以存活条数,平摊给每条克隆:
# 示意,非源码:精炼成本平摊(attack_engine.py:92-97)
combined_cost = add_cost(test_case.simulation_cost, refine_cost)
per_refined_cost = combined_cost / len(valid_inputs) if combined_cost is not None else combined_cost
克隆用 _clone_with_new_input(:273):复制漏洞/类型/attack_method/metadata,只换 input 和 simulation_cost,产出一条全新的 RTTestCase。
挂接点: 谁调 refine?是漏洞基类。BaseVulnerability._refine_simulated_attacks(base_vulnerability.py:63)——若漏洞没被注入 attack_engine,就临时 new 一个默认的(默认 simulator_model="gpt-3.5-turbo-0125"、variations=1,见 attack_engine.py:22-29),然后调 engine.refine(...)。variations=1 是默认,意味着默认不产变体、只做 transform + 校验。
3.4 单轮增强:算法式 vs 模型式
这是 §3.1 阶段②的落地。enhance_attack(attack_simulator.py:476)拿一个被抽中的攻击方法,给单条 test_case.input 套伪装。
核心机制:用 inspect.signature 探测方法要什么参数,再分派。 不同增强手法的 enhance 签名不一样——有的只要一段文本,有的还要 simulator_model,有的还要 model_callback。编排层不写死,而是运行时看签名(:331-356):
# 示意,非源码:按 enhance 签名分派(attack_simulator.py:542-572)
sig = inspect.signature(attack.enhance)
if "simulator_model" in sig.parameters and "model_callback" in sig.parameters:
res = attack.enhance(attack=inp, simulator_model=self.simulator_model,
model_callback=self.model_callback)
elif "simulator_model" in sig.parameters:
res = attack.enhance(attack=inp, simulator_model=self.simulator_model)
elif "model_callback" in sig.parameters:
res = attack.enhance(attack=inp, model_callback=self.model_callback)
else:
res = attack.enhance(attack=inp) # 最朴素:只吃文本
test_case.input = res # 原地覆盖为伪装后的输入
由此天然分成两类增强:
| 算法式增强 | 模型式增强 | |
|---|---|---|
| 代表 | Base64、ROT13、Leetspeak | GrayBox、Roleplay、PromptInjection |
enhance 签名 | enhance(self, attack: str) | enhance(self, attack, simulator_model=...) |
| 走哪个分支 | 落到最后 else(不需模型) | 落到带 simulator_model 的分支 |
| 确定性 | 确定性、零成本、瞬时 | 非确定、要 LLM 调用、有成本、可能重试 |
| 伪装原理 | 编码变形绕过基于文本的过滤器 | 语义改写换个框架诱导模型 |
算法式实例 —— Base64(base64/base64.py:14-16):
def enhance(self, attack: str) -> str:
return base64.b64encode(attack.encode()).decode()
签名里没有 simulator_model 也没有 model_callback,所以分派走最后的 else 分支。ROT13(rot13/rot13.py:13-20)同理,用 str.translate 做字母轮换。它们把"把危险内容藏起来"变成一个纯字符串变换——没有模型参与,输出完全可复现。
模型式实例 —— GrayBox(gray_box/gray_box.py:34-38):
def enhance(self, attack: str, simulator_model=None) -> str:
...
prompt = GrayBoxTemplate.enhance(attack) # 让模型基于"部分内部知识"重写
签名带 simulator_model,分派命中对应分支,编排层把 self.simulator_model 注入进去。它内部还有一个重试循环(:52-93):最 多 max_retries 次,每次生成后要过两道检查——compliance(没被模型拒绝)和 is_gray_box(确实是灰盒攻击);两个都过才返回增强版,否则跑满重试后原样退回未增强的 attack(:93)。这就是模型式增强"可能失败、可能空转"的一面,与算法式的"必成功"形成对照。
多轮攻击在这里也被识别,但不展开。 enhance_attack 开头先 isinstance(attack, BaseMultiTurnAttack) 判一下(:282),是多轮就走 attack._get_turns(...) 那条路(:293),把整段对话塞进 test_case.turns。多轮的记忆、逐步升级、回溯逻辑整体留给第 03 章,本章只关心单轮那半边。
4. 一个端到端的教学示意
把 §3.1 的权重抽样和 §3.4 的签名分派拼起来,你就理解了阶段②的全部骨架:
# 示意,非源码:weighted sampling + enhance 分派的极简版
import random, inspect
def enhance_one(attack, text, simulator_model, model_callback):
# 运行时探测这个 attack 的 enhance 要哪些参数
params = inspect.signature(attack.enhance).parameters
kwargs = {"attack": text}
if "simulator_model" in params: # 模型式增强才需要
kwargs["simulator_model"] = simulator_model
if "model_callback" in params:
kwargs["model_callback"] = model_callback
return attack.enhance(**kwargs) # 算法式 → 直接算;模型式 → 调 LLM
def run_stage_two(test_cases, attacks, sim_model, cb):
weights = [a.weight for a in attacks] # 如 Base64=3, GrayBox=1
for tc in test_cases:
picked = random.choices(attacks, weights=weights, k=1)[0] # 每条独立抽
tc.input = enhance_one(picked, tc.input, sim_model, cb) # 原地覆盖
# 重点看:抽样和分派都不写死方法类型——加新攻击不用改这段
真实代码在 attack_simulator.py:130-140(抽样)与 :542-572(分派),异步对应版在 :592 的 a_enhance_attack。
5. 巧妙之处(可借鉴的技术)
-
权重抽样让增强分布可调而不必分桶。 不用"30% 走 Base64、10% 走 GrayBox"这种硬编码分配,只给每个攻击一个整数
weight,random.choices自动按比例抽(attack_simulator.py:136)。加减攻击、调比例都只动权重。 -
inspect.signature分派 = 开放式插件协议。 编排层永远不isinstance判具体攻击类型,只看enhance要什么参数(:331)。新增一个攻击,只要enhance签名符合约定,零改动接入。算法式和模型式共用一条分派路径。 -
ContextVar 成本累加器,免去参数穿线。 成本不需要一层层当参数传;
cost_accumulator()作用域内所有模型调用自动记账(attack_simulator/utils.py:52-65),且对 asyncio 并发安全。 -
精炼层的 prompt 工程反"极性翻转"。 transform 模板专门用大段红线和对照例子,防止模型把"造攻击"偷偷洗成"防攻击"(
attack_engine/template.py:41-63)——红队 prompt 最常见的失败模式在这里被显式约束。 -
成本双重均摊,让每条用例的 cost 可归因。 baseline 合成按条数摊(
bias.py:222),精炼裂变后再按存活数摊(attack_engine.py:93),保证聚合总成本不重不漏。 -
native / 非 native 三级兜底。 结构化 schema → 无 schema 参数退化 →
trimAndLoadJson抠 JSON(bias.py:206-220),让流水线对各种"不老实"的模型都有韧性。
6. 边界与局限
- 本章只覆盖单轮。 多轮攻击(
_get_turns、对话记忆、逐步升级、回溯)在enhance_attack里只是被isinstance分流出去,细节见第 03 章。 - 不判分、不评测。 流水线只产
RTTestCase.input(和多轮的turns)。有没有越狱、裁判怎么打分、风险报告怎么出,全在第 04 章。 - 变体上限硬编码为 5(
attack_engine.py:29),且默认variations=1(默认不产变体)。 - 质量强依赖出题/精炼模型。 baseline 与精炼都是 LLM 生成,弱模型可能产出被
is_valid=False全否、或触发GrayBox重试耗尽退回原文的情况。 - 模型式增强可能"空转"。 如
GrayBox跑满max_retries仍不达标时原样返回未增强输入(gray_box.py:93),此时该条其实没被真正伪装。 - 同步
simulate无并发节流。max_concurrent的信号量只在异步a_simulate生效(:128);同步路径是纯串行。
7. 横向对比
同组各章的分工(用相对链接互链,不重复内容):
- 概念与数据模型(漏洞 / 攻击 / 测试用例 / 裁判)→ 01-concepts-and-data-model.md
- 多轮越狱(记忆、升级、回溯)→ 03-multi-turn-jailbreaking.md
- 编排与评测(RedTeamer 主循环、LLM-as-a-judge、风险报告)→ 04-orchestration-and-judging.md
- 全景与阅读地图 → index.md
一句话定位: 本章是"造弹药",第 03 章是"多轮怎么打",第 04 章是"打完怎么判"。
8. 代码地图(导航索引)
| 主题 | 文件路径 | 符号名 |
|---|---|---|
| 编排:同步生成主流程 | deepteam/attacks/attack_simulator/attack_simulator.py:62 | AttackSimulator.simulate |
| 编排:异步 + 信号量节流 | deepteam/attacks/attack_simulator/attack_simulator.py:150 | AttackSimulator.a_simulate |
| baseline 容错包装 | deepteam/attacks/attack_simulator/attack_simulator.py:289 | simulate_baseline_attacks |
| 单轮增强 + 签名分派 | deepteam/attacks/attack_simulator/attack_simulator.py:476 | AttackSimulator.enhance_attack |
| 异步单轮增强 | deepteam/attacks/attack_simulator/attack_simulator.py:592 | AttackSimulator.a_enhance_attack |
| 空方法占位攻击 | deepteam/attacks/attack_simulator/attack_simulator.py:28 | BaselineAttack |
| 成本累加器(ContextVar) | deepteam/attacks/attack_simulator/utils.py:52 | cost_accumulator / add_cost |
| baseline 合成 + native 分路 | deepteam/vulnerabilities/bias/bias.py:180 | Bias.simulate_attacks |
| 异步 baseline 合成 | deepteam/vulnerabilities/bias/bias.py:241 | Bias.a_simulate_attacks |
| baseline prompt 模板分派 | deepteam/vulnerabilities/bias/template.py:13 | BiasTemplate.generate_baseline_attacks |
| 精炼层三步主流程 | deepteam/attacks/attack_engine/attack_engine.py:33 | AttackEngine.refine |
| LLM 校验过滤 | deepteam/attacks/attack_engine/attack_engine.py:207 | _validate_candidates_with_llm |
| 克隆用例 + 成本均摊 | deepteam/attacks/attack_engine/attack_engine.py:273 | _clone_with_new_input |
| 精炼挂接点 | deepteam/vulnerabilities/base_vulnerability.py:63 | _refine_simulated_attacks |
| transform/校验 prompt 模板 | deepteam/attacks/attack_engine/template.py:6 | AttackEngineTemplates |
| 精炼 schema | deepteam/attacks/attack_engine/types.py:6 | TransformedAttack / AttackVariations / ValidationResult |
| 攻击基类 + 增强契约 | deepteam/attacks/base_attack.py:12 | BaseAttack / Exploitability |
| 单轮攻击基类 | deepteam/attacks/single_turn/base_single_turn_attack.py:19 | BaseSingleTurnAttack |
| 算法式增强实例 | deepteam/attacks/single_turn/base64/base64.py:6 | Base64.enhance |
| 算法式增强实例(轮换) | deepteam/attacks/single_turn/rot13/rot13.py:5 | ROT13.enhance |
| 模型式增强实例(带重试) | deepteam/attacks/single_turn/gray_box/gray_box.py:25 | GrayBox.enhance |