Skip to content

Commit 53984a5

Browse files
committed
Improve skills code review agent governance
1 parent 1483e54 commit 53984a5

19 files changed

Lines changed: 1275 additions & 270 deletions
Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
# 方案设计
22

3-
本示例以 `code-review` Skill 封装 `SKILL.md`、规则、脚本和 Filter 配置。Agent 可读取 diff、patch、git 工作区、文件列表或 fixture,解析变更文件、hunk、上下文和行号。主审查流水线会审计 Skill 文件哈希并加载规则;同时提供 `--skill-smoke` 和测试用例,真实调用 SDK 的 `skill_load(skill_name="code-review")``skill_run` 执行 `scripts/diff_summary.py`,证明该 Skill 能被 tRPC-Agent 原生工具链加载和运行。无模型 Key 时使用确定性规则、AST/taint 辅助和 fake sandbox 完成 dry-run;生产接入 Container 或 Cube/E2B workspace,`local` 仅作开发 fallback。
3+
本示例以 `code-review` Skill 封装 `SKILL.md`、规则、脚本和 Filter 配置。Agent 可读取 diff、patch、git 工作区、文件列表或 fixture,解析变更文件、hunk、上下文和行号。主审查流水线会审计 Skill 文件哈希并加载规则;普通评审不在沙箱策略外执行 SDK `skill_run``--skill-smoke` 可单独调用 `skill_load(skill_name="code-review")``skill_run` 验证原生 Skill 链路。无模型 Key 时使用确定性规则、AST/taint 辅助和 fake sandbox 完成 dry-run;CLI 与 `run_review()` API 默认生产沙箱为 Container,也可接入 Cube/E2B workspace,`local` 仅作开发 fallback。
44

5-
规则覆盖安全、异步错误、资源泄漏、测试缺失、敏感信息、数据库事务和连接生命周期。沙箱请求先经过 Filter,拦截高风险命令、敏感路径、非白名单网络和超预算执行`deny``needs_human_review` 写入报告和数据库,不能执行。默认 `python:3-slim` 验证容器隔离链路,`Dockerfile.scanners` 提供带 `bandit``ruff``detect-secrets` 的镜像,用于实际离线 scanner 执行。SQLite 保存 task、输入、sandbox run、Filter 决策、finding、监控和报告,并通过 `ReviewStore` 保留 SQL 后端扩展点。报告输出 JSON/Markdown,高置信进入 findings,中置信进入 warnings,低置信进入人工复核;同一文件、行号、类别只保留最高优先级项。所有 diff、stdout、stderr、Filter reason 和 finding evidence 在落库前脱敏,并记录耗时、工具调用、拦截、异常、严重级别分布和去重数量,便于后续回放和审计。
5+
规则覆盖安全、异步错误、资源泄漏、测试缺失、敏感信息、数据库事务和连接生命周期。沙箱请求先经过 Filter,拦截高风险命令、敏感路径、声明型网络 scanner 与测试命令 URL/domain 的非白名单网络访问、以及超预算执行;`deny` 与 `needs_human_review` 写入报告和数据库,不能执行。默认 `python:3-slim` 验证容器隔离链路,`Dockerfile.scanners` 提供带 `bandit`、`ruff`、`detect-secrets` 的镜像,用于实际离线 scanner 执行;scanner 物化 diff 文件时会拒绝路径逃逸。SQLite 保存 task、输入、sandbox run、Filter 决策、finding、监控和报告,并通过 `ReviewStore` 保留 SQL 后端扩展点。报告输出 JSON/Markdown,高置信进入 findings,中置信进入 warnings,低置信进入人工复核;同一文件、行号、类别只保留最高优先级项。所有 diff、stdout、stderr、Filter reason 和 finding evidence 在落库前脱敏,stdout/stderr 共享输出总预算,并记录耗时、工具调用、拦截、异常、严重级别分布和去重数量,便于后续回放和审计。

examples/skills_code_review_agent/README.md

Lines changed: 61 additions & 55 deletions
Large diffs are not rendered by default.

examples/skills_code_review_agent/agent/filter_policy.py

Lines changed: 63 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
import re
77
from dataclasses import dataclass
88
from pathlib import Path
9+
from urllib.parse import urlparse
910

1011
from .models import DiffInput
1112
from .models import FilterDecision
@@ -37,6 +38,15 @@
3738
r":\(\)\s*\{",
3839
)
3940

41+
NETWORK_COMMAND_PATTERNS = (
42+
r"\b(pip|pip3|python(?:3)?\s+-m\s+pip)\s+install\b",
43+
r"\b(npm|pnpm|yarn)\s+(install|add|ci)\b",
44+
r"\b(go\s+get|cargo\s+install)\b",
45+
r"\b(git)\s+(clone|fetch|pull|submodule\s+update)\b",
46+
r"\b(ssh|scp|sftp|rsync)\b",
47+
r"\b(apt-get|apt|apk|yum|dnf|brew)\s+(install|update|upgrade)\b",
48+
)
49+
4050

4151
@dataclass(slots=True)
4252
class SandboxRequest:
@@ -66,6 +76,7 @@ def __init__(
6676
sandbox_path_allowlist: tuple[str, ...] = ("scripts/", "work/"),
6777
sandbox_read_allowlist: tuple[str, ...] = ("scripts/", "work/", "repo/"),
6878
sandbox_write_allowlist: tuple[str, ...] = ("work/", ),
79+
network_command_patterns: tuple[str, ...] = NETWORK_COMMAND_PATTERNS,
6980
schema_version: int = 1,
7081
):
7182
self.network_policy = network_policy
@@ -77,6 +88,7 @@ def __init__(
7788
self.sandbox_path_allowlist = sandbox_path_allowlist
7889
self.sandbox_read_allowlist = sandbox_read_allowlist
7990
self.sandbox_write_allowlist = sandbox_write_allowlist
91+
self.network_command_patterns = network_command_patterns
8092
self.schema_version = schema_version
8193
self.last_redaction_count = 0
8294

@@ -102,6 +114,7 @@ def load(
102114
sandbox_path_allowlist=tuple(data.get("sandbox_path_allowlist", ("scripts/", "work/"))),
103115
sandbox_read_allowlist=tuple(data.get("sandbox_read_allowlist", ("scripts/", "work/", "repo/"))),
104116
sandbox_write_allowlist=tuple(data.get("sandbox_write_allowlist", ("work/", ))),
117+
network_command_patterns=tuple(data.get("network_command_patterns", NETWORK_COMMAND_PATTERNS)),
105118
schema_version=int(data.get("schema_version", 1)),
106119
)
107120

@@ -117,6 +130,7 @@ def audit(self) -> dict[str, object]:
117130
"sandbox_path_allowlist": list(self.sandbox_path_allowlist),
118131
"sandbox_read_allowlist": list(self.sandbox_read_allowlist),
119132
"sandbox_write_allowlist": list(self.sandbox_write_allowlist),
133+
"network_command_patterns": list(self.network_command_patterns),
120134
}
121135

122136
def evaluate(
@@ -241,6 +255,41 @@ def _evaluate_request(self, request: SandboxRequest) -> FilterDecision:
241255
policy="high-risk-command",
242256
severity="high",
243257
)
258+
command_domains = _network_domains_in_command(command_to_check)
259+
if command_domains:
260+
if self.network_policy != "allowlist":
261+
return self._decision(
262+
decision="needs_human_review",
263+
reason="Command references network domains while the active review policy denies network access: "
264+
f"{', '.join(command_domains)}.",
265+
command=command_to_check,
266+
path=request.script_path,
267+
policy="network-command",
268+
severity="high",
269+
)
270+
disallowed = sorted(set(command_domains) - set(self.allowed_network_domains))
271+
if disallowed:
272+
return self._decision(
273+
decision="needs_human_review",
274+
reason=f"Command references network domains outside the configured allowlist: "
275+
f"{', '.join(disallowed)}.",
276+
command=command_to_check,
277+
path=request.script_path,
278+
policy="network-command-allowlist",
279+
severity="high",
280+
)
281+
if not command_domains and _is_network_command(command_to_check, self.network_command_patterns):
282+
return self._decision(
283+
decision="needs_human_review",
284+
reason=(
285+
"Command appears to require network access but does not declare reviewable allowlisted "
286+
"network domains."
287+
),
288+
command=command_to_check,
289+
path=request.script_path,
290+
policy="network-command-implicit",
291+
severity="high",
292+
)
244293
return self._decision(
245294
decision="allow",
246295
reason=(
@@ -283,7 +332,20 @@ def _is_forbidden_path(path: str, markers: tuple[str, ...] = FORBIDDEN_PATH_MARK
283332

284333

285334
def _is_high_risk_command(command: str, patterns: tuple[str, ...] = HIGH_RISK_COMMAND_PATTERNS) -> bool:
286-
return any(re.search(pattern, command) for pattern in patterns)
335+
return any(re.search(pattern, command, re.IGNORECASE) for pattern in patterns)
336+
337+
338+
def _network_domains_in_command(command: str) -> tuple[str, ...]:
339+
domains: set[str] = set()
340+
for match in re.finditer(r"https?://[^\s'\"),]+", command, re.IGNORECASE):
341+
parsed = urlparse(match.group(0))
342+
if parsed.hostname:
343+
domains.add(parsed.hostname.lower())
344+
return tuple(sorted(domains))
345+
346+
347+
def _is_network_command(command: str, patterns: tuple[str, ...] = NETWORK_COMMAND_PATTERNS) -> bool:
348+
return any(re.search(pattern, command, re.IGNORECASE) for pattern in patterns)
287349

288350

289351
def _path_is_allowed(path: str, allowlist: tuple[str, ...]) -> bool:

examples/skills_code_review_agent/agent/native_agent.py

Lines changed: 40 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -19,34 +19,61 @@
1919
async def code_review_tool(
2020
*,
2121
diff_file: str | None = None,
22+
patch_file: str | None = None,
2223
repo_path: str | None = None,
24+
file_list: str | None = None,
2325
fixture: str | None = None,
2426
output_dir: str | None = None,
2527
db_path: str | None = None,
2628
db_url: str | None = None,
27-
dry_run: bool = True,
29+
sandbox: str = "container",
30+
dry_run: bool = False,
2831
container_image: str = "python:3-slim",
32+
docker_path: str | None = None,
33+
docker_base_url: str | None = None,
34+
cube_template: str | None = None,
35+
cube_api_url: str | None = None,
36+
cube_api_key: str | None = None,
37+
cube_sandbox_id: str | None = None,
38+
timeout_sec: float = 5.0,
39+
max_output_bytes: int = 12000,
40+
filter_timeout_budget_sec: float = 30.0,
41+
filter_max_output_bytes: int = 20000,
42+
network_policy: str = "deny",
43+
test_command: str | None = None,
44+
custom_rule_script: str | None = None,
2945
include_network_scanners: bool = False,
46+
max_diff_bytes: int = 2_000_000,
3047
) -> dict[str, Any]:
3148
"""Run the Skills code review pipeline as a FunctionTool-compatible callable."""
32-
sandbox_runner = None
33-
sandbox = "fake"
34-
if not dry_run:
35-
from .runtime_factory import create_container_sandbox_runner
36-
37-
sandbox = "container"
38-
sandbox_runner = create_container_sandbox_runner(image=container_image)
49+
effective_sandbox = "fake" if dry_run else sandbox
3950
report = await run_review(
4051
diff_file=Path(diff_file) if diff_file else None,
52+
patch_file=Path(patch_file) if patch_file else None,
4153
repo_path=Path(repo_path) if repo_path else None,
54+
file_list=Path(file_list) if file_list else None,
4255
fixture=fixture,
4356
output_dir=Path(output_dir) if output_dir else DEFAULT_OUTPUT_DIR,
4457
db_path=Path(db_path) if db_path else DEFAULT_DB_PATH,
4558
db_url=db_url,
4659
dry_run=dry_run,
47-
sandbox=sandbox,
48-
sandbox_runner=sandbox_runner,
60+
sandbox=effective_sandbox,
61+
container_image=container_image,
62+
docker_path=docker_path,
63+
docker_base_url=docker_base_url,
64+
cube_template=cube_template,
65+
cube_api_url=cube_api_url,
66+
cube_api_key=cube_api_key,
67+
cube_sandbox_id=cube_sandbox_id,
68+
timeout_sec=timeout_sec,
69+
max_output_bytes=max_output_bytes,
70+
filter_timeout_budget_sec=filter_timeout_budget_sec,
71+
filter_max_output_bytes=filter_max_output_bytes,
72+
network_policy=network_policy,
73+
test_command=test_command,
74+
custom_rule_script=custom_rule_script,
4975
include_network_scanners=include_network_scanners,
76+
max_diff_bytes=max_diff_bytes,
5077
)
5178
return {
5279
"task_id": report.task_id,
@@ -70,6 +97,7 @@ def create_code_review_skill_tool_set(*, workspace_runtime):
7097
repository = create_default_skill_repository(
7198
str(SKILL_DIR.parent),
7299
workspace_runtime=workspace_runtime,
100+
enable_hot_reload=False,
73101
use_cached_repository=True,
74102
)
75103
return SkillToolSet(repository=repository, skill_stager=LinkSkillStager()), repository
@@ -88,10 +116,8 @@ def create_code_review_agent(*, model, skill_workspace_runtime=None):
88116

89117
return LlmAgent(
90118
name="skills_code_review_agent",
91-
description=(
92-
"Automatic code review agent backed by Skills, sandbox execution, "
93-
"Filter governance, and SQLite persistence."
94-
),
119+
description=("Automatic code review agent backed by Skills, sandbox execution, "
120+
"Filter governance, and SQLite persistence."),
95121
model=model,
96122
instruction=INSTRUCTION,
97123
tools=tools,

0 commit comments

Comments
 (0)